Merge branch 'workstream-i'
This commit is contained in:
@@ -109,7 +109,13 @@ const verifyRegistration = os.auth.webauthn.verifyRegistration
|
|||||||
context.allowedOrigins,
|
context.allowedOrigins,
|
||||||
context.rpName,
|
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
|
const createAuthenticationOptions = os.auth.webauthn.createAuthenticationOptions
|
||||||
@@ -162,6 +168,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
|||||||
"avatar_url",
|
"avatar_url",
|
||||||
"email_verified_at",
|
"email_verified_at",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
|
"password_hash",
|
||||||
])
|
])
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
@@ -176,6 +183,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
|||||||
emailVerified: user.email_verified_at !== null,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: user.display_name === null,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
|
hasPassword: user.password_hash !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export const verifyRegistration = async (
|
|||||||
userId: number,
|
userId: number,
|
||||||
challengeId: number,
|
challengeId: number,
|
||||||
response: RegistrationResponseJSON,
|
response: RegistrationResponseJSON,
|
||||||
): Promise<void> => {
|
): Promise<{ passkeyId: number }> => {
|
||||||
// Fetch the challenge
|
// Fetch the challenge
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
@@ -207,7 +207,7 @@ export const verifyRegistration = async (
|
|||||||
guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`;
|
guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`;
|
||||||
|
|
||||||
// Store the passkey
|
// Store the passkey
|
||||||
await db
|
const { id: passkeyId } = await db
|
||||||
.insertInto("passkeys")
|
.insertInto("passkeys")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -222,7 +222,10 @@ export const verifyRegistration = async (
|
|||||||
rpid: rpInfo.rpID,
|
rpid: rpInfo.rpID,
|
||||||
name: passKeyName,
|
name: passKeyName,
|
||||||
})
|
})
|
||||||
.execute();
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
return { passkeyId: Number(passkeyId) };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Portal {...restProps} />
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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} />
|
||||||
@@ -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} />
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
25
apps/publisher-dashboard/src/routes/account/+layout.svelte
Normal file
25
apps/publisher-dashboard/src/routes/account/+layout.svelte
Normal 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>
|
||||||
285
apps/publisher-dashboard/src/routes/account/+page.svelte
Normal file
285
apps/publisher-dashboard/src/routes/account/+page.svelte
Normal 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}
|
||||||
165
apps/publisher-dashboard/src/routes/account/auth/+page.svelte
Normal file
165
apps/publisher-dashboard/src/routes/account/auth/+page.svelte
Normal 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}
|
||||||
244
apps/publisher-dashboard/src/routes/account/devices/+page.svelte
Normal file
244
apps/publisher-dashboard/src/routes/account/devices/+page.svelte
Normal 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}
|
||||||
@@ -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}
|
||||||
@@ -2302,10 +2302,10 @@ _Depends on: D1-D9, E1-E4, C3_
|
|||||||
_Depends on: F1-F7, C3_
|
_Depends on: F1-F7, C3_
|
||||||
_Can run parallel to H after F1 is done_
|
_Can run parallel to H after F1 is done_
|
||||||
|
|
||||||
- [ ] **I1**: Create `/account` page (profile settings, avatar upload)
|
- [x] **I1**: Create `/account` page (profile settings, avatar upload)
|
||||||
- [ ] **I2**: Create `/account/auth` page (password, passkeys management)
|
- [x] **I2**: Create `/account/auth` page (password, passkeys management)
|
||||||
- [ ] **I3**: Create `/account/devices` page (trusted devices)
|
- [x] **I3**: Create `/account/devices` page (trusted devices)
|
||||||
- [ ] **I4**: Create `/account/sessions` page (session history)
|
- [x] **I4**: Create `/account/sessions` page (session history)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
287
docs/test-plans/account.md
Normal file
287
docs/test-plans/account.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Test Plan: Account Pages (Workstream I)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Manual test plan for the account management pages: Profile, Authentication, Devices, and Sessions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Dev server running: `bun run --cwd apps/publisher-dashboard dev`
|
||||||
|
- Logged-in user account
|
||||||
|
- Access to multiple devices/browsers for session testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Account Navigation
|
||||||
|
|
||||||
|
### 1.1 Navigation Tabs
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Navigate to `/account` | Profile tab is highlighted |
|
||||||
|
| 2 | Click "Authentication" tab | Navigate to `/account/auth`, tab highlighted |
|
||||||
|
| 3 | Click "Devices" tab | Navigate to `/account/devices`, tab highlighted |
|
||||||
|
| 4 | Click "Sessions" tab | Navigate to `/account/sessions`, tab highlighted |
|
||||||
|
| 5 | Resize to mobile width | Tab labels hidden, only icons visible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Profile Page (`/account`)
|
||||||
|
|
||||||
|
### 2.1 Profile Loading
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Navigate to `/account` | Loading spinner shown briefly |
|
||||||
|
| 2 | Wait for data | Form populated with current user data |
|
||||||
|
| 3 | Refresh page | Data persists correctly |
|
||||||
|
|
||||||
|
### 2.2 Avatar URL
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Enter valid image URL | Avatar preview updates |
|
||||||
|
| 2 | Enter invalid URL (e.g., "not-a-url") | Error: "Please enter a valid URL" on blur |
|
||||||
|
| 3 | Enter URL without http/https | Error shown |
|
||||||
|
| 4 | Clear URL | Avatar shows initials fallback |
|
||||||
|
| 5 | Enter URL to non-image | Preview attempts to load, falls back gracefully |
|
||||||
|
|
||||||
|
### 2.3 Display Name (Required)
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Clear display name | Save button disabled |
|
||||||
|
| 2 | Enter valid name (1-100 chars) | Save button enabled |
|
||||||
|
| 3 | Enter name > 100 chars | Input enforces maxlength |
|
||||||
|
|
||||||
|
### 2.4 Full Name (Optional)
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Leave empty | Form valid |
|
||||||
|
| 2 | Enter value | Form valid |
|
||||||
|
| 3 | Save with value | Value persists after refresh |
|
||||||
|
|
||||||
|
### 2.5 Phone Number
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Enter valid phone: "+1 555 123 4567" | Auto-formats to E.164 on blur |
|
||||||
|
| 2 | Enter invalid phone: "abc123" | Error: "Please enter a valid phone number" |
|
||||||
|
| 3 | Leave empty | Form valid (optional field) |
|
||||||
|
| 4 | Enter international number | Formats correctly |
|
||||||
|
|
||||||
|
### 2.6 Save Profile
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Make no changes | Save button disabled |
|
||||||
|
| 2 | Change any field | Save button enabled |
|
||||||
|
| 3 | Click Save | Loading state, success toast |
|
||||||
|
| 4 | Refresh page | Changes persisted |
|
||||||
|
| 5 | Save with validation error | Button disabled, cannot submit |
|
||||||
|
|
||||||
|
### 2.7 Delete Account
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Click "Delete account" button | Confirmation dialog opens |
|
||||||
|
| 2 | Dialog shows warning icon | Red/destructive styling |
|
||||||
|
| 3 | Enter wrong password | Error message shown |
|
||||||
|
| 4 | Enter correct password | Account deleted, redirected to login |
|
||||||
|
| 5 | Click Cancel | Dialog closes, no action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Authentication Page (`/account/auth`)
|
||||||
|
|
||||||
|
### 3.1 Email Display
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | View email section | Email displayed (read-only) |
|
||||||
|
| 2 | Verified user | "Verified" badge shown |
|
||||||
|
| 3 | Unverified user | No badge (or "Unverified" indicator) |
|
||||||
|
|
||||||
|
### 3.2 Password - No Existing Password
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | User without password | Shows "No password set" |
|
||||||
|
| 2 | Click "Set password" | Dialog opens |
|
||||||
|
| 3 | No "Current password" field | Only new password fields shown |
|
||||||
|
| 4 | Enter weak password (<8 chars) | Submit disabled |
|
||||||
|
| 5 | Enter mismatched passwords | Submit disabled |
|
||||||
|
| 6 | Enter valid matching passwords | Submit enabled |
|
||||||
|
| 7 | Submit | Success toast, status updates to "Password is set" |
|
||||||
|
|
||||||
|
### 3.3 Password - Existing Password
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | User with password | Shows "Password is set" |
|
||||||
|
| 2 | Click "Change password" | Dialog opens with current password field |
|
||||||
|
| 3 | Leave current password empty | Submit disabled |
|
||||||
|
| 4 | Enter wrong current password | Error message on submit |
|
||||||
|
| 5 | Enter correct current + valid new | Success toast |
|
||||||
|
|
||||||
|
### 3.4 Passkeys List
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | No passkeys | Empty state message |
|
||||||
|
| 2 | Has passkeys | List with name, created date, last used |
|
||||||
|
| 3 | Each passkey | Rename and Delete buttons visible |
|
||||||
|
|
||||||
|
### 3.5 Add Passkey
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Click "+ Add passkey" | Dialog opens |
|
||||||
|
| 2 | Browser doesn't support WebAuthn | Message shown, form disabled |
|
||||||
|
| 3 | Enter passkey name | Create button enabled |
|
||||||
|
| 4 | Click Create | Browser WebAuthn prompt appears |
|
||||||
|
| 5 | Complete WebAuthn ceremony | Success toast, passkey appears in list |
|
||||||
|
| 6 | Cancel WebAuthn prompt | Error: "Passkey creation was cancelled" |
|
||||||
|
| 7 | Verify passkey name | Name matches what user entered |
|
||||||
|
|
||||||
|
### 3.6 Rename Passkey
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Click rename icon | Dialog opens with current name |
|
||||||
|
| 2 | Enter same name | Save disabled |
|
||||||
|
| 3 | Enter new name | Save enabled |
|
||||||
|
| 4 | Save | Success toast, list updates |
|
||||||
|
|
||||||
|
### 3.7 Delete Passkey
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Click delete icon | Confirmation dialog |
|
||||||
|
| 2 | Confirm deletion | Success toast, passkey removed |
|
||||||
|
| 3 | Only 1 passkey, no password | Delete button disabled with tooltip |
|
||||||
|
| 4 | Has password or 2+ passkeys | Delete enabled |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Devices Page (`/account/devices`)
|
||||||
|
|
||||||
|
### 4.1 Trusted Devices List
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Navigate to page | List of trusted devices shown |
|
||||||
|
| 2 | Current device | Marked with "Current" badge |
|
||||||
|
| 3 | Each device shows | Name, location, last used date |
|
||||||
|
| 4 | No trusted devices | Empty state message |
|
||||||
|
|
||||||
|
### 4.2 Remove Trust
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Click "Remove trust" on any device | Confirmation dialog |
|
||||||
|
| 2 | Confirm | Success toast, device removed from list |
|
||||||
|
| 3 | Cancel | Dialog closes, no change |
|
||||||
|
|
||||||
|
### 4.3 Remove All Trusted Devices
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Only 1 device | "Remove all" button not shown |
|
||||||
|
| 2 | 2+ devices | "Remove all trusted devices" button shown |
|
||||||
|
| 3 | Click remove all | Confirmation dialog |
|
||||||
|
| 4 | Confirm | All devices removed, empty state shown |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sessions Page (`/account/sessions`)
|
||||||
|
|
||||||
|
### 5.1 Active Sessions
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Navigate to page | Active sessions listed |
|
||||||
|
| 2 | Current session | Marked with "Current" badge |
|
||||||
|
| 3 | Each session shows | Browser/OS, location, auth method, start date |
|
||||||
|
| 4 | Passkey session | Shows "via passkey" with key icon |
|
||||||
|
| 5 | Password session | Shows "via password" |
|
||||||
|
|
||||||
|
### 5.2 Revoke Session
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Current session | No revoke button |
|
||||||
|
| 2 | Other session | Revoke button visible |
|
||||||
|
| 3 | Click Revoke | Confirmation dialog |
|
||||||
|
| 4 | Confirm | Session moved to past sessions |
|
||||||
|
|
||||||
|
### 5.3 Revoke All Sessions
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | Only current session | "Revoke all" button not shown |
|
||||||
|
| 2 | Multiple sessions | "Revoke all other sessions" shown |
|
||||||
|
| 3 | Confirm revoke all | All other sessions revoked |
|
||||||
|
|
||||||
|
### 5.4 Past Sessions
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1 | No past sessions | Section not shown |
|
||||||
|
| 2 | Has past sessions | Section shows with grayed styling |
|
||||||
|
| 3 | Past session shows | Browser/OS, location, date range, "Logged out" |
|
||||||
|
| 4 | More than 10 | Shows "Showing 10 of X" message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cross-Cutting Concerns
|
||||||
|
|
||||||
|
### 6.1 Loading States
|
||||||
|
| Page | Expected |
|
||||||
|
|------|----------|
|
||||||
|
| All pages | Spinner while loading data |
|
||||||
|
| Form submissions | Button shows loading state |
|
||||||
|
| Dialogs | Buttons disabled during submission |
|
||||||
|
|
||||||
|
### 6.2 Error Handling
|
||||||
|
| Scenario | Expected |
|
||||||
|
|----------|----------|
|
||||||
|
| Network error | Error alert shown |
|
||||||
|
| API error | Error message from server displayed |
|
||||||
|
| Validation error | Inline error message |
|
||||||
|
|
||||||
|
### 6.3 Accessibility
|
||||||
|
| Test | Expected |
|
||||||
|
|------|----------|
|
||||||
|
| Keyboard navigation | All interactive elements focusable |
|
||||||
|
| Screen reader | ARIA labels present on icons/buttons |
|
||||||
|
| Dialog close button | Has aria-label="Close dialog" |
|
||||||
|
| Navigation tabs | Has aria-current="page" on active |
|
||||||
|
|
||||||
|
### 6.4 Mobile Responsiveness
|
||||||
|
| Breakpoint | Expected |
|
||||||
|
|------------|----------|
|
||||||
|
| Desktop (>640px) | Full navigation labels |
|
||||||
|
| Mobile (<640px) | Icon-only navigation |
|
||||||
|
| All sizes | Forms remain usable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Integration Tests
|
||||||
|
|
||||||
|
### 7.1 Full Passkey Flow
|
||||||
|
1. Navigate to `/account/auth`
|
||||||
|
2. Add new passkey with custom name
|
||||||
|
3. Verify passkey appears in list with correct name
|
||||||
|
4. Rename passkey
|
||||||
|
5. Delete passkey (if not last auth method)
|
||||||
|
|
||||||
|
### 7.2 Password Change Flow
|
||||||
|
1. Set initial password (if none)
|
||||||
|
2. Change password with correct current password
|
||||||
|
3. Verify can log in with new password
|
||||||
|
|
||||||
|
### 7.3 Multi-Device Session Test
|
||||||
|
1. Log in on Device A
|
||||||
|
2. Log in on Device B
|
||||||
|
3. On Device A, view sessions - see both
|
||||||
|
4. Revoke Device B session
|
||||||
|
5. Verify Device B is logged out
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Expected Behavior |
|
||||||
|
|----------|-------------------|
|
||||||
|
| Last auth method | Cannot delete (button disabled) |
|
||||||
|
| Very long passkey name | Truncated in UI, full in tooltip |
|
||||||
|
| Many sessions (>10 past) | Pagination/truncation message |
|
||||||
|
| Slow network | Loading states visible |
|
||||||
|
| Session expired mid-action | Redirected to login |
|
||||||
|
| Concurrent passkey creation | Correct passkey gets renamed (race condition fixed) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sign-Off
|
||||||
|
|
||||||
|
| Tester | Date | Status |
|
||||||
|
|--------|------|--------|
|
||||||
|
| | | |
|
||||||
@@ -88,7 +88,7 @@ export const contract = oc.router({
|
|||||||
response: z.custom<RegistrationResponseJSON>(),
|
response: z.custom<RegistrationResponseJSON>(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.void()),
|
.output(z.object({ passkeyId: z.number() })),
|
||||||
createAuthenticationOptions: oc.output(
|
createAuthenticationOptions: oc.output(
|
||||||
z.object({
|
z.object({
|
||||||
challengeId: z.number(),
|
challengeId: z.number(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const userProfileSchema = z.object({
|
|||||||
emailVerified: z.boolean(),
|
emailVerified: z.boolean(),
|
||||||
needsSetup: z.boolean(),
|
needsSetup: z.boolean(),
|
||||||
isSuperuser: z.boolean(),
|
isSuperuser: z.boolean(),
|
||||||
|
hasPassword: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user