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}
+
+{/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 @@
+
+
+
+
+ {label}
+ {#if required}
+ *
+ {/if}
+
+
+ {#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 @@
+
+
+
+
+
+ {#if showPassword}
+
+ {:else}
+
+ {/if}
+
+
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 @@
+
+
+
+
+
+ {@render children?.()}
+
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 @@
+
+
+
+ {#if loading}
+
+ {loadingText ?? "Loading..."}
+ {:else}
+ {@render children()}
+ {/if}
+
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
+
+
+
+
+
+ "This dashboard has transformed how we analyze our publishing metrics. The insights are
+ invaluable for optimizing our content strategy and maximizing revenue."
+
+
+
+ SK
+
+
+
Sarah Kim
+
Head of Digital, MediaCorp
+
+
+
+
+
+
+
+
+
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}
+
0}
+ onclick={handleResendEmail}
+ class="h-10 w-full"
+ >
+
+ {#if resendCooldown > 0}
+ Resend email ({resendCooldown}s)
+ {:else}
+ Resend email
+ {/if}
+
+ {/if}
+
+
+
+ Use a different email
+
+
+
+
+
+ {#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.
+
+
+
{
+ isSubmitted = false;
+ email = "";
+ }}
+ >
+ Try another email
+
+
+{:else}
+
+
+{/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
+
+
+
+
+
+
+
+
+
+
+
+ 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}
+
+ Use password instead
+
+ {/if}
+
+
+ goto("/auth/login")}
+ class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
+ >
+ Use a different email
+
+
+
+
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}
+
+
+
+
+
+
+
+
+
+
+
+
+ Forgot password?
+
+
+ {#if loginFlowState.hasPasskey}
+
+ {/if}
+
+
+
+ Different email?
+
+
+
+
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.
+
+
+
+ goto("/auth/forgot-password")}>
+ Request new reset link
+
+{:else}
+
+
+{/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
+
+
+
+
+
+{/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}
+
+
+
+
+
+
+
+
+ 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}
+
+
+
+
+
+
Device name
+
+
+ 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
+
+
+
+ No thanks, continue without trusting
+
+
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}
+
+ Try again
+
+ {/if}
+
+
+ Request new verification link
+
+
+
+
+ {/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
-
-
-
-
-
- "This dashboard has transformed how we analyze our publishing metrics. The insights are
- invaluable for optimizing our content strategy and maximizing revenue."
-
-
-
- SK
-
-
-
Sarah Kim
-
Head of Digital, MediaCorp
-
-
-
-
-
-
-
-
-
-
-
-
-
Publisher Dashboard
-
-
-
-
-
Welcome back
-
- Enter your credentials to access your dashboard
-
-
-
-
-
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
-
-
-
-
-
-
-
- Google
-
-
-
-
-
- GitHub
-
-
-
-
-
- By continuing, you agree to our
- Terms of Service
- and
- Privacy Policy
-
-
-
+
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=="],