fix: close issue #2 about account missing page
This commit is contained in:
@@ -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']
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user