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>