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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { AlertCircle } from "@lucide/svelte";
|
||||
|
||||
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}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import PasswordInput from "./password-input.svelte";
|
||||
import PasswordStrength from "./password-strength.svelte";
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Eye, EyeOff } from "@lucide/svelte";
|
||||
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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } 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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./loading-button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as LoadingButton,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonProps } from "$lib/components/ui/button";
|
||||
import { Loader2 } from "@lucide/svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
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>
|
||||
73
apps/publisher-dashboard/src/lib/stores/auth.svelte.ts
Normal file
73
apps/publisher-dashboard/src/lib/stores/auth.svelte.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
51
apps/publisher-dashboard/src/lib/utils/validation.ts
Normal file
51
apps/publisher-dashboard/src/lib/utils/validation.ts
Normal file
@@ -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";
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
89
apps/publisher-dashboard/src/routes/auth/+layout.svelte
Normal file
89
apps/publisher-dashboard/src/routes/auth/+layout.svelte
Normal 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>
|
||||
162
apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte
Normal file
162
apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "$lib/api/client";
|
||||
import {
|
||||
loginFlowState,
|
||||
clearLoginFlowState,
|
||||
getMaskedEmail,
|
||||
} from "$lib/stores/auth.svelte";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Loader2, Mail, AlertCircle, RefreshCw } from "@lucide/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 = 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>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
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 { CheckCircle2 } from "@lucide/svelte";
|
||||
|
||||
// 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>
|
||||
83
apps/publisher-dashboard/src/routes/auth/login/+page.svelte
Normal file
83
apps/publisher-dashboard/src/routes/auth/login/+page.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import { setLoginFlowState } from "$lib/stores/auth.svelte";
|
||||
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";
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
import { api } from "$lib/api/client";
|
||||
import { loginFlowState, getMaskedEmail } from "$lib/stores/auth.svelte";
|
||||
import { Fingerprint, KeyRound, Loader2 } from "@lucide/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) {
|
||||
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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import { loginFlowState, clearLoginFlowState } from "$lib/stores/auth.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { PasswordInput, ErrorAlert } from "$lib/components/auth";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { api } from "$lib/api/client";
|
||||
import { PasswordInput, PasswordStrength, ErrorAlert } 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";
|
||||
import { AlertCircle } from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
// 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>
|
||||
161
apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte
Normal file
161
apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
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 { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||
import { AlertCircle, Loader2 } from "@lucide/svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
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}
|
||||
241
apps/publisher-dashboard/src/routes/auth/signup/+page.svelte
Normal file
241
apps/publisher-dashboard/src/routes/auth/signup/+page.svelte
Normal file
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import { PasswordInput, PasswordStrength, 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 { browserSupportsWebAuthn, startRegistration } from "@simplewebauthn/browser";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
// 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>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
import { Monitor, Smartphone, Tablet, Shield, MapPin } from "@lucide/svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
// 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>
|
||||
142
apps/publisher-dashboard/src/routes/auth/verify/+page.svelte
Normal file
142
apps/publisher-dashboard/src/routes/auth/verify/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { ErrorAlert } from "$lib/components/auth";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { api } from "$lib/api/client";
|
||||
import { Mail, XCircle, CheckCircle2, Loader2 } from "@lucide/svelte";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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>
|
||||
@@ -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()}
|
||||
@@ -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";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
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>
|
||||
|
||||
23
bun.lock
23
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=="],
|
||||
|
||||
Reference in New Issue
Block a user