fix: close issue #2 about account missing page

This commit is contained in:
2026-08-24 10:09:51 +02:00
parent 93a59d1deb
commit 105580313f
9 changed files with 784 additions and 167 deletions
+17
View File
@@ -91,3 +91,20 @@ export const inviteUserSchema = v.object({
role,
username: v.optional(v.string(), '')
});
export const updateProfileSchema = v.object({
name: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())),
username
});
export const changePasswordSchema = v.pipe(
v.object({ _confirm: confirm, _current: v.optional(v.string(), ''), newPassword }),
v.forward(
v.partialCheck(
[['newPassword'], ['_confirm']],
(input) => input.newPassword === input._confirm,
m.errors_passwords_no_match()
),
['_confirm']
)
);
+3 -3
View File
@@ -108,7 +108,9 @@ function build() {
// ponytail: single-slot cache, rebuilt on config edits via invalidateAuth().
// In-flight rate-limit counters and 2FA flow state reset on invalidation.
let cached: ReturnType<typeof build> | null = null;
let cached: null | ReturnType<typeof build> = null;
export type Auth = ReturnType<typeof build>;
export function getAuth(): ReturnType<typeof build> {
return (cached ??= build());
@@ -117,5 +119,3 @@ export function getAuth(): ReturnType<typeof build> {
export function invalidateAuth(): void {
cached = null;
}
export type Auth = ReturnType<typeof build>;
@@ -8,6 +8,7 @@
const LABELS: Record<string, () => string> = {
'/': m.home,
account: m.account,
admin: m.nav_admin,
config: m.nav_admin_config,
configure: m.networking_configure,
@@ -3,6 +3,7 @@
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
@@ -61,8 +62,12 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<BadgeCheckIcon />
{m.account()}
{#snippet child({ props })}
<a href={resolve('/account')} {...props}>
<BadgeCheckIcon />
{m.account()}
</a>
{/snippet}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
+157
View File
@@ -0,0 +1,157 @@
import { error, redirect } from '@sveltejs/kit';
import { command, form, getRequestEvent, query } from '$app/server';
import { v } from '$lib';
import { changePasswordSchema, updateProfileSchema } from '$lib/auth/schemas';
import { getAuth, oauthConfig } from '$lib/auth/server';
import { m } from '$lib/paraglide/messages';
import { getConfig } from '$lib/server/config';
import { extractErrorMessage } from '$lib/utils';
function ctx() {
const { locals, request } = getRequestEvent();
if (!locals.user) redirect(307, '/auth/sign-in');
return { headers: request.headers, session: locals.session, user: locals.user };
}
const fail = (e: unknown) => {
console.log(e);
error(400, { message: extractErrorMessage(e) ?? m.errors_generic() });
};
export const getAccount = query(async () => {
const { headers, session, user } = ctx();
const cfg = getConfig();
const auth = getAuth();
const accounts = await auth.api.listUserAccounts({ headers });
const sessions = await auth.api.listSessions({ headers });
const linked = new Set(accounts.map((a) => a.providerId));
const configured = [
cfg.FACEBOOK_CLIENT_ID ? 'facebook' : undefined,
cfg.GITHUB_CLIENT_ID ? 'github' : undefined,
cfg.GOOGLE_CLIENT_ID ? 'google' : undefined
].filter((p): p is string => Boolean(p));
return {
accounts: accounts.map((a) => ({
accountId: a.accountId,
createdAt: a.createdAt,
id: a.id,
providerId: a.providerId
})),
hasPassword: linked.has('credential'),
// Providers that are configured but not linked yet.
linkable: {
generic: oauthConfig.map((o) => o.providerId).filter((p) => !linked.has(p)),
social: configured.filter((p) => !linked.has(p))
},
sessions: sessions.map((s) => ({
createdAt: s.createdAt,
current: s.token === session?.token,
expiresAt: s.expiresAt,
id: s.id,
ipAddress: s.ipAddress ?? '',
token: s.token,
userAgent: s.userAgent ?? ''
})),
twoFactorRequired: cfg.ENABLE_2FA ?? false,
user: {
createdAt: user.createdAt,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
twoFactorEnabled: user.twoFactorEnabled ?? false,
username: user.username ?? ''
}
};
});
export const updateProfile = form(updateProfileSchema, async ({ name, username }) => {
const { headers } = ctx();
try {
await getAuth().api.updateUser({
body: { displayUsername: username, name, username },
headers
});
} catch (e) {
fail(e);
}
await getAccount().refresh();
});
// Same form for both cases: with a credential account it's a change (current password
// required), without one it's the initial password for an OAuth-only user.
export const changePassword = form(changePasswordSchema, async ({ _current, newPassword }) => {
const { headers } = ctx();
const auth = getAuth();
const accounts = await auth.api.listUserAccounts({ headers });
try {
if (accounts.some((a) => a.providerId === 'credential')) {
if (!_current) error(400, { message: m.errors_non_empty() });
await auth.api.changePassword({
body: { currentPassword: _current, newPassword, revokeOtherSessions: true },
headers
});
} else {
await auth.api.setPassword({ body: { newPassword }, headers });
}
} catch (e) {
fail(e);
}
await getAccount().refresh();
});
export const sendResetLink = command(async () => {
const { user } = ctx();
try {
await getAuth().api.requestPasswordReset({
body: { email: user.email, redirectTo: getConfig().ORIGIN + '/auth/reset-password' }
});
} catch (e) {
fail(e);
}
});
export const resendVerification = command(async () => {
const { user } = ctx();
try {
await getAuth().api.sendVerificationEmail({
body: { callbackURL: '/account', email: user.email }
});
} catch (e) {
fail(e);
}
});
export const disableTwoFactor = command(v.string(), async (password) => {
const { headers } = ctx();
if (getConfig().ENABLE_2FA) error(403, { message: m.account_2fa_enforced() });
try {
await getAuth().api.disableTwoFactor({ body: { password }, headers });
} catch (e) {
fail(e);
}
await getAccount().refresh();
});
export const unlinkAccount = command(
v.object({ accountId: v.string(), providerId: v.string() }),
async ({ accountId, providerId }) => {
const { headers } = ctx();
try {
await getAuth().api.unlinkAccount({ body: { accountId, providerId }, headers });
} catch (e) {
fail(e);
}
await getAccount().refresh();
}
);
export const revokeSession = command(v.string(), async (token) => {
const { headers } = ctx();
try {
await getAuth().api.revokeSession({ body: { token }, headers });
} catch (e) {
fail(e);
}
await getAccount().refresh();
});
+7 -3
View File
@@ -8,9 +8,13 @@ import type { MailPayload } from './schemas';
export async function sendMail(data: MailPayload) {
const cfg = getConfig();
if (!cfg.SMTP_HOST) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { html: _html, ...log } = data;
console.log('\n%s\n\n', log.plainText);
// ponytail: no SMTP configured, dump the mail to stdout instead of dropping it
console.log(
'\n[email] no SMTP_HOST — not sent\nTo: %s\nSubject: %s\n\n%s\n',
data.to,
data.subject,
data.plainText
);
return;
}
+4 -1
View File
@@ -17,7 +17,10 @@
let { children } = $props();
const user = $derived(getUser());
const showSidebar = (cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'));
cU &&
(page.url.pathname.startsWith('/dashboard') ||
page.url.pathname.startsWith('/admin') ||
page.url.pathname.startsWith('/account'));
class TerminalState {
open = $state(false);
+390
View File
@@ -0,0 +1,390 @@
<script lang="ts">
import KeyRoundIcon from '@lucide/svelte/icons/key-round';
import LinkIcon from '@lucide/svelte/icons/link';
import MailCheckIcon from '@lucide/svelte/icons/mail-check';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import { resolve } from '$app/paths';
import { env } from '$env/dynamic/public';
import { getAuthClient } from '$lib/auth/client';
import { changePasswordSchema, updateProfileSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
changePassword,
disableTwoFactor,
getAccount,
resendVerification,
revokeSession,
sendResetLink,
unlinkAccount,
updateProfile
} from '$lib/remotes/account.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const authClient = getAuthClient();
const id = $props.id();
const account = $derived(await getAccount());
let disableOpen = $state(false);
let disablePassword = $state('');
let busy = $state(false);
const fmt = (d: Date | string) => new Date(d).toLocaleString();
const providerLabel = (p: string) =>
p === 'credential' ? m.account_provider_credential() : p.charAt(0).toUpperCase() + p.slice(1);
async function run(fn: () => Promise<unknown>, done: string) {
busy = true;
try {
await fn();
toast.success(done);
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
} finally {
busy = false;
}
}
async function link(provider: string, generic: boolean) {
const callbackURL = new URL('/account', env.PUBLIC_ORIGIN).href;
const { error } = generic
? await authClient.oauth2.link({ callbackURL, providerId: provider })
: await authClient.linkSocial({
callbackURL,
provider: provider as 'facebook' | 'github' | 'google'
});
if (error) toast.error(error.message || m.errors_generic());
}
</script>
<PageMeta title={m.seo_title_account()} description={m.seo_desc_account()} noIndex />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.account()}</h1>
<p class="text-muted-foreground text-sm">{m.account_description()}</p>
</div>
<!-- Profile + email -->
<Card.Root>
<Card.Header>
<Card.Title>{m.account_profile_title()}</Card.Title>
<Card.Description>
{m.account_profile_description()} · {m.account_member_since({
date: fmt(account.user.createdAt)
})}
</Card.Description>
</Card.Header>
<Card.Content>
<form
oninput={() => updateProfile.validate()}
{...updateProfile.preflight(updateProfileSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.saved());
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="name-{id}">{m.name()}</Field.Label>
<Input
id="name-{id}"
{...updateProfile.fields.name.as('text')}
value={account.user.name}
/>
{#each updateProfile.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="username-{id}">{m.username()}</Field.Label>
<Input
id="username-{id}"
autocomplete="username"
{...updateProfile.fields.username.as('text')}
value={account.user.username}
/>
{#each updateProfile.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label>{m.email()}</Field.Label>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm">{account.user.email}</span>
{#if account.user.emailVerified}
<Badge variant="secondary"
><MailCheckIcon class="size-3" />{m.account_email_verified()}</Badge
>
{:else}
<Badge variant="destructive">{m.account_email_unverified()}</Badge>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onclick={() => run(() => resendVerification(), m.account_verification_sent())}
>
{m.account_resend_verification()}
</Button>
{/if}
</div>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={!!updateProfile.pending}>{m.save()}</Button>
</Field.Field>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
<!-- Password -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<KeyRoundIcon class="size-4" />
{account.hasPassword ? m.account_password_title() : m.account_password_set_title()}
</Card.Title>
<Card.Description>
{account.hasPassword
? m.account_password_description()
: m.account_password_set_description()}
</Card.Description>
</Card.Header>
<Card.Content>
<form
oninput={() => changePassword.validate()}
{...changePassword.preflight(changePasswordSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.account_password_changed());
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
}
})}
>
<Field.Group>
{#if account.hasPassword}
<Field.Field>
<Field.Label for="current-{id}">{m.account_current_password()}</Field.Label>
<Input
id="current-{id}"
autocomplete="current-password"
{...changePassword.fields._current.as('password')}
required
/>
</Field.Field>
{/if}
<Field.Field>
<Field.Label for="new-{id}">{m.new_password()}</Field.Label>
<Input
id="new-{id}"
autocomplete="new-password"
{...changePassword.fields.newPassword.as('password')}
required
/>
<Field.Description>{m.password_hint()}</Field.Description>
{#each changePassword.fields.newPassword.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="confirm-{id}">{m.confirm_password()}</Field.Label>
<Input
id="confirm-{id}"
autocomplete="new-password"
{...changePassword.fields._confirm.as('password')}
required
/>
{#each changePassword.fields._confirm.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field orientation="horizontal">
<Button type="submit" disabled={!!changePassword.pending}>{m.save()}</Button>
<Button
type="button"
variant="outline"
disabled={busy}
onclick={() => run(() => sendResetLink(), m.reset_link_sent())}
>
{m.account_send_reset_link()}
</Button>
</Field.Field>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
<!-- Two-factor -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<ShieldCheckIcon class="size-4" />
{m.account_2fa_title()}
</Card.Title>
<Card.Description>{m.account_2fa_description()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-wrap items-center gap-3">
<Badge variant={account.user.twoFactorEnabled ? 'secondary' : 'outline'}>
{account.user.twoFactorEnabled ? m.account_2fa_enabled() : m.account_2fa_disabled()}
</Badge>
{#if !account.user.twoFactorEnabled}
<Button href={resolve('/auth/setup-2fa')} variant="outline" size="sm">
{m.account_2fa_enable()}
</Button>
{:else if account.twoFactorRequired}
<span class="text-muted-foreground text-sm">{m.account_2fa_enforced()}</span>
{:else}
<Button variant="outline" size="sm" onclick={() => (disableOpen = true)}>
{m.account_2fa_disable()}
</Button>
{/if}
</Card.Content>
</Card.Root>
<!-- Connected accounts -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<LinkIcon class="size-4" />
{m.account_providers_title()}
</Card.Title>
<Card.Description>{m.account_providers_description()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
{#each account.accounts as acc (acc.id)}
<div class="flex flex-wrap items-center gap-2 rounded-md border p-3">
<span class="font-medium">{providerLabel(acc.providerId)}</span>
<span class="text-muted-foreground text-sm">
{m.account_linked_on({ date: fmt(acc.createdAt) })}
</span>
<Button
class="ms-auto"
variant="outline"
size="sm"
disabled={busy || account.accounts.length < 2}
title={account.accounts.length < 2 ? m.account_unlink_last_hint() : undefined}
onclick={() =>
run(
() => unlinkAccount({ accountId: acc.accountId, providerId: acc.providerId }),
m.account_unlinked()
)}
>
{m.account_unlink()}
</Button>
</div>
{/each}
{#if account.linkable.social.length || account.linkable.generic.length}
<div class="flex flex-wrap gap-2 pt-2">
{#each account.linkable.social as provider (provider)}
<Button variant="outline" size="sm" onclick={() => link(provider, false)}>
{m.account_link()} · {providerLabel(provider)}
</Button>
{/each}
{#each account.linkable.generic as provider (provider)}
<Button variant="outline" size="sm" onclick={() => link(provider, true)}>
{m.account_link()} · {providerLabel(provider)}
</Button>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- Sessions -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<MonitorIcon class="size-4" />
{m.account_sessions_title()}
</Card.Title>
<Card.Description>{m.account_sessions_description()}</Card.Description>
</Card.Header>
<Card.Content>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.account_sessions_title()}</Table.Head>
<Table.Head class="hidden md:table-cell">IP</Table.Head>
<Table.Head class="hidden md:table-cell">{m.account_session_expires()}</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each account.sessions as s (s.id)}
<Table.Row>
<Table.Cell class="max-w-[24ch] truncate" title={s.userAgent}>
{s.userAgent || '—'}
{#if s.current}<Badge variant="secondary">{m.account_session_current()}</Badge>{/if}
</Table.Cell>
<Table.Cell class="hidden md:table-cell">{s.ipAddress || '—'}</Table.Cell>
<Table.Cell class="hidden md:table-cell">{fmt(s.expiresAt)}</Table.Cell>
<Table.Cell class="text-end">
{#if !s.current}
<Button
variant="ghost"
size="sm"
disabled={busy}
onclick={() => run(() => revokeSession(s.token), m.account_session_revoked())}
>
{m.account_revoke()}
</Button>
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground text-center">
{m.account_no_sessions()}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</div>
<Dialog.Root bind:open={disableOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.account_2fa_disable()}</Dialog.Title>
<Dialog.Description>{m.enter_password_to_continue()}</Dialog.Description>
</Dialog.Header>
<Field.Field>
<Field.Label for="disable-2fa-{id}">{m.password()}</Field.Label>
<Input
id="disable-2fa-{id}"
type="password"
autocomplete="current-password"
bind:value={disablePassword}
/>
</Field.Field>
<Dialog.Footer>
<Button variant="outline" onclick={() => (disableOpen = false)}>{m.cancel()}</Button>
<Button
disabled={busy || !disablePassword}
onclick={async () => {
await run(() => disableTwoFactor(disablePassword), m.account_2fa_disabled_done());
disablePassword = '';
disableOpen = false;
}}
>
{m.account_2fa_disable()}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>