Merge branch 'workstream-i'

This commit is contained in:
RevIQ
2026-01-09 18:12:45 +08:00
30 changed files with 2365 additions and 9 deletions

View File

@@ -109,7 +109,13 @@ const verifyRegistration = os.auth.webauthn.verifyRegistration
context.allowedOrigins,
context.rpName,
);
await verifyReg(context.db, rpInfo, context.user.id, challengeId, response);
return verifyReg(
context.db,
rpInfo,
context.user.id,
challengeId,
response,
);
});
const createAuthenticationOptions = os.auth.webauthn.createAuthenticationOptions
@@ -162,6 +168,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
@@ -176,6 +183,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
};
});

View File

@@ -157,7 +157,7 @@ export const verifyRegistration = async (
userId: number,
challengeId: number,
response: RegistrationResponseJSON,
): Promise<void> => {
): Promise<{ passkeyId: number }> => {
// Fetch the challenge
const challengeRow = await db
.selectFrom("webauthn_challenges")
@@ -207,7 +207,7 @@ export const verifyRegistration = async (
guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`;
// Store the passkey
await db
const { id: passkeyId } = await db
.insertInto("passkeys")
.values({
user_id: userId,
@@ -222,7 +222,10 @@ export const verifyRegistration = async (
rpid: rpInfo.rpID,
name: passKeyName,
})
.execute();
.returning("id")
.executeTakeFirstOrThrow();
return { passkeyId: Number(passkeyId) };
};
/**

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import ClockIcon from "@lucide/svelte/icons/clock";
import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
interface Props {
class?: string;
}
let { class: className }: Props = $props();
const navItems = [
{ href: "/account", label: "Profile", icon: UserIcon },
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
];
function isActive(href: string, pathname: string): boolean {
if (href === "/account") {
return pathname === "/account";
}
return pathname.startsWith(href);
}
</script>
<nav
class={cn(
"bg-muted inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
>
{#each navItems as item}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={active ? "page" : undefined}
>
<item.icon class="size-4" />
<span class="hidden sm:inline">{item.label}</span>
</a>
{/each}
</nav>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { Key } from "@lucide/svelte";
import {
browserSupportsWebAuthn,
startRegistration,
} from "@simplewebauthn/browser";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
interface Props {
open: boolean;
email: string;
}
let { open = $bindable(false), email }: Props = $props();
const queryClient = useQueryClient();
let passkeyName = $state("");
let isSubmitting = $state(false);
let error = $state("");
// Check passkey support
const supportsPasskey = $derived(browserSupportsWebAuthn());
// Reset state when dialog closes
$effect(() => {
if (!open) {
passkeyName = "";
error = "";
isSubmitting = false;
}
});
const isValid = $derived(passkeyName.trim().length > 0);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isValid || isSubmitting || !supportsPasskey) {
return;
}
isSubmitting = true;
error = "";
try {
// Step 1: Get registration options
const { challengeId, options } =
await api.auth.webauthn.createRegistrationOptions({ email });
// Step 2: Start WebAuthn registration ceremony
const registrationResponse = await startRegistration({
optionsJSON: options,
});
// Step 3: Verify registration (authenticated endpoint) - returns the new passkey ID
const { passkeyId } = await api.auth.webauthn.verifyRegistration({
challengeId,
response: registrationResponse,
});
// Step 4: Rename the passkey to the user's chosen name
await api.me.passkeys.rename({
passkeyId,
name: passkeyName.trim(),
});
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
toast.success("Passkey added successfully!");
open = false;
} catch (err) {
if (err instanceof Error) {
// Handle WebAuthn cancellation
if (err.name === "NotAllowedError") {
error = "Passkey creation was cancelled. Please try again.";
} else {
error = err.message;
}
} else {
error = "An unexpected error occurred. Please try again.";
}
} finally {
isSubmitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Key class="h-6 w-6 text-primary" />
</div>
<Dialog.Title class="text-center">Add a passkey</Dialog.Title>
<Dialog.Description class="text-center">
{#if supportsPasskey}
Give your passkey a name, then follow your browser's prompts to create it.
{:else}
Your browser doesn't support passkeys. Please use a modern browser.
{/if}
</Dialog.Description>
</Dialog.Header>
{#if supportsPasskey}
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="passkey-name">Passkey name</Label>
<Input
id="passkey-name"
type="text"
placeholder="e.g., MacBook Pro, iPhone"
bind:value={passkeyName}
maxlength={100}
required
disabled={isSubmitting}
/>
<p class="text-xs text-muted-foreground">
Choose a name to help you identify this passkey later.
</p>
</div>
<ErrorAlert message={error} />
<Dialog.Footer class="flex-col gap-2 sm:flex-col">
<LoadingButton
type="submit"
class="w-full"
disabled={!isValid}
loading={isSubmitting}
loadingText="Creating passkey..."
>
Create passkey
</LoadingButton>
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => (open = false)}
disabled={isSubmitting}
>
Cancel
</Button>
</Dialog.Footer>
</form>
{:else}
<Dialog.Footer>
<Button
variant="outline"
class="w-full"
onclick={() => (open = false)}
>
Close
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { ErrorAlert, PasswordFormField } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { LoadingButton } from "$lib/components/ui/loading-button";
interface Props {
open: boolean;
hasExistingPassword: boolean;
}
let { open = $bindable(false), hasExistingPassword }: Props = $props();
const queryClient = useQueryClient();
let currentPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
let isSubmitting = $state(false);
let error = $state("");
// Reset state when dialog closes
$effect(() => {
if (!open) {
currentPassword = "";
newPassword = "";
confirmPassword = "";
error = "";
isSubmitting = false;
}
});
const passwordsMatch = $derived(newPassword === confirmPassword);
const isValid = $derived(
newPassword.length >= 8 &&
passwordsMatch &&
(!hasExistingPassword || currentPassword.length > 0),
);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isValid || isSubmitting) {
return;
}
isSubmitting = true;
error = "";
try {
await api.me.setPassword({
currentPassword: hasExistingPassword ? currentPassword : undefined,
newPassword,
});
await queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success(
hasExistingPassword
? "Password changed successfully!"
: "Password set successfully!",
);
open = false;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to set password";
} finally {
isSubmitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{hasExistingPassword ? "Change password" : "Set password"}
</Dialog.Title>
<Dialog.Description>
{#if hasExistingPassword}
Enter your current password and choose a new one.
{:else}
Create a password to enable password-based login.
{/if}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
{#if hasExistingPassword}
<PasswordFormField
id="currentPassword"
label="Current password"
bind:value={currentPassword}
placeholder="Enter current password"
autocomplete="current-password"
required
/>
{/if}
<PasswordFormField
id="newPassword"
label="New password"
bind:value={newPassword}
placeholder="Enter new password"
autocomplete="new-password"
showStrength
required
/>
<PasswordFormField
id="confirmPassword"
label="Confirm new password"
bind:value={confirmPassword}
placeholder="Confirm new password"
autocomplete="new-password"
error={confirmPassword && !passwordsMatch ? "Passwords do not match" : ""}
required
/>
<ErrorAlert message={error} />
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={isSubmitting}
>
Cancel
</Button>
<LoadingButton
type="submit"
disabled={!isValid}
loading={isSubmitting}
loadingText="Saving..."
>
{hasExistingPassword ? "Change password" : "Set password"}
</LoadingButton>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { AlertTriangle } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { LoadingButton } from "$lib/components/ui/loading-button";
interface Props {
open: boolean;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
loadingText?: string;
variant?: "default" | "destructive";
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
let {
open = $bindable(false),
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
loadingText = "Processing...",
variant = "default",
loading = false,
onConfirm,
}: Props = $props();
async function handleConfirm() {
await onConfirm();
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
{#if variant === "destructive"}
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle class="h-6 w-6 text-destructive" />
</div>
{/if}
<Dialog.Title class={variant === "destructive" ? "text-center" : ""}>
{title}
</Dialog.Title>
<Dialog.Description class={variant === "destructive" ? "text-center" : ""}>
{description}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class={variant === "destructive" ? "flex-col gap-2 sm:flex-col" : ""}>
{#if variant === "destructive"}
<LoadingButton
variant="destructive"
class="w-full"
loading={loading}
loadingText={loadingText}
onclick={handleConfirm}
>
{confirmText}
</LoadingButton>
<Button
variant="outline"
class="w-full"
onclick={() => (open = false)}
disabled={loading}
>
{cancelText}
</Button>
{:else}
<Button
variant="outline"
onclick={() => (open = false)}
disabled={loading}
>
{cancelText}
</Button>
<LoadingButton
loading={loading}
onclick={handleConfirm}
>
{confirmText}
</LoadingButton>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { AlertTriangle } from "@lucide/svelte";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
interface Props {
open: boolean;
}
let { open = $bindable(false) }: Props = $props();
const queryClient = useQueryClient();
let password = $state("");
let isDeleting = $state(false);
let error = $state("");
// Reset state when dialog closes
$effect(() => {
if (!open) {
password = "";
error = "";
isDeleting = false;
}
});
async function handleDelete(e: Event) {
e.preventDefault();
if (!password || isDeleting) {
return;
}
isDeleting = true;
error = "";
try {
await api.me.delete({ password });
// Clear all cached data
queryClient.clear();
toast.success("Your account has been deleted.");
open = false;
// Redirect to login
goto("/auth/login");
} catch (e) {
error = e instanceof Error ? e.message : "Failed to delete account";
isDeleting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle class="h-6 w-6 text-destructive" />
</div>
<Dialog.Title class="text-center">Delete your account?</Dialog.Title>
<Dialog.Description class="text-center">
This action cannot be undone. All your data will be permanently deleted,
including organization memberships.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleDelete} class="space-y-4">
<div class="space-y-2">
<Label for="delete-password">Enter your password to confirm</Label>
<Input
id="delete-password"
type="password"
placeholder="Your password"
bind:value={password}
required
autocomplete="current-password"
/>
</div>
<ErrorAlert message={error} />
<Dialog.Footer class="flex-col gap-2 sm:flex-col">
<LoadingButton
type="submit"
variant="destructive"
class="w-full"
disabled={!password}
loading={isDeleting}
loadingText="Deleting..."
>
Delete my account
</LoadingButton>
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => (open = false)}
disabled={isDeleting}
>
Cancel
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,7 @@
export { default as AccountNav } from "./account-nav.svelte";
export { default as AddPasskeyDialog } from "./add-passkey-dialog.svelte";
export { default as ChangePasswordDialog } from "./change-password-dialog.svelte";
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as DeleteAccountDialog } from "./delete-account-dialog.svelte";
export { default as PasskeyList } from "./passkey-list.svelte";
export { default as RenamePasskeyDialog } from "./rename-passkey-dialog.svelte";

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { Key, Pencil, Trash2 } from "@lucide/svelte";
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import ConfirmDialog from "./confirm-dialog.svelte";
import RenamePasskeyDialog from "./rename-passkey-dialog.svelte";
interface Passkey {
id: number;
name: string;
createdAt: Date;
lastUsedAt: Date | null;
}
interface Props {
passkeys: Passkey[];
hasPassword: boolean;
}
let { passkeys, hasPassword }: Props = $props();
const queryClient = useQueryClient();
let renameDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let selectedPasskey = $state<Passkey | null>(null);
let isDeleting = $state(false);
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string | null): string {
if (!date) {
return "Never";
}
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function openRename(passkey: Passkey) {
selectedPasskey = passkey;
renameDialogOpen = true;
}
function openDelete(passkey: Passkey) {
selectedPasskey = passkey;
deleteDialogOpen = true;
}
async function handleDelete() {
if (!selectedPasskey || isDeleting) {
return;
}
isDeleting = true;
try {
await api.me.passkeys.delete({ passkeyId: selectedPasskey.id });
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
toast.success("Passkey deleted successfully!");
deleteDialogOpen = false;
selectedPasskey = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete passkey");
} finally {
isDeleting = false;
}
}
const canDeletePasskey = $derived(hasPassword || passkeys.length > 1);
</script>
<div class="divide-y">
{#each passkeys as passkey (passkey.id)}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Key class="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">{passkey.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatDate(passkey.createdAt)} · Last used {formatRelativeTime(passkey.lastUsedAt)}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openRename(passkey)}
aria-label="Rename passkey"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => openDelete(passkey)}
disabled={!canDeletePasskey}
aria-label="Delete passkey"
title={!canDeletePasskey ? "Cannot delete last passkey without a password" : "Delete passkey"}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/each}
</div>
{#if selectedPasskey}
<RenamePasskeyDialog
bind:open={renameDialogOpen}
passkeyId={selectedPasskey.id}
currentName={selectedPasskey.name}
/>
<ConfirmDialog
bind:open={deleteDialogOpen}
title="Delete passkey?"
description="This will remove the passkey '{selectedPasskey.name}' from your account. You can add it again later."
confirmText="Delete"
variant="destructive"
loading={isDeleting}
onConfirm={handleDelete}
/>
{/if}

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
interface Props {
open: boolean;
passkeyId: number;
currentName: string;
}
let { open = $bindable(false), passkeyId, currentName }: Props = $props();
const queryClient = useQueryClient();
let name = $state("");
let isSubmitting = $state(false);
let error = $state("");
// Reset state when dialog opens/closes
$effect(() => {
if (open) {
name = currentName;
error = "";
} else {
isSubmitting = false;
}
});
const isValid = $derived(name.trim().length > 0 && name.trim() !== currentName);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isValid || isSubmitting) {
return;
}
isSubmitting = true;
error = "";
try {
await api.me.passkeys.rename({
passkeyId,
name: name.trim(),
});
await queryClient.invalidateQueries({ queryKey: ["passkeys"] });
toast.success("Passkey renamed successfully!");
open = false;
} catch (e) {
error = e instanceof Error ? e.message : "Failed to rename passkey";
} finally {
isSubmitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title>Rename passkey</Dialog.Title>
<Dialog.Description>
Give this passkey a name to help you identify it.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="passkey-name">Name</Label>
<Input
id="passkey-name"
type="text"
placeholder="e.g., MacBook Pro"
bind:value={name}
maxlength={100}
required
/>
</div>
<ErrorAlert message={error} />
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={isSubmitting}
>
Cancel
</Button>
<LoadingButton
type="submit"
disabled={!isValid}
loading={isSubmitting}
loadingText="Saving..."
>
Save
</LoadingButton>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps =
$props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { ComponentProps, Snippet } from "svelte";
import XIcon from "@lucide/svelte/icons/x";
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import DialogOverlay from "./dialog-overlay.svelte";
import DialogPortal from "./dialog-portal.svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
} = $props();
</script>
<DialogPortal {...portalProps}>
<DialogOverlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 flex w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border p-6 shadow-lg duration-200",
className
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
aria-label="Close dialog"
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-1.5", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-foreground text-lg font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps =
$props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps =
$props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Close from "./dialog-close.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Trigger from "./dialog-trigger.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Dialog,
Close as DialogClose,
Trigger as DialogTrigger,
Portal as DialogPortal,
Overlay as DialogOverlay,
Content as DialogContent,
Header as DialogHeader,
Footer as DialogFooter,
Title as DialogTitle,
Description as DialogDescription,
};

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { AccountNav } from "$lib/components/account";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<svelte:head>
<title>Account Settings - Publisher Dashboard</title>
</svelte:head>
<DashboardLayout title="Account Settings">
<div class="space-y-6">
<AccountNav />
<div class="max-w-2xl">
{@render children()}
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import { AlertCircle, Loader2, Trash2 } from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client";
import DeleteAccountDialog from "$lib/components/account/delete-account-dialog.svelte";
import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils";
import { validatePhone } from "$lib/utils/validation";
const queryClient = useQueryClient();
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Form state initialized from query data
let displayName = $state("");
let fullName = $state("");
let phoneNumber = $state("");
let avatarUrl = $state("");
let isSubmitting = $state(false);
let error = $state("");
let phoneError = $state("");
let avatarError = $state("");
let deleteDialogOpen = $state(false);
function isValidUrl(url: string): boolean {
if (!url) {
return true;
}
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function handleAvatarBlur() {
if (avatarUrl && !isValidUrl(avatarUrl)) {
avatarError = "Please enter a valid URL (http:// or https://)";
} else {
avatarError = "";
}
}
// Initialize form when data loads
$effect(() => {
if (userQuery.data) {
displayName = userQuery.data.displayName ?? "";
fullName = userQuery.data.fullName ?? "";
phoneNumber = userQuery.data.phoneNumber ?? "";
avatarUrl = userQuery.data.avatarUrl ?? "";
}
});
function handlePhoneBlur() {
const result = validatePhone(phoneNumber);
if (phoneNumber && !result.valid) {
phoneError = "Please enter a valid phone number (e.g., +1 555 123 4567)";
} else {
phoneError = "";
if (phoneNumber && result.formatted) {
phoneNumber = result.formatted;
}
}
}
const isValid = $derived(
displayName.trim().length >= 1 &&
displayName.trim().length <= 100 &&
(!phoneNumber || validatePhone(phoneNumber).valid) &&
isValidUrl(avatarUrl),
);
const hasChanges = $derived(
userQuery.data &&
(displayName !== (userQuery.data.displayName ?? "") ||
fullName !== (userQuery.data.fullName ?? "") ||
phoneNumber !== (userQuery.data.phoneNumber ?? "") ||
avatarUrl !== (userQuery.data.avatarUrl ?? "")),
);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isValid || isSubmitting) {
return;
}
isSubmitting = true;
error = "";
try {
await api.me.updateProfile({
displayName: displayName.trim(),
fullName: fullName.trim() || undefined,
phoneNumber: phoneNumber.trim() || undefined,
avatarUrl: avatarUrl.trim() || undefined,
});
await queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success("Profile updated successfully!");
} catch (e) {
error = e instanceof Error ? e.message : "Failed to save profile";
} finally {
isSubmitting = false;
}
}
function getInitials(name: string | null | undefined): string {
if (!name) {
return "?";
}
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
</script>
{#if userQuery.isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if userQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load user data. Please try again.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Profile Settings Card -->
<Card>
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
<CardDescription>
Update your personal information and profile picture.
</CardDescription>
</CardHeader>
<CardContent>
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Avatar Section -->
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-amber-500 to-orange-600 text-lg font-semibold text-white"
>
{#if avatarUrl}
<img
src={avatarUrl}
alt="Avatar"
class="h-full w-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
{getInitials(displayName || userQuery.data?.email)}
{/if}
</div>
<div class="flex-1 space-y-1">
<Label for="avatarUrl">Avatar URL</Label>
<Input
id="avatarUrl"
type="url"
placeholder="https://example.com/avatar.jpg"
bind:value={avatarUrl}
onblur={handleAvatarBlur}
class={cn("h-9", avatarError && "border-destructive")}
/>
{#if avatarError}
<p class="text-xs text-destructive">{avatarError}</p>
{/if}
</div>
</div>
<Separator />
<!-- Profile Fields -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="displayName">Display name <span class="text-destructive">*</span></Label>
<Input
id="displayName"
type="text"
placeholder="How should we call you?"
bind:value={displayName}
required
maxlength={100}
class="h-10"
/>
<p class="text-xs text-muted-foreground">
This will be shown to other team members
</p>
</div>
<div class="space-y-2">
<Label for="fullName">Full name</Label>
<Input
id="fullName"
type="text"
placeholder="Your full name (for invoices)"
bind:value={fullName}
maxlength={200}
class="h-10"
/>
</div>
<div class="space-y-2">
<Label for="phoneNumber">Phone number</Label>
<Input
id="phoneNumber"
type="tel"
placeholder="+1 555 123 4567"
bind:value={phoneNumber}
onblur={handlePhoneBlur}
class={cn("h-10", phoneError && "border-destructive")}
/>
{#if phoneError}
<p class="text-xs text-destructive">{phoneError}</p>
{:else}
<p class="text-xs text-muted-foreground">
Include country code for international numbers
</p>
{/if}
</div>
</div>
<ErrorAlert message={error} />
<LoadingButton
type="submit"
class="h-10"
disabled={!isValid || !hasChanges}
loading={isSubmitting}
loadingText="Saving..."
>
Save changes
</LoadingButton>
</form>
</CardContent>
</Card>
<!-- Danger Zone Card -->
<Card class="border-destructive/50">
<CardHeader>
<CardTitle class="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Permanently delete your account and all associated data.
</CardDescription>
</CardHeader>
<CardContent>
<p class="mb-4 text-sm text-muted-foreground">
Once you delete your account, there is no going back. This action is permanent
and will remove all your data, including organization memberships.
</p>
<Button
variant="destructive"
onclick={() => (deleteDialogOpen = true)}
>
<Trash2 class="mr-2 h-4 w-4" />
Delete account
</Button>
</CardContent>
</Card>
</div>
<DeleteAccountDialog bind:open={deleteDialogOpen} />
{/if}

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { AlertCircle, CheckCircle, Key, Loader2, Plus } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "$lib/api/client";
import AddPasskeyDialog from "$lib/components/account/add-passkey-dialog.svelte";
import ChangePasswordDialog from "$lib/components/account/change-password-dialog.svelte";
import PasskeyList from "$lib/components/account/passkey-list.svelte";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const passkeysQuery = createQuery(() => ({
queryKey: ["passkeys"],
queryFn: () => api.me.passkeys.list(),
}));
let passwordDialogOpen = $state(false);
let addPasskeyDialogOpen = $state(false);
const hasPassword = $derived(userQuery.data?.hasPassword ?? false);
</script>
{#if userQuery.isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if userQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load user data. Please try again.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Email Card -->
<Card>
<CardHeader>
<CardTitle>Email Address</CardTitle>
<CardDescription>
Your email is used for sign-in and notifications.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{userQuery.data?.email}</span>
{#if userQuery.data?.emailVerified}
<Badge variant="outline" class="text-green-600 border-green-600/30 bg-green-50">
<CheckCircle class="mr-1 h-3 w-3" />
Verified
</Badge>
{:else}
<Badge variant="outline" class="text-amber-600 border-amber-600/30 bg-amber-50">
Not verified
</Badge>
{/if}
</div>
</CardContent>
</Card>
<!-- Password Card -->
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
{#if hasPassword}
Change your password to keep your account secure.
{:else}
Set a password to enable password-based login.
{/if}
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Key class="h-4 w-4 text-muted-foreground" />
<span class="text-sm">
{#if hasPassword}
Password is set
{:else}
No password set
{/if}
</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => (passwordDialogOpen = true)}
>
{#if hasPassword}
Change password
{:else}
Set password
{/if}
</Button>
</div>
</CardContent>
</Card>
<!-- Passkeys Card -->
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>Passkeys</CardTitle>
<CardDescription>
Passkeys let you sign in securely without a password.
</CardDescription>
</div>
<Button
size="sm"
onclick={() => (addPasskeyDialogOpen = true)}
>
<Plus class="mr-1 h-4 w-4" />
Add passkey
</Button>
</div>
</CardHeader>
<CardContent>
{#if passkeysQuery.isLoading}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if passkeysQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load passkeys.</AlertDescription>
</Alert>
{:else if passkeysQuery.data && passkeysQuery.data.length > 0}
<PasskeyList
passkeys={passkeysQuery.data}
hasPassword={hasPassword}
/>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<Key class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No passkeys registered yet.</p>
<p class="text-xs text-muted-foreground">
Add a passkey for secure, passwordless sign-in.
</p>
</div>
{/if}
</CardContent>
</Card>
</div>
<ChangePasswordDialog
bind:open={passwordDialogOpen}
hasExistingPassword={hasPassword}
/>
<AddPasskeyDialog
bind:open={addPasskeyDialogOpen}
email={userQuery.data?.email ?? ""}
/>
{/if}

View File

@@ -0,0 +1,244 @@
<script lang="ts">
import {
AlertCircle,
Loader2,
MapPin,
Monitor,
Smartphone,
Star,
Tablet,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
const queryClient = useQueryClient();
const devicesQuery = createQuery(() => ({
queryKey: ["trustedDevices"],
queryFn: () => api.me.listTrustedDevices(),
}));
const currentDeviceQuery = createQuery(() => ({
queryKey: ["deviceInfo"],
queryFn: () => api.me.getDeviceInfo(),
}));
// Get current device fingerprint from comparison
const currentDeviceId = $derived(currentDeviceQuery.data?.id);
let confirmDialogOpen = $state(false);
let confirmAllDialogOpen = $state(false);
let selectedDeviceId = $state<number | null>(null);
let isRemoving = $state(false);
let isRemovingAll = $state(false);
function formatLocation(device: {
city: string | null;
region: string | null;
country: string | null;
}): string {
const parts = [device.city, device.region, device.country].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : "Unknown location";
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getDeviceIcon(name: string) {
const nameLower = name.toLowerCase();
if (
nameLower.includes("iphone") ||
nameLower.includes("android") ||
nameLower.includes("mobile")
) {
return Smartphone;
}
if (nameLower.includes("ipad") || nameLower.includes("tablet")) {
return Tablet;
}
return Monitor;
}
function openRemoveDialog(deviceId: number) {
selectedDeviceId = deviceId;
confirmDialogOpen = true;
}
async function handleRemoveTrust() {
if (!selectedDeviceId || isRemoving) {
return;
}
isRemoving = true;
try {
await api.me.untrustDevice({ deviceId: selectedDeviceId });
await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] });
toast.success("Device trust removed");
confirmDialogOpen = false;
selectedDeviceId = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove trust");
} finally {
isRemoving = false;
}
}
async function handleRemoveAllTrust() {
if (isRemovingAll) {
return;
}
isRemovingAll = true;
try {
await api.me.revokeAllTrustedDevices();
await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] });
toast.success("All trusted devices removed");
confirmAllDialogOpen = false;
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to remove trusted devices",
);
} finally {
isRemovingAll = false;
}
}
</script>
{#if devicesQuery.isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if devicesQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load devices. Please try again.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Trusted Devices</CardTitle>
<CardDescription>
Trusted devices can sign in with just a password, without requiring email verification.
</CardDescription>
</CardHeader>
<CardContent>
{#if devicesQuery.data && devicesQuery.data.length > 0}
<div class="divide-y">
{#each devicesQuery.data as device (device.id)}
{@const DeviceIcon = getDeviceIcon(device.name)}
{@const isCurrentDevice = device.id === currentDeviceId}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{device.name}</p>
{#if isCurrentDevice}
<Badge variant="outline" class="text-xs">
<Star class="mr-1 h-3 w-3" />
Current
</Badge>
{/if}
</div>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin class="h-3 w-3" />
<span>{formatLocation(device)}</span>
</div>
<p class="text-xs text-muted-foreground">
Last used {formatRelativeTime(device.lastUsedAt)}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onclick={() => openRemoveDialog(device.id)}
>
Remove trust
</Button>
</div>
{/each}
</div>
{#if devicesQuery.data.length > 1}
<div class="mt-4 border-t pt-4">
<Button
variant="outline"
class="w-full text-destructive hover:text-destructive"
onclick={() => (confirmAllDialogOpen = true)}
>
Remove all trusted devices
</Button>
</div>
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<Monitor class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No trusted devices.</p>
<p class="text-xs text-muted-foreground">
Trust a device during sign-in to skip email verification.
</p>
</div>
{/if}
</CardContent>
</Card>
</div>
<ConfirmDialog
bind:open={confirmDialogOpen}
title="Remove device trust?"
description="This device will need email verification for future password sign-ins."
confirmText="Remove trust"
variant="destructive"
loading={isRemoving}
onConfirm={handleRemoveTrust}
/>
<ConfirmDialog
bind:open={confirmAllDialogOpen}
title="Remove all trusted devices?"
description="All devices will require email verification for future password sign-ins."
confirmText="Remove all"
variant="destructive"
loading={isRemovingAll}
onConfirm={handleRemoveAllTrust}
/>
{/if}

View File

@@ -0,0 +1,312 @@
<script lang="ts">
import {
AlertCircle,
Key,
Loader2,
LogOut,
MapPin,
Monitor,
Smartphone,
Star,
Tablet,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
const queryClient = useQueryClient();
const sessionsQuery = createQuery(() => ({
queryKey: ["sessions"],
queryFn: () => api.me.listSessions(),
}));
let confirmDialogOpen = $state(false);
let confirmAllDialogOpen = $state(false);
let selectedSessionId = $state<number | null>(null);
let isRevoking = $state(false);
let isRevokingAll = $state(false);
// Split sessions into active and past
const activeSessions = $derived(
sessionsQuery.data?.filter((s) => s.revokedAt === null) ?? [],
);
const pastSessions = $derived(
sessionsQuery.data?.filter((s) => s.revokedAt !== null) ?? [],
);
function formatLocation(session: {
city: string | null;
region: string | null;
country: string | null;
}): string {
const parts = [session.city, session.region, session.country].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : "Unknown location";
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(d);
}
function parseUserAgent(userAgent: string): {
browser: string;
os: string;
deviceType: string;
} {
const parser = new UAParser(userAgent);
const browser = parser.getBrowser().name || "Unknown browser";
const os = parser.getOS().name || "Unknown OS";
const deviceType = parser.getDevice().type || "desktop";
return { browser, os, deviceType };
}
function getDeviceIcon(deviceType: string) {
switch (deviceType) {
case "mobile":
return Smartphone;
case "tablet":
return Tablet;
default:
return Monitor;
}
}
function openRevokeDialog(sessionId: number) {
selectedSessionId = sessionId;
confirmDialogOpen = true;
}
async function handleRevoke() {
if (!selectedSessionId || isRevoking) {
return;
}
isRevoking = true;
try {
await api.me.revokeSession({ sessionId: selectedSessionId });
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
toast.success("Session revoked");
confirmDialogOpen = false;
selectedSessionId = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke session");
} finally {
isRevoking = false;
}
}
async function handleRevokeAll() {
if (isRevokingAll) {
return;
}
isRevokingAll = true;
try {
await api.me.revokeAllSessions();
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
toast.success("All other sessions revoked");
confirmAllDialogOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke sessions");
} finally {
isRevokingAll = false;
}
}
</script>
{#if sessionsQuery.isLoading}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if sessionsQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load sessions. Please try again.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Active Sessions -->
<Card>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>
Devices that are currently signed in to your account.
</CardDescription>
</CardHeader>
<CardContent>
{#if activeSessions.length > 0}
<div class="divide-y">
{#each activeSessions as session (session.id)}
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
{@const DeviceIcon = getDeviceIcon(deviceType)}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{browser} on {os}</p>
{#if session.isCurrent}
<Badge variant="outline" class="text-xs">
<Star class="mr-1 h-3 w-3" />
Current
</Badge>
{/if}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<div class="flex items-center gap-1">
<MapPin class="h-3 w-3" />
<span>{formatLocation(session)}</span>
</div>
<span>·</span>
{#if session.trustedMode}
<div class="flex items-center gap-1">
<Key class="h-3 w-3" />
<span>via passkey</span>
</div>
{:else}
<span>via password</span>
{/if}
</div>
<p class="text-xs text-muted-foreground">
Started {formatRelativeTime(session.createdAt)}
</p>
</div>
</div>
{#if !session.isCurrent}
<Button
variant="outline"
size="sm"
onclick={() => openRevokeDialog(session.id)}
>
Revoke
</Button>
{/if}
</div>
{/each}
</div>
{#if activeSessions.length > 1}
<div class="mt-4 border-t pt-4">
<Button
variant="outline"
class="w-full text-destructive hover:text-destructive"
onclick={() => (confirmAllDialogOpen = true)}
>
Revoke all other sessions
</Button>
</div>
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<Monitor class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No active sessions.</p>
</div>
{/if}
</CardContent>
</Card>
<!-- Past Sessions -->
{#if pastSessions.length > 0}
<Card>
<CardHeader>
<CardTitle>Past Sessions</CardTitle>
<CardDescription>
Sessions that have been logged out or revoked.
</CardDescription>
</CardHeader>
<CardContent>
<div class="divide-y">
{#each pastSessions.slice(0, 10) as session (session.id)}
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
{@const DeviceIcon = getDeviceIcon(deviceType)}
<div class="flex items-center gap-3 py-3 first:pt-0 last:pb-0 opacity-60">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">{browser} on {os}</p>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin class="h-3 w-3" />
<span>{formatLocation(session)}</span>
</div>
<p class="text-xs text-muted-foreground">
{formatDate(session.createdAt)} - {session.revokedAt ? formatDate(session.revokedAt) : ""}
</p>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<LogOut class="h-3 w-3" />
<span>Logged out</span>
</div>
</div>
</div>
{/each}
</div>
{#if pastSessions.length > 10}
<p class="mt-4 text-center text-xs text-muted-foreground">
Showing 10 of {pastSessions.length} past sessions
</p>
{/if}
</CardContent>
</Card>
{/if}
</div>
<ConfirmDialog
bind:open={confirmDialogOpen}
title="Revoke this session?"
description="This will sign out the device. You'll need to sign in again to use that device."
confirmText="Revoke session"
variant="destructive"
loading={isRevoking}
onConfirm={handleRevoke}
/>
<ConfirmDialog
bind:open={confirmAllDialogOpen}
title="Revoke all other sessions?"
description="This will sign out all devices except your current one. You'll need to sign in again on those devices."
confirmText="Revoke all"
variant="destructive"
loading={isRevokingAll}
onConfirm={handleRevokeAll}
/>
{/if}