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..4f66a77
--- /dev/null
+++ b/apps/publisher-dashboard/src/lib/components/ui/alert/index.ts
@@ -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,
+};
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..7cfbfe5
--- /dev/null
+++ b/apps/publisher-dashboard/src/lib/components/ui/loading-button/index.ts
@@ -0,0 +1,3 @@
+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..c484912
--- /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..619005f
--- /dev/null
+++ b/apps/publisher-dashboard/src/lib/stores/auth.svelte.ts
@@ -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}`;
+}
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..82d9ae4
--- /dev/null
+++ b/apps/publisher-dashboard/src/lib/utils/validation.ts
@@ -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";
+}
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..3ae9eba
--- /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."
+
+ {#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 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}
+
- "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
-