From 073db98a91669b869058c37ce1e68fc8193b6218 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:32:35 +0800 Subject: [PATCH] Implement Workstream H: Auth pages with refactored components Add 10 authentication pages for the Publisher Dashboard: - H1: /auth/signup - Account creation with passkey/password - H2: /auth/setup/user - Profile setup with phone validation - H3: /auth/login - Email entry with routing logic - H4: /auth/login/passkey - WebAuthn authentication - H5: /auth/login/password - Password authentication - H6: /auth/confirm - Email verification polling - H7: /auth/trust-device - Device trust prompt - H8: /auth/verify - Email verification callback - H9: /auth/forgot-password - Password reset request - H10: /auth/reset-password - New password form New reusable components: - LoadingButton: Button with Loader2 spinner and loading state - ErrorAlert: Accessible error display with ARIA live region - PasswordFormField: Composite field with label, input, strength meter - PasswordInput: Improved with bind:value and cn() class merging New utilities: - validation.ts: Email, phone validation, email masking, error parsing - auth.svelte.ts: Login flow state store for SPA mode guards Backend updates: - Implement me.get, me.setupProfile, me.getDeviceInfo, me.trustDevice Dependencies added: - @simplewebauthn/browser, libphonenumber-js, ua-parser-js - zxcvbn, svelte-sonner, shadcn alert component Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/router.ts | 82 +++++- apps/publisher-dashboard/package.json | 9 +- .../lib/components/auth/error-alert.svelte | 19 ++ .../src/lib/components/auth/index.ts | 4 + .../auth/password-form-field.svelte | 54 ++++ .../lib/components/auth/password-input.svelte | 64 +++++ .../components/auth/password-strength.svelte | 57 +++++ .../ui/alert/alert-description.svelte | 23 ++ .../components/ui/alert/alert-title.svelte | 20 ++ .../src/lib/components/ui/alert/alert.svelte | 44 ++++ .../src/lib/components/ui/alert/index.ts | 14 + .../lib/components/ui/loading-button/index.ts | 6 + .../ui/loading-button/loading-button.svelte | 28 ++ .../src/lib/stores/auth.svelte.ts | 73 ++++++ .../src/lib/utils/validation.ts | 51 ++++ .../src/routes/+layout.svelte | 2 + .../src/routes/auth/+layout.svelte | 89 +++++++ .../src/routes/auth/confirm/+page.svelte | 162 ++++++++++++ .../routes/auth/forgot-password/+page.svelte | 124 +++++++++ .../src/routes/auth/login/+page.svelte | 83 ++++++ .../routes/auth/login/passkey/+page.svelte | 150 +++++++++++ .../routes/auth/login/password/+page.svelte | 109 ++++++++ .../routes/auth/reset-password/+page.svelte | 144 +++++++++++ .../src/routes/auth/setup/user/+page.svelte | 161 ++++++++++++ .../src/routes/auth/signup/+page.svelte | 241 ++++++++++++++++++ .../src/routes/auth/trust-device/+page.svelte | 163 ++++++++++++ .../src/routes/auth/verify/+page.svelte | 142 +++++++++++ .../src/routes/login/+layout.svelte | 12 - .../src/routes/login/+page.svelte | 213 +--------------- bun.lock | 23 ++ 30 files changed, 2138 insertions(+), 228 deletions(-) create mode 100644 apps/publisher-dashboard/src/lib/components/auth/error-alert.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/auth/index.ts create mode 100644 apps/publisher-dashboard/src/lib/components/auth/password-form-field.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/auth/password-input.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/auth/password-strength.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/ui/alert/alert-description.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/ui/alert/alert-title.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/ui/alert/alert.svelte create mode 100644 apps/publisher-dashboard/src/lib/components/ui/alert/index.ts create mode 100644 apps/publisher-dashboard/src/lib/components/ui/loading-button/index.ts create mode 100644 apps/publisher-dashboard/src/lib/components/ui/loading-button/loading-button.svelte create mode 100644 apps/publisher-dashboard/src/lib/stores/auth.svelte.ts create mode 100644 apps/publisher-dashboard/src/lib/utils/validation.ts create mode 100644 apps/publisher-dashboard/src/routes/auth/+layout.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/forgot-password/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/login/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/login/passkey/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/login/password/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/reset-password/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/signup/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/auth/verify/+page.svelte delete mode 100644 apps/publisher-dashboard/src/routes/login/+layout.svelte diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 7743973..d654332 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -105,14 +105,47 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication }); // Me procedures -const meGet = os.me.get.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => { + const user = await context.db + .selectFrom("users") + .where("id", "=", context.user.id) + .select([ + "id", + "email", + "display_name", + "full_name", + "phone_number", + "avatar_url", + "email_verified_at", + "is_superuser", + ]) + .executeTakeFirstOrThrow(); + + return { + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: !!user.email_verified_at, + needsSetup: !user.display_name, + isSuperuser: user.is_superuser, + }; }); const setupProfile = os.me.setupProfile .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); + .handler(async ({ input, context }) => { + await context.db + .updateTable("users") + .set({ + display_name: input.displayName, + full_name: input.fullName ?? null, + phone_number: input.phoneNumber ?? null, + }) + .where("id", "=", context.user.id) + .execute(); }); const updateProfile = os.me.updateProfile @@ -206,13 +239,44 @@ const revokeAllSessions = os.me.revokeAllSessions const getDeviceInfo = os.me.getDeviceInfo .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); + .handler(async ({ context }) => { + const session = await context.db + .selectFrom("sessions") + .where("id", "=", context.session.id) + .select([ + "ip_address", + "city", + "region", + "country", + "user_agent", + ]) + .executeTakeFirstOrThrow(); + + return { + id: 0, + name: "Unknown Device", + ip: session.ip_address ?? "Unknown", + city: session.city, + region: session.region, + country: session.country, + lastUsedAt: new Date(), + isTrusted: context.session.trustedMode, + }; }); -const trustDevice = os.me.trustDevice.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); +const trustDevice = os.me.trustDevice + .use(authMiddleware) + .handler(async ({ input, context }) => { + // Note: Sessions table doesn't have a device_name field + // The name parameter is accepted by the contract but not stored + await context.db + .updateTable("sessions") + .set({ + trusted_mode: true, + }) + .where("id", "=", context.session.id) + .execute(); + }); const listTrustedDevices = os.me.listTrustedDevices .use(authMiddleware) diff --git a/apps/publisher-dashboard/package.json b/apps/publisher-dashboard/package.json index 90172b9..c52025c 100644 --- a/apps/publisher-dashboard/package.json +++ b/apps/publisher-dashboard/package.json @@ -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", diff --git a/apps/publisher-dashboard/src/lib/components/auth/error-alert.svelte b/apps/publisher-dashboard/src/lib/components/auth/error-alert.svelte new file mode 100644 index 0000000..8940009 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/error-alert.svelte @@ -0,0 +1,19 @@ + + +{#if message} +
+ + + {message} + +
+{/if} diff --git a/apps/publisher-dashboard/src/lib/components/auth/index.ts b/apps/publisher-dashboard/src/lib/components/auth/index.ts new file mode 100644 index 0000000..1dc6826 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/index.ts @@ -0,0 +1,4 @@ +export { default as PasswordInput } from "./password-input.svelte"; +export { default as PasswordStrength } from "./password-strength.svelte"; +export { default as PasswordFormField } from "./password-form-field.svelte"; +export { default as ErrorAlert } from "./error-alert.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/auth/password-form-field.svelte b/apps/publisher-dashboard/src/lib/components/auth/password-form-field.svelte new file mode 100644 index 0000000..dbf3890 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/password-form-field.svelte @@ -0,0 +1,54 @@ + + +
+ + + {#if showStrength} + + {/if} + {#if error} +

{error}

+ {/if} +
diff --git a/apps/publisher-dashboard/src/lib/components/auth/password-input.svelte b/apps/publisher-dashboard/src/lib/components/auth/password-input.svelte new file mode 100644 index 0000000..ba361ec --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/password-input.svelte @@ -0,0 +1,64 @@ + + +
+ + +
diff --git a/apps/publisher-dashboard/src/lib/components/auth/password-strength.svelte b/apps/publisher-dashboard/src/lib/components/auth/password-strength.svelte new file mode 100644 index 0000000..7fc28c4 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/auth/password-strength.svelte @@ -0,0 +1,57 @@ + + +{#if password} +
+ +
+ {#each Array(4) as _, i} +
+ {/each} +
+ + +

+ Password strength: {config.label} +

+ + + {#if result?.feedback.warning || result?.feedback.suggestions.length} +
+ {#if result.feedback.warning} +

{result.feedback.warning}

+ {/if} + {#each result.feedback.suggestions as suggestion} +

{suggestion}

+ {/each} +
+ {/if} +
+{/if} diff --git a/apps/publisher-dashboard/src/lib/components/ui/alert/alert-description.svelte b/apps/publisher-dashboard/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..8b56aed --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/apps/publisher-dashboard/src/lib/components/ui/alert/alert-title.svelte b/apps/publisher-dashboard/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..77e45ad --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/publisher-dashboard/src/lib/components/ui/alert/alert.svelte b/apps/publisher-dashboard/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..2b2eff9 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/apps/publisher-dashboard/src/lib/components/ui/alert/index.ts b/apps/publisher-dashboard/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..97e21b4 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/apps/publisher-dashboard/src/lib/components/ui/loading-button/index.ts b/apps/publisher-dashboard/src/lib/components/ui/loading-button/index.ts new file mode 100644 index 0000000..5479576 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/loading-button/index.ts @@ -0,0 +1,6 @@ +import Root from "./loading-button.svelte"; + +export { + Root, + Root as LoadingButton, +}; diff --git a/apps/publisher-dashboard/src/lib/components/ui/loading-button/loading-button.svelte b/apps/publisher-dashboard/src/lib/components/ui/loading-button/loading-button.svelte new file mode 100644 index 0000000..f9fe5f3 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/ui/loading-button/loading-button.svelte @@ -0,0 +1,28 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/stores/auth.svelte.ts b/apps/publisher-dashboard/src/lib/stores/auth.svelte.ts new file mode 100644 index 0000000..dd038c0 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/stores/auth.svelte.ts @@ -0,0 +1,73 @@ +/** + * 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}`; +} diff --git a/apps/publisher-dashboard/src/lib/utils/validation.ts b/apps/publisher-dashboard/src/lib/utils/validation.ts new file mode 100644 index 0000000..99728a8 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/utils/validation.ts @@ -0,0 +1,51 @@ +import { parsePhoneNumberWithError, isValidPhoneNumber } 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"; +} diff --git a/apps/publisher-dashboard/src/routes/+layout.svelte b/apps/publisher-dashboard/src/routes/+layout.svelte index 80c8b2c..d3b1642 100644 --- a/apps/publisher-dashboard/src/routes/+layout.svelte +++ b/apps/publisher-dashboard/src/routes/+layout.svelte @@ -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()} + diff --git a/apps/publisher-dashboard/src/routes/auth/+layout.svelte b/apps/publisher-dashboard/src/routes/auth/+layout.svelte new file mode 100644 index 0000000..fa46c10 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/+layout.svelte @@ -0,0 +1,89 @@ + + + + Publisher Dashboard + + +
+ + + + +
+
+ +
+
+ + + +
+ Publisher Dashboard +
+ + + {@render children()} + + +

+ By continuing, you agree to our + Terms of Service + and + Privacy Policy +

+
+
+
diff --git a/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte new file mode 100644 index 0000000..e979c2c --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte @@ -0,0 +1,162 @@ + + +
+ +
+
+ +
+
+

Check your email

+

+ We sent a verification link to + {getMaskedEmail()} +

+
+
+ + +
+ + Waiting for email verification... +
+ + + + + + + {#if statusQuery.data?.status === "expired"} + + + + Your verification link has expired. Please request a new one. + + + {/if} + + +
+ {#if isResending} + + Resend email + + {:else} + + {/if} + +
+ +
+
+ + + {#if import.meta.env.DEV} +
+

+ Development Mode +

+

+ To complete login without email, use the CLI command: +

+ + bun run cli auth complete-login --email {loginFlowState.email} + +
+ {/if} +
diff --git a/apps/publisher-dashboard/src/routes/auth/forgot-password/+page.svelte b/apps/publisher-dashboard/src/routes/auth/forgot-password/+page.svelte new file mode 100644 index 0000000..a77f7bb --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/forgot-password/+page.svelte @@ -0,0 +1,124 @@ + + + + Forgot Password | Publisher Dashboard + + + + +
+

Forgot password?

+

+ {#if isSubmitted} + Check your email for reset instructions + {:else} + Enter your email and we'll send you reset instructions + {/if} +

+
+ +{#if isSubmitted} + + + + + If an account exists with this email, we've sent password reset instructions. + + + +
+

+ Didn't receive the email? Check your spam folder or try again with a different email. +

+ + +
+{:else} + +
+
+ + +
+ + + + + Send reset instructions + + +{/if} + + +
+ Remember your password?{" "} + + Sign in + +
diff --git a/apps/publisher-dashboard/src/routes/auth/login/+page.svelte b/apps/publisher-dashboard/src/routes/auth/login/+page.svelte new file mode 100644 index 0000000..b5287ee --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/login/+page.svelte @@ -0,0 +1,83 @@ + + +
+ +
+

Welcome back

+

Enter your email to sign in to your account

+
+ + + + + +
+
+ + +
+ + + Continue + +
+ + +
+

+ Don't have an account? + + Sign up + +

+
+
diff --git a/apps/publisher-dashboard/src/routes/auth/login/passkey/+page.svelte b/apps/publisher-dashboard/src/routes/auth/login/passkey/+page.svelte new file mode 100644 index 0000000..7cd1b77 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/login/passkey/+page.svelte @@ -0,0 +1,150 @@ + + + + Passkey Authentication | Publisher Dashboard + + + +
+ +
+
+ {#if isAuthenticating} + + {:else if error} + + {:else} + + {/if} +
+ +
+

+ {#if isAuthenticating} + Authenticating... + {:else if error} + Authentication failed + {:else} + Use your passkey + {/if} +

+ +

+ {#if isAuthenticating} + Follow the prompts to authenticate with your passkey + {:else if maskedEmail} + Signing in as {maskedEmail} + {:else} + Use your passkey to sign in + {/if} +

+
+
+ + + {#if isAuthenticating && !error && !hasAttempted} +
+ + Waiting for passkey authentication... +
+ {/if} + + + + + +
+ {#if error || hasAttempted} + + Try again + + {/if} + + + {#if loginFlowState.hasPassword} + + {/if} + +
+ +
+
+
diff --git a/apps/publisher-dashboard/src/routes/auth/login/password/+page.svelte b/apps/publisher-dashboard/src/routes/auth/login/password/+page.svelte new file mode 100644 index 0000000..dca079c --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/login/password/+page.svelte @@ -0,0 +1,109 @@ + + +
+ +
+

Enter your password

+

+ Signing in as {loginFlowState.email} +

+
+ + + + + +
+
+ + +
+ + + Sign in + +
+ + +
+ + Forgot password? + + + {#if loginFlowState.hasPasskey} + + {/if} + +
+ +
+
+
diff --git a/apps/publisher-dashboard/src/routes/auth/reset-password/+page.svelte b/apps/publisher-dashboard/src/routes/auth/reset-password/+page.svelte new file mode 100644 index 0000000..5be836d --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/reset-password/+page.svelte @@ -0,0 +1,144 @@ + + + + Reset Password | Publisher Dashboard + + + + +
+

Set new password

+

+ Create a strong password for your account +

+
+ +{#if !token} + + + + + Invalid password reset link. Please request a new one. + + + + +{:else} + +
+
+ + + +
+ +
+ + + {#if confirmPassword && !passwordsMatch} +

Passwords do not match

+ {/if} +
+ + + + + Reset password + + +{/if} + + +
+ Remember your password?{" "} + + Sign in + +
diff --git a/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte b/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte new file mode 100644 index 0000000..f87155d --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte @@ -0,0 +1,161 @@ + + + + Complete Your Profile | Publisher Dashboard + + +{#if userQuery.isLoading} +
+ +
+{:else if userQuery.error} + + + Failed to load user data. Please try again. + +{:else} + +
+

Complete your profile

+

+ Tell us a bit about yourself to get started +

+
+ + +
+
+ + +

+ This will be shown to other team members +

+
+ +
+ + +
+ +
+ + + {#if phoneError} +

{phoneError}

+ {:else} +

+ Include country code for international numbers +

+ {/if} +
+ + + + + Continue to Dashboard + + +{/if} diff --git a/apps/publisher-dashboard/src/routes/auth/signup/+page.svelte b/apps/publisher-dashboard/src/routes/auth/signup/+page.svelte new file mode 100644 index 0000000..ea9a90c --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/signup/+page.svelte @@ -0,0 +1,241 @@ + + + + Create Account | Publisher Dashboard + + + + +
+

Create an account

+

+ {#if authMode === "passkey"} + Use a passkey for secure, passwordless authentication + {:else} + Enter your details to create your account + {/if} +

+
+ + +
+
+ + +
+ + {#if authMode === "password"} +
+ + + +
+ +
+ + + {#if confirmPassword && !passwordsMatch} +

Passwords do not match

+ {/if} +
+ {/if} + + + + {#if authMode === "passkey"} + + Create with passkey + + +
+ +
+ {:else} + + Create account + + + {#if supportsPasskey} +
+ +
+ {/if} + {/if} + + + +
+ Already have an account?{" "} + + Sign in + +
diff --git a/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte new file mode 100644 index 0000000..943b3e6 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte @@ -0,0 +1,163 @@ + + + + Trust This Device | Publisher Dashboard + + + +
+

Trust this device?

+

+ Trusted devices can sign in without email verification +

+
+ + +
+
+
+ +
+
+

{browserName} on {osName}

+ {#if deviceQuery.isLoading} +

Loading device info...

+ {:else if deviceQuery.data} +
+ + + {#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} + +
+

+ IP: {deviceQuery.data.ip} +

+ {/if} +
+
+ + +
+ + +

+ Give this device a name to recognize it later +

+
+
+ + +
+ +
+

Only trust devices you own

+

Don't trust shared or public computers. You can manage trusted devices in your account settings.

+
+
+ + + + +
+ + Yes, trust this device + + + +
diff --git a/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte b/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte new file mode 100644 index 0000000..fa994c7 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte @@ -0,0 +1,142 @@ + + + + Verify Email | Publisher Dashboard + + + +
+ +
+
+ {#if isVerifying} + + {:else if error} + + {:else} + + {/if} +
+ +
+

+ {#if isVerifying} + Verifying your email... + {:else if error} + Verification failed + {:else} + Email verified! + {/if} +

+ +

+ {#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} +

+
+
+ + + {#if isVerifying} +
+ + Verifying... +
+ {/if} + + + {#if error} + + + +
+ {#if token} + + {/if} + + + + +
+ {/if} +
diff --git a/apps/publisher-dashboard/src/routes/login/+layout.svelte b/apps/publisher-dashboard/src/routes/login/+layout.svelte deleted file mode 100644 index 794ade9..0000000 --- a/apps/publisher-dashboard/src/routes/login/+layout.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - -{@render children()} diff --git a/apps/publisher-dashboard/src/routes/login/+page.svelte b/apps/publisher-dashboard/src/routes/login/+page.svelte index 238c8a5..f8cf816 100644 --- a/apps/publisher-dashboard/src/routes/login/+page.svelte +++ b/apps/publisher-dashboard/src/routes/login/+page.svelte @@ -1,211 +1,12 @@ - - Login | Publisher Dashboard - - - -
- - - - -
-
- -
-
- - - -
- Publisher Dashboard -
- - -
-

Welcome back

-

- Enter your credentials to access your dashboard -

-
- - -
-
- - -
- -
-
- - - Forgot password? - -
- -
- - {#if error} -

{error}

- {/if} - - -
- - -
-
- -
-
- Or continue with -
-
- - -
- - -
- - -

- By continuing, you agree to our - Terms of Service - and - Privacy Policy -

-
-
+
+

Redirecting...

diff --git a/bun.lock b/bun.lock index 8bbdd2d..7def2e0 100644 --- a/bun.lock +++ b/bun.lock @@ -61,13 +61,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", @@ -78,6 +83,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", @@ -388,6 +395,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=="], @@ -458,6 +467,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=="], @@ -546,6 +557,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=="], @@ -678,6 +691,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=="], @@ -882,6 +897,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=="], @@ -932,6 +949,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=="], @@ -986,6 +1007,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=="],