Fix floating promise lint errors and apply code formatting
- Add void operator to async calls in $effect() blocks to satisfy noFloatingPromises lint rule: - passkey/+page.svelte: void authenticate() - verify/+page.svelte: void verifyEmail() - Apply biome formatter import reorganization across auth files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { AlertCircle } from "@lucide/svelte";
|
||||||
import { AlertCircle } from "@lucide/svelte";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { message }: Props = $props();
|
let { message }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if message}
|
{#if message}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
export { default as ErrorAlert } from "./error-alert.svelte";
|
||||||
|
export { default as PasswordFormField } from "./password-form-field.svelte";
|
||||||
export { default as PasswordInput } from "./password-input.svelte";
|
export { default as PasswordInput } from "./password-input.svelte";
|
||||||
export { default as PasswordStrength } from "./password-strength.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";
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from "$lib/components/ui/label";
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
import PasswordInput from "./password-input.svelte";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import PasswordStrength from "./password-strength.svelte";
|
import PasswordInput from "./password-input.svelte";
|
||||||
import type { HTMLInputAttributes } from "svelte/elements";
|
import PasswordStrength from "./password-strength.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id?: string;
|
id?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
showStrength?: boolean;
|
showStrength?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
autocomplete?: HTMLInputAttributes["autocomplete"];
|
autocomplete?: HTMLInputAttributes["autocomplete"];
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id = "password",
|
id = "password",
|
||||||
label = "Password",
|
label = "Password",
|
||||||
value = $bindable(""),
|
value = $bindable(""),
|
||||||
placeholder = "Enter your password",
|
placeholder = "Enter your password",
|
||||||
showStrength = false,
|
showStrength = false,
|
||||||
error = "",
|
error = "",
|
||||||
autocomplete = "current-password",
|
autocomplete = "current-password",
|
||||||
required = false,
|
required = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLInputAttributes } from "svelte/elements";
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Eye, EyeOff } from "@lucide/svelte";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Eye, EyeOff } from "@lucide/svelte";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autocomplete?: HTMLInputAttributes["autocomplete"];
|
autocomplete?: HTMLInputAttributes["autocomplete"];
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(""),
|
value = $bindable(""),
|
||||||
placeholder = "Enter your password",
|
placeholder = "Enter your password",
|
||||||
id = "password",
|
id = "password",
|
||||||
name = "password",
|
name = "password",
|
||||||
required = false,
|
required = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
autocomplete = "current-password",
|
autocomplete = "current-password",
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
|
|
||||||
function toggleVisibility() {
|
function toggleVisibility() {
|
||||||
showPassword = !showPassword;
|
showPassword = !showPassword;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import zxcvbn from "zxcvbn";
|
import zxcvbn from "zxcvbn";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
password: string;
|
password: string;
|
||||||
userInputs?: string[];
|
userInputs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { password, userInputs = [] }: Props = $props();
|
let { password, userInputs = [] }: Props = $props();
|
||||||
|
|
||||||
// Compute password strength using zxcvbn
|
// Compute password strength using zxcvbn
|
||||||
const result = $derived(password ? zxcvbn(password, userInputs) : null);
|
const result = $derived(password ? zxcvbn(password, userInputs) : null);
|
||||||
const score = $derived(result?.score ?? 0);
|
const score = $derived(result?.score ?? 0);
|
||||||
|
|
||||||
// Strength labels and colors
|
// Strength labels and colors
|
||||||
const strengthConfig = [
|
const strengthConfig = [
|
||||||
{ label: "Very weak", color: "bg-destructive", textColor: "text-destructive" },
|
{
|
||||||
{ label: "Weak", color: "bg-destructive", textColor: "text-destructive" },
|
label: "Very weak",
|
||||||
{ label: "Fair", color: "bg-warning", textColor: "text-warning" },
|
color: "bg-destructive",
|
||||||
{ label: "Good", color: "bg-success", textColor: "text-success" },
|
textColor: "text-destructive",
|
||||||
{ label: "Strong", color: "bg-success", textColor: "text-success" },
|
},
|
||||||
] as const;
|
{ 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]);
|
const config = $derived(strengthConfig[score]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if password}
|
{#if password}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
export const alertVariants = tv({
|
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",
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: "bg-card text-card-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import Root from "./alert.svelte";
|
import Root from "./alert.svelte";
|
||||||
import Description from "./alert-description.svelte";
|
import Description from "./alert-description.svelte";
|
||||||
import Title from "./alert-title.svelte";
|
import Title from "./alert-title.svelte";
|
||||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
|
||||||
|
export { type AlertVariant, alertVariants } from "./alert.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Description,
|
Description,
|
||||||
Title,
|
Title,
|
||||||
//
|
//
|
||||||
Root as Alert,
|
Root as Alert,
|
||||||
Description as AlertDescription,
|
Description as AlertDescription,
|
||||||
Title as AlertTitle,
|
Title as AlertTitle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import Root from "./loading-button.svelte";
|
import Root from "./loading-button.svelte";
|
||||||
|
|
||||||
export {
|
export { Root, Root as LoadingButton };
|
||||||
Root,
|
|
||||||
Root as LoadingButton,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button, type ButtonProps } from "$lib/components/ui/button";
|
import type { Snippet } from "svelte";
|
||||||
import { Loader2 } from "@lucide/svelte";
|
import { Loader2 } from "@lucide/svelte";
|
||||||
import type { Snippet } from "svelte";
|
import { Button, type ButtonProps } from "$lib/components/ui/button";
|
||||||
|
|
||||||
interface Props extends ButtonProps {
|
interface Props extends ButtonProps {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
loadingText?: string;
|
loadingText?: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
loading = false,
|
loading = false,
|
||||||
loadingText,
|
loadingText,
|
||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button disabled={loading || disabled} {...rest}>
|
<Button disabled={loading || disabled} {...rest}>
|
||||||
|
|||||||
@@ -61,13 +61,16 @@ export function hasActiveLoginFlow(): boolean {
|
|||||||
* Get masked email for display (e.g., "j***@example.com")
|
* Get masked email for display (e.g., "j***@example.com")
|
||||||
*/
|
*/
|
||||||
export function getMaskedEmail(): string {
|
export function getMaskedEmail(): string {
|
||||||
if (!loginFlowState.email) return "";
|
if (!loginFlowState.email) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
const [local, domain] = loginFlowState.email.split("@");
|
const [local, domain] = loginFlowState.email.split("@");
|
||||||
if (!domain) return loginFlowState.email;
|
if (!domain) {
|
||||||
|
return loginFlowState.email;
|
||||||
|
}
|
||||||
|
|
||||||
const maskedLocal =
|
const maskedLocal = local.length > 1 ? `${local[0]}***` : `${local}***`;
|
||||||
local.length > 1 ? local[0] + "***" : local + "***";
|
|
||||||
|
|
||||||
return `${maskedLocal}@${domain}`;
|
return `${maskedLocal}@${domain}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { parsePhoneNumberWithError, isValidPhoneNumber } from "libphonenumber-js";
|
import {
|
||||||
|
isValidPhoneNumber,
|
||||||
|
parsePhoneNumberWithError,
|
||||||
|
} from "libphonenumber-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates email format using a simple regex pattern.
|
* Validates email format using a simple regex pattern.
|
||||||
@@ -12,8 +15,13 @@ export function isValidEmail(email: string): boolean {
|
|||||||
* Validates and formats phone numbers using libphonenumber-js.
|
* Validates and formats phone numbers using libphonenumber-js.
|
||||||
* Returns validation status and optionally the formatted number.
|
* Returns validation status and optionally the formatted number.
|
||||||
*/
|
*/
|
||||||
export function validatePhone(value: string): { valid: boolean; formatted?: string } {
|
export function validatePhone(value: string): {
|
||||||
if (!value) return { valid: true };
|
valid: boolean;
|
||||||
|
formatted?: string;
|
||||||
|
} {
|
||||||
|
if (!value) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const phone = parsePhoneNumberWithError(value);
|
const phone = parsePhoneNumberWithError(value);
|
||||||
if (isValidPhoneNumber(phone.number)) {
|
if (isValidPhoneNumber(phone.number)) {
|
||||||
@@ -30,10 +38,13 @@ export function validatePhone(value: string): { valid: boolean; formatted?: stri
|
|||||||
*/
|
*/
|
||||||
export function maskEmail(email: string): string {
|
export function maskEmail(email: string): string {
|
||||||
const [local, domain] = email.split("@");
|
const [local, domain] = email.split("@");
|
||||||
if (!domain) return email;
|
if (!domain) {
|
||||||
const masked = local.length > 1
|
return email;
|
||||||
? `${local[0]}${"*".repeat(Math.min(local.length - 1, 3))}`
|
}
|
||||||
: local;
|
const masked =
|
||||||
|
local.length > 1
|
||||||
|
? `${local[0]}${"*".repeat(Math.min(local.length - 1, 3))}`
|
||||||
|
: local;
|
||||||
return `${masked}@${domain}`;
|
return `${masked}@${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,75 +1,75 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { AlertCircle, Loader2, Mail, RefreshCw } from "@lucide/svelte";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { api } from "$lib/api/client";
|
import { goto } from "$app/navigation";
|
||||||
import {
|
import { api } from "$lib/api/client";
|
||||||
loginFlowState,
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
clearLoginFlowState,
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
getMaskedEmail,
|
import { Button } from "$lib/components/ui/button";
|
||||||
} from "$lib/stores/auth.svelte";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import {
|
||||||
import { Button } from "$lib/components/ui/button";
|
clearLoginFlowState,
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
getMaskedEmail,
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
loginFlowState,
|
||||||
import { Loader2, Mail, AlertCircle, RefreshCw } from "@lucide/svelte";
|
} from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
let resendCooldown = $state(0);
|
let resendCooldown = $state(0);
|
||||||
let isResending = $state(false);
|
let isResending = $state(false);
|
||||||
let resendError = $state<string | null>(null);
|
let resendError = $state<string | null>(null);
|
||||||
|
|
||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loginFlowState.email) {
|
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");
|
goto("/auth/login");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll for login completion every 3 seconds
|
||||||
|
// In Svelte 5 with TanStack Query v6, options are passed as a thunk (function)
|
||||||
|
const statusQuery = createQuery(() => ({
|
||||||
|
queryKey: ["loginStatus"],
|
||||||
|
queryFn: () => api.auth.loginIfRequestIsCompleted(),
|
||||||
|
refetchInterval: 3000,
|
||||||
|
enabled: !!loginFlowState.email,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Watch for completed status
|
||||||
|
// In TanStack Query v6 for Svelte 5, query results are reactive objects (not stores)
|
||||||
|
$effect(() => {
|
||||||
|
if (statusQuery.data?.status === "completed") {
|
||||||
|
clearLoginFlowState();
|
||||||
|
goto(statusQuery.data.redirectTo || "/performance");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cooldown timer
|
||||||
|
$effect(() => {
|
||||||
|
if (resendCooldown > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resendCooldown -= 1;
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleResendEmail() {
|
||||||
|
resendError = null;
|
||||||
|
isResending = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.auth.resendVerificationEmail();
|
||||||
|
resendCooldown = 60;
|
||||||
|
} catch (err) {
|
||||||
|
resendError = err instanceof Error ? err.message : "Failed to resend email";
|
||||||
|
} finally {
|
||||||
|
isResending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDifferentEmail() {
|
||||||
|
clearLoginFlowState();
|
||||||
|
goto("/auth/login");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,46 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from "$lib/api/client";
|
import { CheckCircle2 } from "@lucide/svelte";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { api } from "$lib/api/client";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { CheckCircle2 } from "@lucide/svelte";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let email = $state("");
|
let email = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let isSubmitted = $state(false);
|
let isSubmitted = $state(false);
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
const isFormValid = $derived(email.length > 0 && email.includes("@"));
|
const isFormValid = $derived(email.length > 0 && email.includes("@"));
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isFormValid) return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { setLoginFlowState } from "$lib/stores/auth.svelte";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { setLoginFlowState } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
let email = $state("");
|
let email = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = null;
|
error = null;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.auth.createLoginRequest({ email });
|
const response = await api.auth.createLoginRequest({ email });
|
||||||
setLoginFlowState(response);
|
setLoginFlowState(response);
|
||||||
|
|
||||||
if (response.hasPasskey) {
|
if (response.hasPasskey) {
|
||||||
goto("/auth/login/passkey");
|
goto("/auth/login/passkey");
|
||||||
} else if (response.hasPassword) {
|
} else if (response.hasPassword) {
|
||||||
goto("/auth/login/password");
|
goto("/auth/login/password");
|
||||||
} else {
|
} else {
|
||||||
// Anti-enumeration: always redirect to confirm even if user doesn't exist
|
// Anti-enumeration: always redirect to confirm even if user doesn't exist
|
||||||
goto("/auth/confirm");
|
goto("/auth/confirm");
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : "An unexpected error occurred";
|
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
import { api } from "$lib/api/client";
|
import { getMaskedEmail, loginFlowState } from "$lib/stores/auth.svelte";
|
||||||
import { loginFlowState, getMaskedEmail } from "$lib/stores/auth.svelte";
|
|
||||||
import { Fingerprint, KeyRound, Loader2 } from "@lucide/svelte";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passkey authentication page
|
* Passkey authentication page
|
||||||
@@ -32,7 +32,9 @@ async function authenticate(): Promise<void> {
|
|||||||
const options = await api.auth.webauthn.createAuthenticationOptions();
|
const options = await api.auth.webauthn.createAuthenticationOptions();
|
||||||
|
|
||||||
// Trigger browser WebAuthn prompt
|
// Trigger browser WebAuthn prompt
|
||||||
const credential = await startAuthentication({ optionsJSON: options.options });
|
const credential = await startAuthentication({
|
||||||
|
optionsJSON: options.options,
|
||||||
|
});
|
||||||
|
|
||||||
// Verify with server
|
// Verify with server
|
||||||
await api.auth.webauthn.verifyAuthentication({
|
await api.auth.webauthn.verifyAuthentication({
|
||||||
@@ -60,7 +62,7 @@ $effect(() => {
|
|||||||
// Auto-trigger authentication on mount
|
// Auto-trigger authentication on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (loginFlowState.email && !hasAttempted) {
|
if (loginFlowState.email && !hasAttempted) {
|
||||||
authenticate();
|
void authenticate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,42 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { api } from "$lib/api/client";
|
import { api } from "$lib/api/client";
|
||||||
import { loginFlowState, clearLoginFlowState } from "$lib/stores/auth.svelte";
|
import { ErrorAlert, PasswordInput } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { PasswordInput, ErrorAlert } from "$lib/components/auth";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { clearLoginFlowState, loginFlowState } from "$lib/stores/auth.svelte";
|
||||||
|
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
// Guard: redirect to /auth/login if no active login flow
|
// Guard: redirect to /auth/login if no active login flow
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loginFlowState.email) {
|
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");
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -1,68 +1,78 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { AlertCircle } from "@lucide/svelte";
|
||||||
import { page } from "$app/stores";
|
import { toast } from "svelte-sonner";
|
||||||
import { api } from "$lib/api/client";
|
import zxcvbn from "zxcvbn";
|
||||||
import { PasswordInput, PasswordStrength, ErrorAlert } from "$lib/components/auth";
|
import { goto } from "$app/navigation";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { page } from "$app/stores";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { api } from "$lib/api/client";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import {
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
ErrorAlert,
|
||||||
import { AlertCircle } from "@lucide/svelte";
|
PasswordInput,
|
||||||
import { toast } from "svelte-sonner";
|
PasswordStrength,
|
||||||
import zxcvbn from "zxcvbn";
|
} from "$lib/components/auth";
|
||||||
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
|
|
||||||
// Get token from URL
|
// Get token from URL
|
||||||
const token = $derived($page.url.searchParams.get("token") ?? "");
|
const token = $derived($page.url.searchParams.get("token") ?? "");
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let newPassword = $state("");
|
let newPassword = $state("");
|
||||||
let confirmPassword = $state("");
|
let confirmPassword = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
|
||||||
// Password validation
|
// Password validation
|
||||||
const passwordScore = $derived(newPassword ? zxcvbn(newPassword).score : 0);
|
const passwordScore = $derived(newPassword ? zxcvbn(newPassword).score : 0);
|
||||||
const passwordsMatch = $derived(newPassword === confirmPassword);
|
const passwordsMatch = $derived(newPassword === confirmPassword);
|
||||||
const isPasswordValid = $derived(
|
const isPasswordValid = $derived(
|
||||||
newPassword.length >= 8 && passwordScore >= 3 && passwordsMatch && confirmPassword.length > 0
|
newPassword.length >= 8 &&
|
||||||
);
|
passwordScore >= 3 &&
|
||||||
|
passwordsMatch &&
|
||||||
|
confirmPassword.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
const isFormValid = $derived(token.length > 0 && isPasswordValid);
|
const isFormValid = $derived(token.length > 0 && isPasswordValid);
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isFormValid) return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,78 +1,80 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { AlertCircle, Loader2 } from "@lucide/svelte";
|
||||||
import { api } from "$lib/api/client";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { toast } from "svelte-sonner";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { goto } from "$app/navigation";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { api } from "$lib/api/client";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { AlertCircle, Loader2 } from "@lucide/svelte";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { toast } from "svelte-sonner";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
import { validatePhone } from "$lib/utils/validation";
|
import { validatePhone } from "$lib/utils/validation";
|
||||||
|
|
||||||
// Fetch current user to check if setup is needed
|
// Fetch current user to check if setup is needed
|
||||||
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
|
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
|
||||||
const userQuery = createQuery(() => ({
|
const userQuery = createQuery(() => ({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: () => api.me.get(),
|
queryFn: () => api.me.get(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Redirect if user doesn't need setup
|
// Redirect if user doesn't need setup
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (userQuery.data && !userQuery.data.needsSetup) {
|
if (userQuery.data && !userQuery.data.needsSetup) {
|
||||||
goto("/performance");
|
goto("/performance");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let displayName = $state("");
|
let displayName = $state("");
|
||||||
let fullName = $state("");
|
let fullName = $state("");
|
||||||
let phoneNumber = $state("");
|
let phoneNumber = $state("");
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let phoneError = $state("");
|
let phoneError = $state("");
|
||||||
|
|
||||||
function handlePhoneBlur() {
|
function handlePhoneBlur() {
|
||||||
const result = validatePhone(phoneNumber);
|
const result = validatePhone(phoneNumber);
|
||||||
if (phoneNumber && !result.valid) {
|
if (phoneNumber && !result.valid) {
|
||||||
phoneError = "Please enter a valid phone number (e.g., +1 555 123 4567)";
|
phoneError = "Please enter a valid phone number (e.g., +1 555 123 4567)";
|
||||||
} else {
|
} else {
|
||||||
phoneError = "";
|
phoneError = "";
|
||||||
if (phoneNumber && result.formatted) {
|
if (phoneNumber && result.formatted) {
|
||||||
phoneNumber = result.formatted;
|
phoneNumber = result.formatted;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = $derived(
|
const isValid = $derived(
|
||||||
displayName.trim().length >= 1 &&
|
displayName.trim().length >= 1 &&
|
||||||
displayName.trim().length <= 100 &&
|
displayName.trim().length <= 100 &&
|
||||||
(!phoneNumber || validatePhone(phoneNumber).valid)
|
(!phoneNumber || validatePhone(phoneNumber).valid),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isValid || isSubmitting) return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,127 +1,142 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import {
|
||||||
import { api } from "$lib/api/client";
|
browserSupportsWebAuthn,
|
||||||
import { PasswordInput, PasswordStrength, ErrorAlert } from "$lib/components/auth";
|
startRegistration,
|
||||||
import { Input } from "$lib/components/ui/input";
|
} from "@simplewebauthn/browser";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import zxcvbn from "zxcvbn";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { goto } from "$app/navigation";
|
||||||
import { browserSupportsWebAuthn, startRegistration } from "@simplewebauthn/browser";
|
import { api } from "$lib/api/client";
|
||||||
import zxcvbn from "zxcvbn";
|
import {
|
||||||
|
ErrorAlert,
|
||||||
|
PasswordInput,
|
||||||
|
PasswordStrength,
|
||||||
|
} from "$lib/components/auth";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let email = $state("");
|
let email = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let confirmPassword = $state("");
|
let confirmPassword = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
|
||||||
// Authentication mode: "passkey" or "password"
|
// Authentication mode: "passkey" or "password"
|
||||||
let authMode = $state<"passkey" | "password">("passkey");
|
let authMode = $state<"passkey" | "password">("passkey");
|
||||||
|
|
||||||
// Check passkey support on mount
|
// Check passkey support on mount
|
||||||
let supportsPasskey = $state(false);
|
let supportsPasskey = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
supportsPasskey = browserSupportsWebAuthn();
|
supportsPasskey = browserSupportsWebAuthn();
|
||||||
if (!supportsPasskey) {
|
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";
|
authMode = "password";
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function switchToPasskey() {
|
// Password validation
|
||||||
authMode = "passkey";
|
const passwordScore = $derived(password ? zxcvbn(password, [email]).score : 0);
|
||||||
password = "";
|
const passwordsMatch = $derived(password === confirmPassword);
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,78 +1,82 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { MapPin, Monitor, Shield, Smartphone, Tablet } from "@lucide/svelte";
|
||||||
import { api } from "$lib/api/client";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { toast } from "svelte-sonner";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { goto } from "$app/navigation";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { api } from "$lib/api/client";
|
||||||
import { LoadingButton } from "$lib/components/ui/loading-button";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Monitor, Smartphone, Tablet, Shield, MapPin } from "@lucide/svelte";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { toast } from "svelte-sonner";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { LoadingButton } from "$lib/components/ui/loading-button";
|
||||||
|
|
||||||
// Fetch device info from server
|
// Fetch device info from server
|
||||||
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
|
// TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly
|
||||||
const deviceQuery = createQuery(() => ({
|
const deviceQuery = createQuery(() => ({
|
||||||
queryKey: ["deviceInfo"],
|
queryKey: ["deviceInfo"],
|
||||||
queryFn: () => api.me.getDeviceInfo(),
|
queryFn: () => api.me.getDeviceInfo(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Parse user agent for suggested device name
|
// Parse user agent for suggested device name
|
||||||
const parser = new UAParser(navigator.userAgent);
|
const parser = new UAParser(navigator.userAgent);
|
||||||
const browserName = parser.getBrowser().name || "Browser";
|
const browserName = parser.getBrowser().name || "Browser";
|
||||||
const osName = parser.getOS().name || "Device";
|
const osName = parser.getOS().name || "Device";
|
||||||
const deviceType = parser.getDevice().type;
|
const deviceType = parser.getDevice().type;
|
||||||
|
|
||||||
const suggestedName = $derived(`${browserName} on ${osName}`);
|
const suggestedName = $derived(`${browserName} on ${osName}`);
|
||||||
|
|
||||||
let deviceName = $state("");
|
let deviceName = $state("");
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
|
||||||
// Initialize device name with suggestion
|
// Initialize device name with suggestion
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!deviceName && suggestedName) {
|
if (!deviceName && suggestedName) {
|
||||||
deviceName = suggestedName;
|
deviceName = suggestedName;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isValid = $derived(deviceName.trim().length >= 1 && deviceName.trim().length <= 100);
|
const isValid = $derived(
|
||||||
|
deviceName.trim().length >= 1 && deviceName.trim().length <= 100,
|
||||||
|
);
|
||||||
|
|
||||||
async function handleTrust() {
|
async function handleTrust() {
|
||||||
if (!isValid || isSubmitting) return;
|
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() {
|
isSubmitting = true;
|
||||||
|
error = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.me.trustDevice({ name: deviceName.trim() });
|
||||||
|
toast.success("Device trusted successfully!");
|
||||||
goto("/performance");
|
goto("/performance");
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Failed to trust device";
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get device icon based on type
|
async function handleSkip() {
|
||||||
function getDeviceIcon() {
|
goto("/performance");
|
||||||
switch (deviceType) {
|
}
|
||||||
case "mobile":
|
|
||||||
return Smartphone;
|
// Get device icon based on type
|
||||||
case "tablet":
|
function getDeviceIcon() {
|
||||||
return Tablet;
|
switch (deviceType) {
|
||||||
default:
|
case "mobile":
|
||||||
return Monitor;
|
return Smartphone;
|
||||||
}
|
case "tablet":
|
||||||
|
return Tablet;
|
||||||
|
default:
|
||||||
|
return Monitor;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DeviceIcon = getDeviceIcon();
|
const DeviceIcon = getDeviceIcon();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { CheckCircle2, Loader2, Mail, XCircle } from "@lucide/svelte";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { api } from "$lib/api/client";
|
||||||
import { ErrorAlert } from "$lib/components/auth";
|
import { ErrorAlert } from "$lib/components/auth";
|
||||||
import { Button } from "$lib/components/ui/button";
|
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
|
* Email verification callback page
|
||||||
@@ -42,7 +42,7 @@ async function verifyEmail(): Promise<void> {
|
|||||||
// Auto-verify on mount
|
// Auto-verify on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
verifyEmail();
|
void verifyEmail();
|
||||||
} else {
|
} else {
|
||||||
error = "No verification token provided";
|
error = "No verification token provided";
|
||||||
isVerifying = false;
|
isVerifying = false;
|
||||||
@@ -58,7 +58,8 @@ async function resendVerification(): Promise<void> {
|
|||||||
toast.success("Verification email sent! Check your inbox.");
|
toast.success("Verification email sent! Check your inbox.");
|
||||||
error = "";
|
error = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : "Failed to send verification email";
|
const message =
|
||||||
|
e instanceof Error ? e.message : "Failed to send verification email";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
// Redirect old /login route to new /auth/login
|
// Redirect old /login route to new /auth/login
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
goto("/auth/login", { replaceState: true });
|
goto("/auth/login", { replaceState: true });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center">
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user