Merge branch 'workstream-h-v2'

This commit is contained in:
RevIQ
2026-01-09 16:50:52 +08:00
30 changed files with 2133 additions and 230 deletions

View File

@@ -15,13 +15,18 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"libphonenumber-js": "^1.12.33",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tslib": "catalog:"
"tslib": "catalog:",
"ua-parser-js": "^2.0.7",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -32,6 +37,8 @@
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"svelte": "^5.28.2",
"svelte-check": "^4.2.1",

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { AlertCircle } from "@lucide/svelte";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
interface Props {
message: string;
}
let { message }: Props = $props();
</script>
{#if message}
<div role="alert" aria-live="polite">
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>{message}</AlertDescription>
</Alert>
</div>
{/if}

View File

@@ -0,0 +1,4 @@
export { default as ErrorAlert } from "./error-alert.svelte";
export { default as PasswordFormField } from "./password-form-field.svelte";
export { default as PasswordInput } from "./password-input.svelte";
export { default as PasswordStrength } from "./password-strength.svelte";

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { Label } from "$lib/components/ui/label";
import PasswordInput from "./password-input.svelte";
import PasswordStrength from "./password-strength.svelte";
interface Props {
id?: string;
label?: string;
value?: string;
placeholder?: string;
showStrength?: boolean;
error?: string;
autocomplete?: HTMLInputAttributes["autocomplete"];
required?: boolean;
disabled?: boolean;
}
let {
id = "password",
label = "Password",
value = $bindable(""),
placeholder = "Enter your password",
showStrength = false,
error = "",
autocomplete = "current-password",
required = false,
disabled = false,
}: Props = $props();
</script>
<div class="space-y-2">
<Label for={id}>
{label}
{#if required}
<span class="text-destructive">*</span>
{/if}
</Label>
<PasswordInput
bind:value
{id}
name={id}
{placeholder}
{autocomplete}
{required}
{disabled}
/>
{#if showStrength}
<PasswordStrength password={value} />
{/if}
{#if error}
<p class="text-xs text-destructive">{error}</p>
{/if}
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { Eye, EyeOff } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { cn } from "$lib/utils";
interface Props {
value?: string;
placeholder?: string;
id?: string;
name?: string;
required?: boolean;
disabled?: boolean;
autocomplete?: HTMLInputAttributes["autocomplete"];
class?: string;
}
let {
value = $bindable(""),
placeholder = "Enter your password",
id = "password",
name = "password",
required = false,
disabled = false,
autocomplete = "current-password",
class: className = "",
}: Props = $props();
let showPassword = $state(false);
function toggleVisibility() {
showPassword = !showPassword;
}
</script>
<div class="relative">
<Input
type={showPassword ? "text" : "password"}
{id}
{name}
{placeholder}
{required}
{disabled}
{autocomplete}
bind:value
class={cn("h-10 pr-10", className)}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground"
onclick={toggleVisibility}
{disabled}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{#if showPassword}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</Button>
</div>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import zxcvbn from "zxcvbn";
interface Props {
password: string;
userInputs?: string[];
}
let { password, userInputs = [] }: Props = $props();
// Compute password strength using zxcvbn
const result = $derived(password ? zxcvbn(password, userInputs) : null);
const score = $derived(result?.score ?? 0);
// Strength labels and colors
const strengthConfig = [
{
label: "Very weak",
color: "bg-destructive",
textColor: "text-destructive",
},
{ label: "Weak", color: "bg-destructive", textColor: "text-destructive" },
{ label: "Fair", color: "bg-warning", textColor: "text-warning" },
{ label: "Good", color: "bg-success", textColor: "text-success" },
{ label: "Strong", color: "bg-success", textColor: "text-success" },
] as const;
const config = $derived(strengthConfig[score]);
</script>
{#if password}
<div class="space-y-2">
<!-- Strength bars -->
<div class="flex gap-1">
{#each Array(4) as _, i}
<div
class="h-1 flex-1 rounded-full transition-colors {i < score
? config.color
: 'bg-muted'}"
></div>
{/each}
</div>
<!-- Strength label -->
<p class="text-xs {config.textColor}">
Password strength: {config.label}
</p>
<!-- Feedback suggestions -->
{#if result?.feedback.warning || result?.feedback.suggestions.length}
<div class="text-xs text-muted-foreground">
{#if result.feedback.warning}
<p class="text-destructive">{result.feedback.warning}</p>
{/if}
{#each result.feedback.suggestions as suggestion}
<p>{suggestion}</p>
{/each}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,23 @@
<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="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
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="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { type AlertVariant, alertVariants } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@@ -0,0 +1,3 @@
import Root from "./loading-button.svelte";
export { Root, Root as LoadingButton };

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { Loader2 } from "@lucide/svelte";
import { Button, type ButtonProps } from "$lib/components/ui/button";
interface Props extends ButtonProps {
loading?: boolean;
loadingText?: string;
children: Snippet;
}
let {
loading = false,
loadingText,
children,
disabled,
...rest
}: Props = $props();
</script>
<Button disabled={loading || disabled} {...rest}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{loadingText ?? "Loading..."}
{:else}
{@render children()}
{/if}
</Button>

View File

@@ -0,0 +1,76 @@
/**
* Auth Store - Svelte 5 runes-based store for authentication flow state
*
* This module provides state management for the multi-step login flow.
* State persists across page navigations in SPA mode.
*/
interface LoginFlowState {
email: string | null;
hasPasskey: boolean;
hasPassword: boolean;
isTrustedDevice: boolean;
}
/**
* Module-level reactive state for login flow
* Persists across page navigations within the SPA
*/
export const loginFlowState: LoginFlowState = $state({
email: null,
hasPasskey: false,
hasPassword: false,
isTrustedDevice: false,
});
/**
* Set login flow state after createLoginRequest API call
* Call this before navigating to passkey/password/confirm pages
*/
export function setLoginFlowState(response: {
email: string;
hasPasskey: boolean;
hasPassword: boolean;
isTrustedDevice: boolean;
}): void {
loginFlowState.email = response.email;
loginFlowState.hasPasskey = response.hasPasskey;
loginFlowState.hasPassword = response.hasPassword;
loginFlowState.isTrustedDevice = response.isTrustedDevice;
}
/**
* Clear login flow state after successful login or when user navigates away
*/
export function clearLoginFlowState(): void {
loginFlowState.email = null;
loginFlowState.hasPasskey = false;
loginFlowState.hasPassword = false;
loginFlowState.isTrustedDevice = false;
}
/**
* Check if there is an active login flow
* Used for route guards on passkey/password/confirm pages
*/
export function hasActiveLoginFlow(): boolean {
return loginFlowState.email !== null;
}
/**
* Get masked email for display (e.g., "j***@example.com")
*/
export function getMaskedEmail(): string {
if (!loginFlowState.email) {
return "";
}
const [local, domain] = loginFlowState.email.split("@");
if (!domain) {
return loginFlowState.email;
}
const maskedLocal = local.length > 1 ? `${local[0]}***` : `${local}***`;
return `${maskedLocal}@${domain}`;
}

View File

@@ -0,0 +1,62 @@
import {
isValidPhoneNumber,
parsePhoneNumberWithError,
} from "libphonenumber-js";
/**
* Validates email format using a simple regex pattern.
* Matches the backend validation pattern.
*/
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* Validates and formats phone numbers using libphonenumber-js.
* Returns validation status and optionally the formatted number.
*/
export function validatePhone(value: string): {
valid: boolean;
formatted?: string;
} {
if (!value) {
return { valid: true };
}
try {
const phone = parsePhoneNumberWithError(value);
if (isValidPhoneNumber(phone.number)) {
return { valid: true, formatted: phone.formatInternational() };
}
return { valid: false };
} catch {
return { valid: false };
}
}
/**
* Masks an email for display, e.g., "john@example.com" -> "j***@example.com"
*/
export function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) {
return email;
}
const masked =
local.length > 1
? `${local[0]}${"*".repeat(Math.min(local.length - 1, 3))}`
: local;
return `${masked}@${domain}`;
}
/**
* Parses an error to extract a user-friendly message.
*/
export function parseErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "An unexpected error occurred";
}

View File

@@ -3,6 +3,7 @@ import "../app.css";
import type { Snippet } from "svelte";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { SvelteQueryDevtools } from "@tanstack/svelte-query-devtools";
import { Toaster } from "svelte-sonner";
interface Props {
children: Snippet;
@@ -24,3 +25,4 @@ const queryClient = new QueryClient({
{@render children()}
<SvelteQueryDevtools />
</QueryClientProvider>
<Toaster richColors position="top-center" />

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<svelte:head>
<title>Publisher Dashboard</title>
</svelte:head>
<div class="grid min-h-screen lg:grid-cols-2">
<!-- Left Panel - Branding (hidden on mobile) -->
<div class="relative hidden bg-primary lg:block">
<div class="absolute inset-0 bg-gradient-to-br from-primary via-primary to-chart-1/20"></div>
<div class="relative flex h-full flex-col justify-between p-10">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-foreground">
<svg
class="h-6 w-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-primary-foreground">Publisher Dashboard</span>
</div>
<!-- Testimonial -->
<div class="space-y-4">
<blockquote class="text-lg font-light leading-relaxed text-primary-foreground/90">
"This dashboard has transformed how we analyze our publishing metrics. The insights are
invaluable for optimizing our content strategy and maximizing revenue."
</blockquote>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-foreground/20 text-sm font-medium text-primary-foreground"
>
SK
</div>
<div>
<p class="font-medium text-primary-foreground">Sarah Kim</p>
<p class="text-sm text-primary-foreground/70">Head of Digital, MediaCorp</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Panel - Form Content -->
<div class="flex items-center justify-center bg-background p-6 lg:p-10">
<div class="mx-auto w-full max-w-sm space-y-8">
<!-- Mobile Logo -->
<div class="flex items-center justify-center gap-3 lg:hidden">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg
class="h-6 w-6 text-primary-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-foreground">Publisher Dashboard</span>
</div>
<!-- Page Content -->
{@render children()}
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and
<a href="/privacy" class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { AlertCircle, Loader2, Mail, RefreshCw } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import { LoadingButton } from "$lib/components/ui/loading-button";
import {
clearLoginFlowState,
getMaskedEmail,
loginFlowState,
} from "$lib/stores/auth.svelte";
let resendCooldown = $state(0);
let isResending = $state(false);
let resendError = $state<string | null>(null);
// Guard: redirect to /auth/login if no active login flow
$effect(() => {
if (!loginFlowState.email) {
goto("/auth/login");
}
});
// Poll for login completion every 3 seconds
// In Svelte 5 with TanStack Query v6, options are passed as a thunk (function)
const statusQuery = createQuery(() => ({
queryKey: ["loginStatus"],
queryFn: () => api.auth.loginIfRequestIsCompleted(),
refetchInterval: 3000,
enabled: !!loginFlowState.email,
}));
// Watch for completed status
// In TanStack Query v6 for Svelte 5, query results are reactive objects (not stores)
$effect(() => {
if (statusQuery.data?.status === "completed") {
clearLoginFlowState();
goto(statusQuery.data.redirectTo || "/performance");
}
});
// Handle cooldown timer
$effect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => {
resendCooldown -= 1;
}, 1000);
return () => clearTimeout(timer);
}
});
async function handleResendEmail() {
resendError = null;
isResending = true;
try {
await api.auth.resendVerificationEmail();
resendCooldown = 60;
} catch (err) {
resendError = err instanceof Error ? err.message : "Failed to resend email";
} finally {
isResending = false;
}
}
function handleDifferentEmail() {
clearLoginFlowState();
goto("/auth/login");
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Mail class="h-8 w-8 text-primary" />
</div>
<div class="space-y-2">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-sm text-muted-foreground">
We sent a verification link to
<span class="font-medium text-foreground">{getMaskedEmail()}</span>
</p>
</div>
</div>
<!-- Loading indicator -->
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Waiting for email verification...</span>
</div>
<!-- Error Alerts -->
<ErrorAlert message={resendError ?? ""} />
<ErrorAlert message={statusQuery.error instanceof Error ? statusQuery.error.message : statusQuery.error ? "Failed to check verification status" : ""} />
<!-- Expired Status -->
{#if statusQuery.data?.status === "expired"}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>
Your verification link has expired. Please request a new one.
</AlertDescription>
</Alert>
{/if}
<!-- Resend Button -->
<div class="space-y-4">
{#if isResending}
<LoadingButton
variant="outline"
class="h-10 w-full"
loading={true}
loadingText="Sending..."
>
Resend email
</LoadingButton>
{:else}
<Button
variant="outline"
disabled={resendCooldown > 0}
onclick={handleResendEmail}
class="h-10 w-full"
>
<RefreshCw class="h-4 w-4" />
{#if resendCooldown > 0}
Resend email ({resendCooldown}s)
{:else}
Resend email
{/if}
</Button>
{/if}
<div class="text-center">
<button
type="button"
onclick={handleDifferentEmail}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Use a different email
</button>
</div>
</div>
<!-- Dev Mode Banner -->
{#if import.meta.env.DEV}
<div class="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4">
<p class="text-sm font-medium text-yellow-600 dark:text-yellow-400">
Development Mode
</p>
<p class="mt-1 text-xs text-muted-foreground">
To complete login without email, use the CLI command:
</p>
<code class="mt-2 block rounded bg-muted px-2 py-1 text-xs">
bun run cli auth complete-login --email {loginFlowState.email}
</code>
</div>
{/if}
</div>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import { CheckCircle2 } from "@lucide/svelte";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
// Form state
let email = $state("");
let isLoading = $state(false);
let error = $state("");
let isSubmitted = $state(false);
// Form validation
const isFormValid = $derived(email.length > 0 && email.includes("@"));
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isFormValid) {
return;
}
isLoading = true;
error = "";
try {
await api.auth.forgotPassword({ email });
// Always show success to prevent email enumeration
isSubmitted = true;
} catch (err) {
// Even on error, show success message to prevent enumeration
// Only show error for network/unexpected issues
if (err instanceof Error && err.message.includes("network")) {
error = "Unable to connect. Please check your connection and try again.";
} else {
// Still show success to prevent enumeration
isSubmitted = true;
}
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Forgot Password | Publisher Dashboard</title>
<meta name="description" content="Reset your Publisher Dashboard password" />
</svelte:head>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Forgot password?</h1>
<p class="text-sm text-muted-foreground">
{#if isSubmitted}
Check your email for reset instructions
{:else}
Enter your email and we'll send you reset instructions
{/if}
</p>
</div>
{#if isSubmitted}
<!-- Success state -->
<Alert>
<CheckCircle2 class="h-4 w-4" />
<AlertDescription>
If an account exists with this email, we've sent password reset instructions.
</AlertDescription>
</Alert>
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Didn't receive the email? Check your spam folder or try again with a different email.
</p>
<Button
variant="outline"
class="h-10 w-full"
onclick={() => {
isSubmitted = false;
email = "";
}}
>
Try another email
</Button>
</div>
{:else}
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
bind:value={email}
required
disabled={isLoading}
class="h-10"
/>
</div>
<ErrorAlert message={error} />
<LoadingButton
type="submit"
class="h-10 w-full"
disabled={!isFormValid}
loading={isLoading}
loadingText="Sending instructions..."
>
Send reset instructions
</LoadingButton>
</form>
{/if}
<!-- Back to login link -->
<div class="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in
</a>
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { setLoginFlowState } from "$lib/stores/auth.svelte";
let email = $state("");
let isLoading = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = null;
isLoading = true;
try {
const response = await api.auth.createLoginRequest({ email });
setLoginFlowState(response);
if (response.hasPasskey) {
goto("/auth/login/passkey");
} else if (response.hasPassword) {
goto("/auth/login/password");
} else {
// Anti-enumeration: always redirect to confirm even if user doesn't exist
goto("/auth/confirm");
}
} catch (err) {
error = err instanceof Error ? err.message : "An unexpected error occurred";
isLoading = false;
}
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p class="text-sm text-muted-foreground">Enter your email to sign in to your account</p>
</div>
<!-- Error Alert -->
<ErrorAlert message={error ?? ""} />
<!-- Login Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
autocomplete="email"
required
disabled={isLoading}
bind:value={email}
class="h-10"
/>
</div>
<LoadingButton
type="submit"
class="h-10 w-full"
loading={isLoading}
loadingText="Signing in..."
>
Continue
</LoadingButton>
</form>
<!-- Signup Link -->
<div class="text-center">
<p class="text-sm text-muted-foreground">
Don't have an account?
<a href="/auth/signup" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
Sign up
</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
import { startAuthentication } from "@simplewebauthn/browser";
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 { LoadingButton } from "$lib/components/ui/loading-button";
import { getMaskedEmail, loginFlowState } from "$lib/stores/auth.svelte";
/**
* Passkey authentication page
* Automatically triggers WebAuthn authentication on mount
* Provides fallback options for password and email login
*/
let isAuthenticating = $state(false);
let error = $state("");
let hasAttempted = $state(false);
const maskedEmail = $derived(getMaskedEmail());
/**
* Authenticate using WebAuthn passkey
*/
async function authenticate(): Promise<void> {
isAuthenticating = true;
error = "";
try {
// Get challenge from server
const options = await api.auth.webauthn.createAuthenticationOptions();
// Trigger browser WebAuthn prompt
const credential = await startAuthentication({
optionsJSON: options.options,
});
// Verify with server
await api.auth.webauthn.verifyAuthentication({
challengeId: options.challengeId,
response: credential,
});
// Success - redirect to confirm for session creation
goto("/auth/confirm");
} catch (e) {
error = e instanceof Error ? e.message : "Authentication failed";
hasAttempted = true;
} finally {
isAuthenticating = false;
}
}
// Guard: redirect to /auth/login if no active login flow
$effect(() => {
if (!loginFlowState.email) {
goto("/auth/login");
}
});
// Auto-trigger authentication on mount
$effect(() => {
if (loginFlowState.email && !hasAttempted) {
void authenticate();
}
});
</script>
<svelte:head>
<title>Passkey Authentication | Publisher Dashboard</title>
<meta name="description" content="Authenticate with your passkey" />
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
{#if isAuthenticating}
<Fingerprint class="h-8 w-8 animate-pulse text-primary" />
{:else if error}
<KeyRound class="h-8 w-8 text-destructive" />
{:else}
<Fingerprint class="h-8 w-8 text-primary" />
{/if}
</div>
<div class="space-y-2">
<h1 class="text-2xl font-semibold tracking-tight">
{#if isAuthenticating}
Authenticating...
{:else if error}
Authentication failed
{:else}
Use your passkey
{/if}
</h1>
<p class="text-sm text-muted-foreground">
{#if isAuthenticating}
Follow the prompts to authenticate with your passkey
{:else if maskedEmail}
Signing in as <span class="font-medium text-foreground">{maskedEmail}</span>
{:else}
Use your passkey to sign in
{/if}
</p>
</div>
</div>
<!-- Loading indicator -->
{#if isAuthenticating && !error && !hasAttempted}
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Waiting for passkey authentication...</span>
</div>
{/if}
<!-- Error Alert -->
<ErrorAlert message={error} />
<!-- Actions -->
<div class="space-y-3">
{#if error || hasAttempted}
<LoadingButton
class="h-10 w-full"
onclick={authenticate}
loading={isAuthenticating}
loadingText="Authenticating..."
>
Try again
</LoadingButton>
{/if}
<!-- Fallback links -->
{#if loginFlowState.hasPassword}
<Button variant="outline" class="h-10 w-full" href="/auth/login/password">
Use password instead
</Button>
{/if}
<div class="text-center">
<button
type="button"
onclick={() => goto("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Use a different email
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ErrorAlert, PasswordInput } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { clearLoginFlowState, loginFlowState } from "$lib/stores/auth.svelte";
let password = $state("");
let isLoading = $state(false);
let error = $state<string | null>(null);
// Guard: redirect to /auth/login if no active login flow
$effect(() => {
if (!loginFlowState.email) {
goto("/auth/login");
}
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = null;
isLoading = true;
try {
await api.auth.loginPassword({ password });
// On success, redirect to confirm page for email verification
goto("/auth/confirm");
} catch (err) {
error =
err instanceof Error
? err.message
: "Invalid password. Please try again.";
isLoading = false;
}
}
function handleDifferentEmail() {
clearLoginFlowState();
goto("/auth/login");
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Enter your password</h1>
<p class="text-sm text-muted-foreground">
Signing in as <span class="font-medium text-foreground">{loginFlowState.email}</span>
</p>
</div>
<!-- Error Alert -->
<ErrorAlert message={error ?? ""} />
<!-- Password Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
disabled={isLoading}
bind:value={password}
/>
</div>
<LoadingButton
type="submit"
class="h-10 w-full"
loading={isLoading}
loadingText="Signing in..."
>
Sign in
</LoadingButton>
</form>
<!-- Secondary Links -->
<div class="space-y-3 text-center">
<a
href="/auth/forgot-password"
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Forgot password?
</a>
{#if loginFlowState.hasPasskey}
<div>
<a
href="/auth/login/passkey"
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Use passkey instead
</a>
</div>
{/if}
<div>
<button
type="button"
onclick={handleDifferentEmail}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Different email?
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { AlertCircle } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import zxcvbn from "zxcvbn";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import {
ErrorAlert,
PasswordInput,
PasswordStrength,
} from "$lib/components/auth";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
// Get token from URL
const token = $derived($page.url.searchParams.get("token") ?? "");
// Form state
let newPassword = $state("");
let confirmPassword = $state("");
let isLoading = $state(false);
let error = $state("");
// Password validation
const passwordScore = $derived(newPassword ? zxcvbn(newPassword).score : 0);
const passwordsMatch = $derived(newPassword === confirmPassword);
const isPasswordValid = $derived(
newPassword.length >= 8 &&
passwordScore >= 3 &&
passwordsMatch &&
confirmPassword.length > 0,
);
// Form validation
const isFormValid = $derived(token.length > 0 && isPasswordValid);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isFormValid) {
return;
}
isLoading = true;
error = "";
try {
await api.auth.resetPassword({
token,
newPassword,
});
// Show success toast and redirect to login
toast.success("Password reset successfully", {
description: "You can now sign in with your new password.",
});
await goto("/auth/login");
} catch (err) {
if (err instanceof Error) {
// Handle specific error cases
if (err.message.includes("expired") || err.message.includes("invalid")) {
error =
"This password reset link has expired or is invalid. Please request a new one.";
} else {
error = err.message;
}
} else {
error = "An unexpected error occurred. Please try again.";
}
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Reset Password | Publisher Dashboard</title>
<meta name="description" content="Set a new password for your Publisher Dashboard account" />
</svelte:head>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Set new password</h1>
<p class="text-sm text-muted-foreground">
Create a strong password for your account
</p>
</div>
{#if !token}
<!-- No token state -->
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>
Invalid password reset link. Please request a new one.
</AlertDescription>
</Alert>
<Button variant="outline" class="h-10 w-full" onclick={() => goto("/auth/forgot-password")}>
Request new reset link
</Button>
{:else}
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="new-password">New Password</Label>
<PasswordInput
id="new-password"
name="new-password"
bind:value={newPassword}
placeholder="Create a strong password"
disabled={isLoading}
autocomplete="new-password"
/>
<PasswordStrength password={newPassword} />
</div>
<div class="space-y-2">
<Label for="confirm-password">Confirm Password</Label>
<PasswordInput
id="confirm-password"
name="confirm-password"
bind:value={confirmPassword}
placeholder="Confirm your password"
disabled={isLoading}
autocomplete="new-password"
/>
{#if confirmPassword && !passwordsMatch}
<p class="text-xs text-destructive">Passwords do not match</p>
{/if}
</div>
<ErrorAlert message={error} />
<LoadingButton
type="submit"
class="h-10 w-full"
disabled={!isFormValid}
loading={isLoading}
loadingText="Resetting password..."
>
Reset password
</LoadingButton>
</form>
{/if}
<!-- Back to login link -->
<div class="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in
</a>
</div>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { AlertCircle, Loader2 } from "@lucide/svelte";
import { createQuery } 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 { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
import { validatePhone } from "$lib/utils/validation";
// Fetch current user to check if setup is needed
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Redirect if user doesn't need setup
$effect(() => {
if (userQuery.data && !userQuery.data.needsSetup) {
goto("/performance");
}
});
let displayName = $state("");
let fullName = $state("");
let phoneNumber = $state("");
let isSubmitting = $state(false);
let error = $state("");
let phoneError = $state("");
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),
);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isValid || isSubmitting) {
return;
}
isSubmitting = true;
error = "";
try {
await api.me.setupProfile({
displayName: displayName.trim(),
fullName: fullName.trim() || undefined,
phoneNumber: phoneNumber.trim() || undefined,
});
toast.success("Profile setup complete!");
goto("/performance");
} catch (e) {
error = e instanceof Error ? e.message : "Failed to save profile";
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Complete Your Profile | Publisher Dashboard</title>
</svelte:head>
{#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}
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Complete your profile</h1>
<p class="text-sm text-muted-foreground">
Tell us a bit about yourself to get started
</p>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} 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 (optional)"
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 (optional)"
bind:value={phoneNumber}
onblur={handlePhoneBlur}
class="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>
<ErrorAlert message={error} />
<LoadingButton
type="submit"
class="h-10 w-full"
disabled={!isValid}
loading={isSubmitting}
loadingText="Saving..."
>
Continue to Dashboard
</LoadingButton>
</form>
{/if}

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import {
browserSupportsWebAuthn,
startRegistration,
} from "@simplewebauthn/browser";
import zxcvbn from "zxcvbn";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import {
ErrorAlert,
PasswordInput,
PasswordStrength,
} from "$lib/components/auth";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
// Form state
let email = $state("");
let password = $state("");
let confirmPassword = $state("");
let isLoading = $state(false);
let error = $state("");
// Authentication mode: "passkey" or "password"
let authMode = $state<"passkey" | "password">("passkey");
// Check passkey support on mount
let supportsPasskey = $state(false);
$effect(() => {
supportsPasskey = browserSupportsWebAuthn();
if (!supportsPasskey) {
authMode = "password";
}
});
// Password validation
const passwordScore = $derived(password ? zxcvbn(password, [email]).score : 0);
const passwordsMatch = $derived(password === confirmPassword);
const isPasswordValid = $derived(
password.length >= 8 &&
passwordScore >= 3 &&
passwordsMatch &&
confirmPassword.length > 0,
);
// Form validation
const isFormValid = $derived(
authMode === "passkey"
? email.length > 0 && email.includes("@")
: email.length > 0 && email.includes("@") && isPasswordValid,
);
async function handlePasskeySignup() {
isLoading = true;
error = "";
try {
// Step 1: Get registration options from server
const { challengeId, options } =
await api.auth.webauthn.createRegistrationOptions({ email });
// Step 2: Start WebAuthn registration
const registrationResponse = await startRegistration({
optionsJSON: options,
});
// Step 3: Complete signup with passkey info
await api.auth.signup({
email,
passkeyInfo: {
challengeId,
response: registrationResponse,
},
});
// Redirect to user setup
await goto("/auth/setup/user");
} 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 {
isLoading = false;
}
}
async function handlePasswordSignup() {
isLoading = true;
error = "";
try {
await api.auth.signup({
email,
password,
});
// Redirect to user setup
await goto("/auth/setup/user");
} catch (err) {
if (err instanceof Error) {
error = err.message;
} else {
error = "An unexpected error occurred. Please try again.";
}
} finally {
isLoading = false;
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!isFormValid) {
return;
}
if (authMode === "passkey") {
await handlePasskeySignup();
} else {
await handlePasswordSignup();
}
}
function switchToPassword() {
authMode = "password";
}
function switchToPasskey() {
authMode = "passkey";
password = "";
confirmPassword = "";
}
</script>
<svelte:head>
<title>Create Account | Publisher Dashboard</title>
<meta name="description" content="Create your Publisher Dashboard account" />
</svelte:head>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Create an account</h1>
<p class="text-sm text-muted-foreground">
{#if authMode === "passkey"}
Use a passkey for secure, passwordless authentication
{:else}
Enter your details to create your account
{/if}
</p>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
bind:value={email}
required
disabled={isLoading}
class="h-10"
/>
</div>
{#if authMode === "password"}
<div class="space-y-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
bind:value={password}
placeholder="Create a strong password"
disabled={isLoading}
autocomplete="new-password"
/>
<PasswordStrength password={password} userInputs={[email]} />
</div>
<div class="space-y-2">
<Label for="confirm-password">Confirm Password</Label>
<PasswordInput
id="confirm-password"
name="confirm-password"
bind:value={confirmPassword}
placeholder="Confirm your password"
disabled={isLoading}
autocomplete="new-password"
/>
{#if confirmPassword && !passwordsMatch}
<p class="text-xs text-destructive">Passwords do not match</p>
{/if}
</div>
{/if}
<ErrorAlert message={error} />
{#if authMode === "passkey"}
<LoadingButton
type="submit"
class="h-10 w-full"
disabled={!isFormValid}
loading={isLoading}
loadingText="Creating passkey..."
>
Create with passkey
</LoadingButton>
<div class="text-center">
<button
type="button"
onclick={switchToPassword}
class="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4"
>
Or use password instead
</button>
</div>
{:else}
<LoadingButton
type="submit"
class="h-10 w-full"
disabled={!isFormValid}
loading={isLoading}
loadingText="Creating account..."
>
Create account
</LoadingButton>
{#if supportsPasskey}
<div class="text-center">
<button
type="button"
onclick={switchToPasskey}
class="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4"
>
Or use passkey instead
</button>
</div>
{/if}
{/if}
</form>
<!-- Sign in link -->
<div class="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<a href="/auth/login" class="text-foreground underline underline-offset-4 hover:text-primary">
Sign in
</a>
</div>

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { MapPin, Monitor, Shield, Smartphone, Tablet } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { UAParser } from "ua-parser-js";
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 { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { LoadingButton } from "$lib/components/ui/loading-button";
// Fetch device info from server
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
const deviceQuery = createQuery(() => ({
queryKey: ["deviceInfo"],
queryFn: () => api.me.getDeviceInfo(),
}));
// Parse user agent for suggested device name
const parser = new UAParser(navigator.userAgent);
const browserName = parser.getBrowser().name || "Browser";
const osName = parser.getOS().name || "Device";
const deviceType = parser.getDevice().type;
const suggestedName = $derived(`${browserName} on ${osName}`);
let deviceName = $state("");
let isSubmitting = $state(false);
let error = $state("");
// Initialize device name with suggestion
$effect(() => {
if (!deviceName && suggestedName) {
deviceName = suggestedName;
}
});
const isValid = $derived(
deviceName.trim().length >= 1 && deviceName.trim().length <= 100,
);
async function handleTrust() {
if (!isValid || isSubmitting) {
return;
}
isSubmitting = true;
error = "";
try {
await api.me.trustDevice({ name: deviceName.trim() });
toast.success("Device trusted successfully!");
goto("/performance");
} catch (e) {
error = e instanceof Error ? e.message : "Failed to trust device";
} finally {
isSubmitting = false;
}
}
async function handleSkip() {
goto("/performance");
}
// Get device icon based on type
function getDeviceIcon() {
switch (deviceType) {
case "mobile":
return Smartphone;
case "tablet":
return Tablet;
default:
return Monitor;
}
}
const DeviceIcon = getDeviceIcon();
</script>
<svelte:head>
<title>Trust This Device | Publisher Dashboard</title>
</svelte:head>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Trust this device?</h1>
<p class="text-sm text-muted-foreground">
Trusted devices can sign in without email verification
</p>
</div>
<!-- Device Info Card -->
<div class="rounded-lg border border-border bg-card p-4 space-y-4">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<DeviceIcon class="h-6 w-6 text-primary" />
</div>
<div class="flex-1 space-y-1">
<p class="font-medium text-foreground">{browserName} on {osName}</p>
{#if deviceQuery.isLoading}
<p class="text-sm text-muted-foreground">Loading device info...</p>
{:else if deviceQuery.data}
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<MapPin class="h-3 w-3" />
<span>
{#if deviceQuery.data.city && deviceQuery.data.country}
{deviceQuery.data.city}, {deviceQuery.data.country}
{:else if deviceQuery.data.country}
{deviceQuery.data.country}
{:else}
Unknown location
{/if}
</span>
</div>
<p class="text-xs text-muted-foreground">
IP: {deviceQuery.data.ip}
</p>
{/if}
</div>
</div>
<!-- Device Name Input -->
<div class="space-y-2">
<Label for="deviceName">Device name</Label>
<Input
id="deviceName"
type="text"
placeholder="e.g., Work Laptop, Home Desktop"
bind:value={deviceName}
maxlength={100}
class="h-10"
/>
<p class="text-xs text-muted-foreground">
Give this device a name to recognize it later
</p>
</div>
</div>
<!-- Security Note -->
<div class="flex items-start gap-3 rounded-lg bg-muted/50 p-3">
<Shield class="h-5 w-5 text-muted-foreground mt-0.5" />
<div class="text-sm text-muted-foreground">
<p class="font-medium text-foreground">Only trust devices you own</p>
<p>Don't trust shared or public computers. You can manage trusted devices in your account settings.</p>
</div>
</div>
<ErrorAlert message={error} />
<!-- Actions -->
<div class="space-y-3">
<LoadingButton
onclick={handleTrust}
class="h-10 w-full"
disabled={!isValid}
loading={isSubmitting}
loadingText="Trusting device..."
>
Yes, trust this device
</LoadingButton>
<Button onclick={handleSkip} variant="ghost" class="h-10 w-full" disabled={isSubmitting}>
No thanks, continue without trusting
</Button>
</div>

View File

@@ -0,0 +1,143 @@
<script lang="ts">
import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { ErrorAlert } from "$lib/components/auth";
import { Button } from "$lib/components/ui/button";
/**
* Email verification callback page
* Automatically verifies the email token from URL on mount
* Redirects to dashboard on success
*/
let isVerifying = $state(true);
let error = $state("");
const token = $derived(page.url.searchParams.get("token"));
/**
* Verify email with the token from URL
*/
async function verifyEmail(): Promise<void> {
if (!token) {
error = "No verification token provided";
isVerifying = false;
return;
}
try {
await api.auth.verifyEmail({ token });
toast.success("Email verified successfully!");
goto("/performance");
} catch (e) {
error = e instanceof Error ? e.message : "Verification failed";
} finally {
isVerifying = false;
}
}
// Auto-verify on mount
$effect(() => {
if (token) {
void verifyEmail();
} else {
error = "No verification token provided";
isVerifying = false;
}
});
/**
* Resend verification email
*/
async function resendVerification(): Promise<void> {
try {
await api.auth.resendVerificationEmail();
toast.success("Verification email sent! Check your inbox.");
error = "";
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to send verification email";
toast.error(message);
}
}
</script>
<svelte:head>
<title>Verify Email | Publisher Dashboard</title>
<meta name="description" content="Verify your email address" />
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="space-y-4 text-center">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
{#if isVerifying}
<Mail class="h-8 w-8 animate-pulse text-primary" />
{:else if error}
<XCircle class="h-8 w-8 text-destructive" />
{:else}
<CheckCircle2 class="h-8 w-8 text-green-600" />
{/if}
</div>
<div class="space-y-2">
<h1 class="text-2xl font-semibold tracking-tight">
{#if isVerifying}
Verifying your email...
{:else if error}
Verification failed
{:else}
Email verified!
{/if}
</h1>
<p class="text-sm text-muted-foreground">
{#if isVerifying}
Please wait while we verify your email address
{:else if error}
We could not verify your email address
{:else}
Redirecting to your dashboard...
{/if}
</p>
</div>
</div>
<!-- Loading indicator -->
{#if isVerifying}
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>Verifying...</span>
</div>
{/if}
<!-- Error Alert -->
{#if error}
<ErrorAlert message={error} />
<!-- Actions -->
<div class="space-y-3">
{#if token}
<Button class="h-10 w-full" onclick={verifyEmail}>
Try again
</Button>
{/if}
<Button variant="outline" class="h-10 w-full" onclick={resendVerification}>
Request new verification link
</Button>
<div class="text-center">
<a
href="/auth/login"
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>
{/if}
</div>

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import "../../app.css";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
{@render children()}

View File

@@ -1,211 +1,12 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
let email = $state("");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = "";
// Simulate login - replace with actual auth logic
await new Promise((resolve) => setTimeout(resolve, 1000));
// For demo, just redirect to dashboard
goto("/performance");
}
// Redirect old /login route to new /auth/login
$effect(() => {
goto("/auth/login", { replaceState: true });
});
</script>
<svelte:head>
<title>Login | Publisher Dashboard</title>
<meta name="description" content="Sign in to your Publisher Dashboard account" />
</svelte:head>
<div class="grid min-h-screen lg:grid-cols-2">
<!-- Left Panel - Branding -->
<div class="relative hidden bg-primary lg:block">
<div class="absolute inset-0 bg-gradient-to-br from-primary via-primary to-chart-1/20"></div>
<div class="relative flex h-full flex-col justify-between p-10">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-foreground">
<svg
class="h-6 w-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-primary-foreground">Publisher Dashboard</span>
</div>
<!-- Testimonial -->
<div class="space-y-4">
<blockquote class="text-lg font-light leading-relaxed text-primary-foreground/90">
"This dashboard has transformed how we analyze our publishing metrics. The insights are
invaluable for optimizing our content strategy and maximizing revenue."
</blockquote>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-foreground/20 text-sm font-medium text-primary-foreground"
>
SK
</div>
<div>
<p class="font-medium text-primary-foreground">Sarah Kim</p>
<p class="text-sm text-primary-foreground/70">Head of Digital, MediaCorp</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Panel - Login Form -->
<div class="flex items-center justify-center bg-background p-6 lg:p-10">
<div class="mx-auto w-full max-w-sm space-y-8">
<!-- Mobile Logo -->
<div class="flex items-center justify-center gap-3 lg:hidden">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg
class="h-6 w-6 text-primary-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-foreground">Publisher Dashboard</span>
</div>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Welcome back</h1>
<p class="text-sm text-muted-foreground">
Enter your credentials to access your dashboard
</p>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
bind:value={email}
required
class="h-10"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<a href="/forgot-password" class="text-xs text-muted-foreground hover:text-foreground">
Forgot password?
</a>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
bind:value={password}
required
class="h-10"
/>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Button type="submit" class="h-10 w-full" disabled={isLoading || !email || !password}>
{#if isLoading}
<svg class="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Signing in...
{:else}
Sign in
{/if}
</Button>
</form>
<!-- Divider -->
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-border"></span>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<!-- Social Login -->
<div class="grid grid-cols-2 gap-3">
<Button variant="outline" class="h-10">
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google
</Button>
<Button variant="outline" class="h-10">
<svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
GitHub
</Button>
</div>
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and
<a href="/privacy" class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p>
</div>
</div>
<div class="flex min-h-screen items-center justify-center">
<p class="text-muted-foreground">Redirecting...</p>
</div>

View File

@@ -62,13 +62,18 @@
"@orpc/client": "^1.13.2",
"@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"libphonenumber-js": "^1.12.33",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tslib": "catalog:",
"ua-parser-js": "^2.0.7",
"zxcvbn": "^4.4.2",
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -79,6 +84,8 @@
"@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"svelte": "^5.28.2",
"svelte-check": "^4.2.1",
@@ -397,6 +404,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="],
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
@@ -467,6 +476,8 @@
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@types/ua-parser-js": ["@types/ua-parser-js@0.7.39", "", {}, "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg=="],
"@types/zxcvbn": ["@types/zxcvbn@4.4.5", "", {}, "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="],
@@ -557,6 +568,8 @@
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
@@ -689,6 +702,8 @@
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -893,6 +908,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
@@ -943,6 +960,10 @@
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
"ua-parser-js": ["ua-parser-js@2.0.7", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -997,6 +1018,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],

View File

@@ -1982,8 +1982,8 @@ bun add -D kysely-codegen @types/pg @types/zxcvbn @types/ua-parser-js @types/geo
### Frontend
```bash
bun add @orpc/client @orpc/tanstack-query @simplewebauthn/browser @tanstack/svelte-query libphonenumber-js zxcvbn
bun add -D @types/zxcvbn
bun add @orpc/client @orpc/tanstack-query @simplewebauthn/browser @tanstack/svelte-query libphonenumber-js zxcvbn ua-parser-js svelte-sonner
bun add -D @types/zxcvbn @types/ua-parser-js
```
| Package | Purpose |
@@ -1994,6 +1994,8 @@ bun add -D @types/zxcvbn
| `@tanstack/svelte-query` | Server state management, caching, refetching |
| `libphonenumber-js` | Phone number parsing/validation/formatting |
| `zxcvbn` | Client-side password strength estimation |
| `ua-parser-js` | User-Agent parsing for device name display |
| `svelte-sonner` | Toast notifications for success/error states |
### CLI
@@ -2275,16 +2277,16 @@ _Can run parallel to D, E, F_
_Depends on: D1-D9, E1-E4, C3_
- [ ] **H1**: Create `/auth/signup` page (passkey detection, password fallback)
- [ ] **H2**: Create `/auth/setup/user` page (profile setup)
- [ ] **H3**: Create `/auth/login` page (email entry, createLoginRequest)
- [ ] **H4**: Create `/auth/login/passkey` page (WebAuthn flow)
- [ ] **H5**: Create `/auth/login/password` page
- [ ] **H6**: Create `/auth/confirm` page (polling for email confirmation)
- [ ] **H7**: Create `/auth/trust-device` page
- [ ] **H8**: Create `/auth/verify` page (email verification callback)
- [ ] **H9**: Create `/auth/forgot-password` page
- [ ] **H10**: Create `/auth/reset-password` page
- [x] **H1**: Create `/auth/signup` page (passkey detection, password fallback)
- [x] **H2**: Create `/auth/setup/user` page (profile setup)
- [x] **H3**: Create `/auth/login` page (email entry, createLoginRequest)
- [x] **H4**: Create `/auth/login/passkey` page (WebAuthn flow)
- [x] **H5**: Create `/auth/login/password` page
- [x] **H6**: Create `/auth/confirm` page (polling for email confirmation)
- [x] **H7**: Create `/auth/trust-device` page
- [x] **H8**: Create `/auth/verify` page (email verification callback)
- [x] **H9**: Create `/auth/forgot-password` page
- [x] **H10**: Create `/auth/reset-password` page
#### Workstream I: Account Pages (Frontend)