login now username+password based

This commit is contained in:
user
2026-02-28 19:39:21 +02:00
parent 0265d6e774
commit cf8d72cac3
14 changed files with 245 additions and 1128 deletions

View File

@@ -2,7 +2,6 @@ import {
adminClient,
customSessionClient,
inferAdditionalFields,
magicLinkClient,
multiSessionClient,
usernameClient,
} from "better-auth/client/plugins";
@@ -13,7 +12,6 @@ export const authClient = createAuthClient({
plugins: [
usernameClient(),
adminClient(),
magicLinkClient(),
multiSessionClient(),
customSessionClient<typeof auth>(),
inferAdditionalFields<typeof auth>(),

View File

@@ -1,17 +0,0 @@
<script lang="ts">
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import GoogleIcon from "~icons/devicon/google";
import Icon from "../atoms/icon.svelte";
import Button from "../ui/button/button.svelte";
</script>
<Button
type="button"
class="w-full"
variant="outline"
onclick={() => authVM.handleGoogleOAuthLogin()}
disabled={authVM.loggingIn}
>
<Icon icon={GoogleIcon} cls="h-6 w-6" />
<p class="">Sign in with Google</p>
</Button>

View File

@@ -1,12 +1,14 @@
import type { User } from "@pkg/logic/domains/user/data";
import { apiClient, user as userStore } from "$lib/global.stores";
import { authClient } from "$lib/auth.client";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class AccountViewModel {
loading = $state(false);
emailLoading = $state(false);
passwordLoading = $state(false);
errorMessage = $state<string | null>(null);
async updateProfilePicture(imagePath: string): Promise<boolean> {
@@ -104,44 +106,71 @@ class AccountViewModel {
return user;
}
async changeEmail(email: string): Promise<boolean> {
this.emailLoading = true;
async changePassword(password: string): Promise<boolean> {
this.passwordLoading = true;
this.errorMessage = null;
const currentUser = get(userStore);
if (!currentUser?.id) {
this.passwordLoading = false;
this.errorMessage = "User not found";
toast.error(this.errorMessage);
return false;
}
const client = get(apiClient);
if (!client) {
this.passwordLoading = false;
this.errorMessage = "API client not initialized";
toast.error(this.errorMessage);
return false;
}
const result = await ResultAsync.fromPromise(
authClient.changeEmail({ newEmail: email }),
client.users["rotate-password"].$put({
json: { userId: currentUser.id, password },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to change email",
message: "Failed to change password",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message:
response.error.message ?? "Failed to change email",
description:
response.error.statusText ??
"Please try again later",
detail: response.error.statusText ?? "Unknown error",
message: "Failed to change password",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return okAsync(response.data);
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail:
error instanceof Error
? error.message
: String(error),
}),
).andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
});
const success = result.match(
() => {
toast.success("Verification email sent!", {
description:
"Please check your inbox and verify your new email address.",
});
toast.success("Password updated successfully");
return true;
},
(error) => {
this.errorMessage = error.message ?? "Failed to change email";
this.errorMessage = error.message ?? "Failed to change password";
toast.error(this.errorMessage, {
description: error.description,
});
@@ -149,7 +178,7 @@ class AccountViewModel {
},
);
this.emailLoading = false;
this.passwordLoading = false;
return success;
}
}

View File

@@ -1,54 +1,32 @@
import { apiClient } from "$lib/global.stores";
import { authClient } from "$lib/auth.client";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { page } from "$app/state";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result";
class AuthViewModel {
loggingIn = $state(false);
loginError = $state<string | null>(null);
async handleGoogleOAuthLogin() {
const result = await ResultAsync.fromPromise(
authClient.signIn.social({ provider: "google" }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to initiate Google OAuth login",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
);
async loginWithCredentials(data: FormData): Promise<boolean> {
const username = data.get("username")?.toString().trim();
const password = data.get("password")?.toString();
result.match(
() => {
// OAuth flow will redirect, no action needed
},
(error) => {
toast.error("Failed to initiate Google login", {
description: error.description,
});
},
);
}
if (!username || username.length < 3) {
toast.error("Please enter a valid username");
return false;
}
async loginWithEmail(data: FormData): Promise<boolean> {
const email = data.get("email")?.toString();
if (!email || email.length < 5) {
toast.error("Please enter a valid email");
if (!password || password.length < 6) {
toast.error("Please enter a valid password");
return false;
}
this.loggingIn = true;
this.loginError = null;
const result = await ResultAsync.fromPromise(
authClient.signIn.magicLink({ email }),
authClient.signIn.username({ username, password }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to send magic link",
message: "Failed to login",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
@@ -57,10 +35,10 @@ class AuthViewModel {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: response.error.message ?? "Something went wrong",
message: response.error.message ?? "Invalid credentials",
description:
response.error.statusText ??
"Please try another login method or try again later",
"Please check your username and password",
detail: response.error.statusText ?? "Unknown error",
});
}
@@ -69,26 +47,18 @@ class AuthViewModel {
const success = result.match(
() => {
this.loginError = null;
toast.success("Login request sent", {
description: "Check your email for a magic link to log in",
toast.success("Login successful", {
description: "Redirecting...",
});
setTimeout(() => {
window.location.href = "/";
}, 500);
return true;
},
(error) => {
// Set error message for UI display
const errorMessage = error.message ?? "Something went wrong";
this.loginError = errorMessage;
// Don't show toast for known error cases that we handle in the UI
if (
!errorMessage.includes("not valid to login") &&
!errorMessage.includes("not allowed")
) {
toast.error(errorMessage, {
description: error.description,
});
}
toast.error(error.message ?? "Invalid credentials", {
description: error.description,
});
return false;
},
);
@@ -96,138 +66,6 @@ class AuthViewModel {
this.loggingIn = false;
return success;
}
clearLoginError() {
this.loginError = null;
}
async verifyMagicLink() {
const token = page.url.searchParams.get("token");
if (!token) {
throw new Error("No token provided");
}
const result = await ResultAsync.fromPromise(
authClient.magicLink.verify({ query: { token } }),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to verify magic link",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: "Could not verify magic link",
description: response.error.message ?? "Unknown error",
detail: response.error.statusText ?? "Unknown error",
});
}
if (!response.data?.user?.id) {
return errAsync({
code: "API_ERROR",
message: "Invalid verification result",
description: "User ID not found in verification response",
detail: "Missing user.id in response",
});
}
return okAsync(response.data);
})
.andThen((verificationResult) => {
return ResultAsync.fromPromise(
get(apiClient).users["ensure-account-exists"].$put({
json: { userId: verificationResult.user.id },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to ensure account exists",
description: "Network request failed",
detail:
error instanceof Error
? error.message
: String(error),
}),
)
.andThen((response) => {
if (!response.ok) {
return errAsync({
code: "API_ERROR",
message: "Failed to ensure account exists",
description: `Response failed with status ${response.status}`,
detail: `HTTP ${response.status}`,
});
}
return ResultAsync.fromPromise(
response.json(),
(error): Err => ({
code: "PARSING_ERROR",
message: "Failed to parse response",
description: "Invalid response format",
detail:
error instanceof Error
? error.message
: String(error),
}),
);
})
.andThen((apiResult: any) => {
if (apiResult.error) {
return errAsync(apiResult.error);
}
return okAsync(apiResult.data);
});
});
result.match(
() => {
// Success - account ensured
},
(error: Err) => {
throw new Error(error.message ?? "Failed to verify magic link");
},
);
}
async verifyEmailChange() {
const token = page.url.searchParams.get("token");
if (!token) {
throw new Error("No verification token provided");
}
const result = await ResultAsync.fromPromise(
authClient.verifyEmail({
query: { token },
}),
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to verify email change",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
)
.andThen((response) => {
if (response.error) {
return errAsync({
code: "API_ERROR",
message: "Could not verify email change",
description: response.error.message ?? "Unknown error",
detail: response.error.statusText ?? "Unknown error",
});
}
return okAsync(response.data);
});
result.match(
() => {
// Success - email verified
},
(error: Err) => {
throw new Error(error.message ?? "Could not verify email change");
},
);
}
}
export const authVM = new AuthViewModel();

View File

@@ -3,152 +3,76 @@
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import { AlertCircle, AtSign, CheckCircle, Loader2 } from "@lucide/svelte";
import { AtSign, KeyRound, Loader2 } from "@lucide/svelte";
let status = $state<"idle" | "sending" | "sent" | "failed">("idle");
let submittedEmail = $state<string>("");
let email = $state<string>("");
let emailFocused = $state<boolean>(false);
let isEmailValid = $state<boolean>(false);
let username = $state<string>("");
let password = $state<string>("");
// Validate email as user types
$effect(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isEmailValid = emailRegex.test(email) && email.length >= 5;
});
// Clear API error when user starts typing
$effect(() => {
if (email && authVM.loginError) {
authVM.clearLoginError();
}
});
function onInput(e: Event & { currentTarget: HTMLInputElement }) {
email = e.currentTarget.value;
}
const canSubmit = $derived(
username.trim().length >= 3 && password.length >= 6 && !authVM.loggingIn,
);
async function onSubmit(
e: SubmitEvent & { currentTarget: HTMLFormElement },
) {
e.preventDefault();
if (!isEmailValid || authVM.loggingIn) return;
const formData = new FormData(e.currentTarget);
submittedEmail = (formData.get("email")?.toString() || "").trim();
status = "sending";
try {
const ok = await authVM.loginWithEmail(formData);
status = ok ? "sent" : "failed";
} catch {
status = "failed";
}
}
function resetFormState() {
status = "idle";
submittedEmail = "";
authVM.clearLoginError();
if (!canSubmit) return;
await authVM.loginWithCredentials(new FormData(e.currentTarget));
}
</script>
<form class="space-y-4" onsubmit={onSubmit} aria-describedby="login-status">
<!-- Email -->
<form class="space-y-4" onsubmit={onSubmit}>
<div class="space-y-2">
<label
for="email"
for="username"
class="text-foreground flex items-center gap-2 text-sm font-medium"
>
<Icon icon={AtSign} cls="h-4 w-4 text-muted-foreground" />
<span>Email address</span>
<span>Username</span>
</label>
<div class="relative">
<Input
id="email"
name="email"
type="email"
required
minlength={5}
placeholder="you@example.com"
bind:value={email}
oninput={onInput}
onfocus={() => (emailFocused = true)}
onblur={() => (emailFocused = false)}
class="h-12 pr-12"
aria-invalid={!isEmailValid && email.length > 0}
/>
<!-- Right adornment: validation / loader -->
<div class="absolute top-1/2 right-3 -translate-y-1/2">
{#if authVM.loggingIn}
<Icon
icon={Loader2}
cls="h-5 w-5 animate-spin text-muted-foreground"
/>
{:else if isEmailValid && email.length > 0}
<Icon icon={CheckCircle} cls="h-5 w-5 text-emerald-500" />
{:else if email.length > 0}
<!-- keep space balanced with a subtle icon state -->
<Icon
icon={AtSign}
cls="h-5 w-5 text-muted-foreground/50"
/>
{/if}
</div>
</div>
{#if emailFocused && email.length > 0 && !isEmailValid}
<p class="text-xs text-muted-foreground">
Please enter a valid email address.
</p>
{/if}
<Input
id="username"
name="username"
type="text"
required
minlength={3}
maxlength={64}
placeholder="your-username"
bind:value={username}
class="h-12"
/>
</div>
<div class="space-y-2">
<label
for="password"
class="text-foreground flex items-center gap-2 text-sm font-medium"
>
<Icon icon={KeyRound} cls="h-4 w-4 text-muted-foreground" />
<span>Password</span>
</label>
<Input
id="password"
name="password"
type="password"
required
minlength={6}
placeholder="Your password"
bind:value={password}
class="h-12"
/>
</div>
<!-- Submit -->
<Button
type="submit"
class="from-primary to-primary/90 hover:from-primary/90 hover:to-primary h-12 w-full bg-gradient-to-r text-base font-medium shadow-lg transition-all duration-200 hover:shadow-xl"
disabled={authVM.loggingIn || !isEmailValid}
disabled={!canSubmit}
>
{#if authVM.loggingIn}
<Icon icon={Loader2} cls="mr-2 h-5 w-5 animate-spin" />
Sending Magic Link...
Signing In...
{:else}
Send Magic Link
Sign In
{/if}
</Button>
<!-- Inline status messages (minimal) -->
<div id="login-status" class="space-y-2" aria-live="polite" role="status">
{#if status === "sent"}
<div
class="bg-emerald-50 dark:bg-emerald-950/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800 rounded-md px-3 py-2 text-sm"
>
<div class="flex items-start gap-2">
<Icon icon={CheckCircle} cls="h-4 w-4 mt-0.5" />
<p>
Weve sent a magic link to <span class="font-medium"
>{submittedEmail}</span
>. Check your inbox.
</p>
</div>
</div>
{:else if status === "failed" && authVM.loginError}
<div
class="bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800 rounded-md px-3 py-2 text-sm"
>
<div class="flex items-start gap-2">
<Icon icon={AlertCircle} cls="h-4 w-4 mt-0.5" />
<p>{authVM.loginError}</p>
</div>
<button
type="button"
class="mt-2 text-xs underline underline-offset-2 hover:opacity-80"
onclick={resetFormState}
>
Try again
</button>
</div>
{/if}
</div>
</form>

View File

@@ -1,228 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { sessionsVM } from "$lib/domains/account/sessions/sessions.vm.svelte";
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import {
ArrowRight,
CheckCircle,
Loader2,
Mail,
Shield,
XCircle,
} from "@lucide/svelte";
import { onMount } from "svelte";
let verificationState = $state<"loading" | "success" | "error">("loading");
let errorMessage = $state<string>("");
onMount(async () => {
try {
verificationState = "loading";
await authVM.verifyMagicLink();
verificationState = "success";
setTimeout(() => {
window.location.href = "/";
}, 500);
} catch (error) {
console.error(error);
verificationState = "error";
errorMessage =
error instanceof Error ? error.message : "Verification failed";
setTimeout(() => {
sessionsVM.logout().then();
}, 500);
}
});
function handleRetry() {
window.location.href = "/auth/login";
}
</script>
<Card.Root class="bg-card/95 border shadow-2xl backdrop-blur-sm">
<Card.Content class="p-8">
<div class="flex flex-col items-center space-y-6 text-center">
{#if verificationState === "loading"}
<!-- Header Icon -->
<div class="relative">
<div
class="bg-primary/20 absolute inset-0 animate-pulse rounded-full blur-xl"
></div>
<div
class="bg-primary/10 border-primary/20 relative rounded-full border p-4"
>
<Icon icon={Shield} cls="h-8 w-8 text-primary" />
</div>
</div>
{/if}
<!-- Dynamic Content Based on State -->
{#if verificationState === "loading"}
<div class="space-y-4">
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Verifying Your Login
</Title>
<div
class="text-muted-foreground flex items-center justify-center space-x-2"
>
<Icon icon={Loader2} cls="h-5 w-5 animate-spin" />
<span class="text-sm"
>Please wait while we verify your magic
link...</span
>
</div>
<!-- Progress Animation -->
<div
class="bg-muted/30 h-2 w-full overflow-hidden rounded-full"
>
<div
class="bg-primary from-primary/50 to-primary h-full animate-pulse rounded-full bg-gradient-to-r transition-all duration-1000 ease-in-out"
></div>
</div>
<!-- Security Features -->
<div class="space-y-3 pt-4">
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Secure encrypted connection</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Verifying your identity</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="bg-primary/10 border-primary/20 rounded-full border p-1"
>
<Loader2
class="text-primary h-4 w-4 animate-spin"
/>
</div>
<span>Processing authentication</span>
</div>
</div>
</div>
{:else if verificationState === "success"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="absolute inset-0 animate-pulse rounded-full bg-emerald-500/20 blur-lg"
></div>
<div
class="relative rounded-full border border-emerald-500/20 bg-emerald-500/10 p-4"
>
<Icon
icon={CheckCircle}
cls="h-8 w-8 text-emerald-500"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Login Successful!
</Title>
<p class="text-muted-foreground text-sm">
You've been successfully authenticated.
Redirecting you now...
</p>
<div
class="flex items-center justify-center space-x-2 text-sm text-emerald-500"
>
<span>Redirecting</span>
<Icon
icon={ArrowRight}
cls="h-4 w-4 animate-bounce"
/>
</div>
</div>
{:else if verificationState === "error"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="bg-destructive/20 absolute inset-0 rounded-full blur-lg"
></div>
<div
class="bg-destructive/10 border-destructive/20 relative rounded-full border p-4"
>
<Icon
icon={XCircle}
cls="h-8 w-8 text-destructive"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Verification Failed
</Title>
<p class="text-muted-foreground text-center text-sm">
{errorMessage ||
"We couldn't verify your magic link. It may have expired or been used already."}
</p>
<div class="space-y-3 pt-2">
<Button onclick={handleRetry} class="w-full">
<Icon icon={Mail} cls="h-4 w-4 mr-2" />
Try Again
</Button>
<p
class="text-muted-foreground text-center text-xs"
>
Need help? Contact our support team if this
problem persists.
</p>
</div>
</div>
{/if}
<!-- Footer Note -->
<div class="border-border w-full border-t pt-4">
<p class="text-muted-foreground text-center text-xs">
Magic links are secure, one-time use authentication
tokens that expire after 10 minutes.
</p>
</div>
</div>
</Card.Content>
</Card.Root>

View File

@@ -10,7 +10,7 @@
import { accountVM } from "$lib/domains/account/account.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import AtSign from "@lucide/svelte/icons/at-sign";
import Mail from "@lucide/svelte/icons/mail";
import KeyRound from "@lucide/svelte/icons/key-round";
import Save from "@lucide/svelte/icons/save";
import Upload from "@lucide/svelte/icons/upload";
import User from "@lucide/svelte/icons/user";
@@ -22,14 +22,15 @@
const user = $state(data.user!);
// Separate form state for profile and email
// Separate form state for profile and password
let profileData = $state({
name: user.name ?? "",
username: user.username ?? "",
});
let emailData = $state({
email: user.email,
let passwordData = $state({
password: "",
confirmPassword: "",
});
// Handle profile form submission (name, username)
@@ -38,11 +39,20 @@
await accountVM.updateProfile(profileData);
}
// Handle email form submission
async function handleEmailSubmit(e: SubmitEvent) {
// Handle password form submission
async function handlePasswordSubmit(e: SubmitEvent) {
e.preventDefault();
if (user.email !== emailData.email) {
await accountVM.changeEmail(emailData.email);
if (
passwordData.password.length >= 6 &&
passwordData.password === passwordData.confirmPassword
) {
const didChange = await accountVM.changePassword(
passwordData.password,
);
if (didChange) {
passwordData.password = "";
passwordData.confirmPassword = "";
}
}
}
@@ -171,67 +181,72 @@
</Card.Footer>
</Card.Root>
<!-- Email Settings -->
<!-- Password Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Email Settings</Card.Title>
<Card.Title>Password Settings</Card.Title>
<Card.Description>
Manage your email address and verification status.
Update your account password.
</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={handleEmailSubmit} class="space-y-4">
<!-- Email -->
<form onsubmit={handlePasswordSubmit} class="space-y-4">
<div class="grid grid-cols-1 gap-1.5">
<Label for="email" class="flex items-center gap-1.5">
<Label for="password" class="flex items-center gap-1.5">
<Icon
icon={Mail}
icon={KeyRound}
cls="h-3.5 w-3.5 text-muted-foreground"
/>
Email Address
New Password
</Label>
<Input
id="email"
type="email"
bind:value={emailData.email}
placeholder="your.email@example.com"
id="password"
type="password"
bind:value={passwordData.password}
placeholder="Enter new password"
minlength={6}
/>
</div>
<div class="grid grid-cols-1 gap-1.5">
<Label
for="confirm-password"
class="flex items-center gap-1.5"
>
<Icon
icon={KeyRound}
cls="h-3.5 w-3.5 text-muted-foreground"
/>
Confirm Password
</Label>
<Input
id="confirm-password"
type="password"
bind:value={passwordData.confirmPassword}
placeholder="Re-enter new password"
minlength={6}
maxlength={128}
/>
<div class="flex items-center gap-1.5">
<span
class={user.emailVerified
? "text-xs text-emerald-600 dark:text-emerald-400"
: "text-xs text-amber-600"}
>
{user.emailVerified
? "Email verified"
: "Email not verified"}
</span>
{#if !user.emailVerified}
<Button
variant="link"
size="sm"
class="h-auto p-0 text-xs">Verify now</Button
>
{/if}
</div>
</div>
<div class="flex justify-end">
<Button
type="submit"
disabled={accountVM.emailLoading ||
user.email === emailData.email}
disabled={accountVM.passwordLoading ||
passwordData.password.length < 6 ||
passwordData.password !==
passwordData.confirmPassword}
variant="outline"
class="w-full sm:w-auto"
>
{#if accountVM.emailLoading}
<Icon icon={Mail} cls="h-4 w-4 mr-2 animate-spin" />
Sending...
{#if accountVM.passwordLoading}
<Icon
icon={KeyRound}
cls="h-4 w-4 mr-2 animate-spin"
/>
Updating...
{:else}
<Icon icon={Mail} cls="h-4 w-4 mr-2" />
Update Email
<Icon icon={KeyRound} cls="h-4 w-4 mr-2" />
Update Password
{/if}
</Button>
</div>
@@ -239,8 +254,7 @@
</Card.Content>
<Card.Footer>
<p class="text-muted-foreground text-xs">
Changing your email will require verification of the new
address.
Choose a strong password with at least 6 characters.
</p>
</Card.Footer>
</Card.Root>

View File

@@ -1,240 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import {
ArrowRight,
CheckCircle,
Loader2,
Mail,
User,
XCircle,
} from "@lucide/svelte";
import { onMount } from "svelte";
let verificationState = $state<"loading" | "success" | "error">("loading");
let errorMessage = $state<string>("");
onMount(async () => {
try {
verificationState = "loading";
await authVM.verifyEmailChange();
verificationState = "success";
setTimeout(() => {
window.location.href = "/account";
}, 2000);
} catch (error) {
console.error(error);
verificationState = "error";
errorMessage =
error instanceof Error
? error.message
: "Email verification failed";
}
});
function handleReturnToAccount() {
window.location.href = "/account";
}
</script>
<div
class="from-muted/20 via-background to-accent/10 flex min-h-screen items-center justify-center bg-linear-to-br p-4"
>
<div class="w-full max-w-md">
<Card.Root class="bg-card/95 border shadow-2xl backdrop-blur-sm">
<Card.Content class="p-8">
<div class="flex flex-col items-center space-y-6 text-center">
{#if verificationState === "loading"}
<!-- Header Icon -->
<div class="relative">
<div
class="bg-primary/20 absolute inset-0 animate-pulse rounded-full blur-xl"
></div>
<div
class="bg-primary/10 border-primary/20 relative rounded-full border p-4"
>
<Icon icon={Mail} cls="h-8 w-8 text-primary" />
</div>
</div>
{/if}
<!-- Dynamic Content Based on State -->
{#if verificationState === "loading"}
<div class="space-y-4">
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Verifying Your Email
</Title>
<div
class="text-muted-foreground flex items-center justify-center space-x-2"
>
<Icon
icon={Loader2}
cls="h-5 w-5 animate-spin"
/>
<span class="text-sm"
>Please wait while we verify your email
change...</span
>
</div>
<!-- Progress Animation -->
<div
class="bg-muted/30 h-2 w-full overflow-hidden rounded-full"
>
<div
class="bg-primary from-primary/50 to-primary h-full animate-pulse rounded-full bg-linear-to-r transition-all duration-1000 ease-in-out"
></div>
</div>
<!-- Security Features -->
<div class="space-y-3 pt-4">
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Secure encrypted connection</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Validating email ownership</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="bg-primary/10 border-primary/20 rounded-full border p-1"
>
<Loader2
class="text-primary h-4 w-4 animate-spin"
/>
</div>
<span>Updating account settings</span>
</div>
</div>
</div>
{:else if verificationState === "success"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="absolute inset-0 animate-pulse rounded-full bg-emerald-500/20 blur-lg"
></div>
<div
class="relative rounded-full border border-emerald-500/20 bg-emerald-500/10 p-4"
>
<Icon
icon={CheckCircle}
cls="h-8 w-8 text-emerald-500"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Email Verified Successfully!
</Title>
<p class="text-muted-foreground text-sm">
Your email address has been updated and
verified. Redirecting you to your account...
</p>
<div
class="flex items-center justify-center space-x-2 text-sm text-emerald-500"
>
<span>Redirecting</span>
<Icon
icon={ArrowRight}
cls="h-4 w-4 animate-bounce"
/>
</div>
</div>
{:else if verificationState === "error"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="bg-destructive/20 absolute inset-0 rounded-full blur-lg"
></div>
<div
class="bg-destructive/10 border-destructive/20 relative rounded-full border p-4"
>
<Icon
icon={XCircle}
cls="h-8 w-8 text-destructive"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Email Verification Failed
</Title>
<p
class="text-muted-foreground text-center text-sm"
>
{errorMessage ||
"We couldn't verify your email change. The verification link may have expired or been used already."}
</p>
<div class="space-y-3 pt-2">
<Button
onclick={handleReturnToAccount}
class="w-full"
>
<Icon icon={User} cls="h-4 w-4 mr-2" />
Return to Account
</Button>
<p
class="text-muted-foreground text-center text-xs"
>
You can try changing your email again from
your account settings.
</p>
</div>
</div>
{/if}
<!-- Footer Note -->
<div class="border-border w-full border-t pt-4">
<p class="text-muted-foreground text-center text-xs">
Email verification links are secure, one-time use
tokens that expire after 10 minutes.
</p>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import { UserRoleMap } from "@pkg/logic/domains/user/data";
import { user } from "@pkg/db/schema/better.auth.schema";
import { auth } from "@pkg/logic/domains/auth/config.base";
import { account, user } from "@pkg/db/schema/better.auth.schema";
import type { RequestHandler } from "./$types";
import { settings } from "@pkg/settings";
import { logger } from "@pkg/logger";
import { db, eq } from "@pkg/db";
import { db, eq, or } from "@pkg/db";
import { nanoid } from "nanoid";
import * as v from "valibot";
@@ -48,45 +49,81 @@ export const POST: RequestHandler = async ({ request }) => {
const data = await request.json();
const _schema = v.object({
username: v.string(),
email: v.string(),
usertype: v.enum(UserRoleMap),
password: v.pipe(v.string(), v.minLength(6)),
email: v.optional(v.string()),
usertype: v.optional(v.enum(UserRoleMap)),
});
const res = v.safeParse(_schema, data);
if (!res.success) {
return new Response("Invalid data", { status: 400 });
}
const resData = res.output;
const email = resData.email ?? `${resData.username}@debug.local`;
const usertype = resData.usertype ?? UserRoleMap.admin;
if (
!!(
await db.query.user
.findFirst({
where: eq(user.role, UserRoleMap.user),
columns: { id: true },
})
.execute()
)?.id
) {
return new Response("Admin already exists", { status: 400 });
const existingUser = await db.query.user
.findFirst({
where: or(eq(user.username, resData.username), eq(user.email, email)),
columns: { id: true },
})
.execute();
if (existingUser?.id) {
return new Response("User already exists", { status: 409 });
}
const resData = res.output;
logger.info(
`Creating ${resData.username} | ${resData.email} (${resData.usertype})`,
`Creating debug user ${resData.username} | ${email} (${usertype})`,
);
const out = await db.insert(user).values({
id: nanoid(),
username: resData.username,
email: resData.email,
emailVerified: false,
name: resData.username,
role: resData.usertype,
createdAt: new Date(),
updatedAt: new Date(),
const userId = nanoid();
const accountId = nanoid();
const hashedPassword = await auth.$context.then((ctx) =>
ctx.password.hash(resData.password),
);
await db.transaction(async (tx) => {
await tx
.insert(user)
.values({
id: userId,
username: resData.username,
email,
emailVerified: true,
name: resData.username,
role: usertype,
createdAt: new Date(),
updatedAt: new Date(),
})
.execute();
await tx
.insert(account)
.values({
id: accountId,
accountId: userId,
providerId: "credential",
userId,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
})
.execute();
});
logger.debug(out);
logger.info("Debug user created with credential account", {
userId,
username: resData.username,
email,
});
return new Response(JSON.stringify({ ...out }), { status: 200 });
return new Response(
JSON.stringify({
id: userId,
username: resData.username,
email,
role: usertype,
}),
{ status: 200 },
);
};

View File

@@ -46,7 +46,7 @@
</Title>
</Card.Title>
<Card.Description class="text-muted-foreground">
Sign in to your account to continue
Sign in with your username and password
</Card.Description>
</div>
</div>
@@ -54,12 +54,6 @@
<Card.Content class="px-8 pb-8">
<EmailLoginForm />
<div class="w-full flex gap-4 items-center justify-center py-4">
<div class="h-0.5 w-full bg-muted-foreground"></div>
<p class="text-lg">OR</p>
<div class="h-0.5 w-full bg-muted-foreground"></div>
</div>
</Card.Content>
<Card.Footer class="flex items-center justify-between">

View File

@@ -1,11 +0,0 @@
<script lang="ts">
import MagicLinkVerification from "$lib/domains/security/magic-link-verification.svelte";
</script>
<div
class="from-muted/20 via-background to-accent/10 flex min-h-screen items-center justify-center bg-gradient-to-br p-4"
>
<div class="w-full max-w-md">
<MagicLinkVerification />
</div>
</div>

View File

@@ -1,52 +1,19 @@
import {
admin,
customSession,
magicLink,
multiSession,
username,
} from "better-auth/plugins";
import { getUserController, UserController } from "../user/controller";
import { AuthController, getAuthController } from "./controller";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { FlowExecCtx } from "@/core/flow.execution.context";
import { UserRoleMap } from "@domains/user/data";
import { getRedisInstance } from "@pkg/keystore";
import { APIError } from "better-auth/api";
import { settings } from "@core/settings";
import { betterAuth } from "better-auth";
import { logger } from "@pkg/logger";
import { db, schema } from "@pkg/db";
import { nanoid } from "nanoid";
// Constants
const EMAIL_EXPIRES_IN_MINS = 10;
const EMAIL_EXPIRES_IN_SECONDS = 60 * EMAIL_EXPIRES_IN_MINS;
const COOKIE_CACHE_MAX_AGE = 60 * 5;
// Helper to create flow context for better-auth callbacks
function createAuthFlowContext(contextLabel: string): FlowExecCtx {
return {
flowId: `auth:${contextLabel}:${nanoid(10)}`,
};
}
// Singleton controller instances
let authControllerInstance: AuthController | null = null;
let userControllerInstance: UserController | null = null;
function getAuthControllerInstance(): AuthController {
if (!authControllerInstance) {
authControllerInstance = getAuthController();
}
return authControllerInstance;
}
function getUserControllerInstance(): UserController {
if (!userControllerInstance) {
userControllerInstance = getUserController();
}
return userControllerInstance;
}
const USERNAME_REGEX = /^[a-zA-Z0-9_]+$/;
export const auth = betterAuth({
trustedOrigins: ["http://localhost:5173", settings.betterAuthUrl],
@@ -66,48 +33,7 @@ export const auth = betterAuth({
minUsernameLength: 5,
maxUsernameLength: 20,
usernameValidator: async (username) => {
const fctx = createAuthFlowContext("username-check");
const uc = getUserControllerInstance();
const result = await uc
.isUsernameAvailable(fctx, username)
.match(
(isAvailable) => ({ success: true, isAvailable }),
(error) => {
logger.error(
`[${fctx.flowId}] Failed to check username availability`,
error,
);
return { success: false, isAvailable: false };
},
);
return result.isAvailable;
},
}),
magicLink({
expiresIn: EMAIL_EXPIRES_IN_SECONDS,
rateLimit: { window: 60, max: 4 },
sendMagicLink: async ({ email, token, url }, request) => {
const fctx = createAuthFlowContext("magic-link");
const ac = getAuthControllerInstance();
const result = await ac
.sendMagicLink(fctx, email, token, url)
.match(
() => ({ success: true, error: undefined }),
(error) => ({ success: false, error }),
);
if (!result.success || result?.error) {
logger.error(
`[${fctx.flowId}] Failed to send magic link`,
result.error,
);
throw new APIError("INTERNAL_SERVER_ERROR", {
message: result.error?.message,
});
}
return USERNAME_REGEX.test(username);
},
}),
admin({
@@ -157,38 +83,6 @@ export const auth = betterAuth({
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailVerification: async (
{ user, newEmail, url, token },
request,
) => {
const fctx = createAuthFlowContext("email-change");
const ac = getAuthControllerInstance();
const result = await ac
.sendEmailChangeVerificationEmail(
fctx,
newEmail,
token,
url,
)
.match(
() => ({ success: true, error: undefined }),
(error) => ({ success: false, error }),
);
if (!result.success || result?.error) {
logger.error(
`[${fctx.flowId}] Failed to send email change verification`,
result.error,
);
throw new APIError("INTERNAL_SERVER_ERROR", {
message: result.error?.message,
});
}
},
},
modelName: "user",
additionalFields: {
onboardingDone: {

View File

@@ -2,61 +2,14 @@ import { AuthContext, MiddlewareContext, MiddlewareOptions } from "better-auth";
import { AccountRepository } from "../user/account.repository";
import { FlowExecCtx } from "@/core/flow.execution.context";
import { ResultAsync } from "neverthrow";
import type { Err } from "@pkg/result";
import { authErrors } from "./errors";
import { logger } from "@pkg/logger";
import { nanoid } from "nanoid";
import { db } from "@pkg/db";
export class AuthController {
private readonly mins = 10;
constructor(private accountRepo: AccountRepository) {}
sendEmailChangeVerificationEmail(
fctx: FlowExecCtx,
newEmail: string,
token: string,
url: string,
): ResultAsync<void, Err> {
logger.info("Sending email change verification link", {
...fctx,
newEmail,
});
logger.debug("Original URL", { ...fctx, url });
const transformedUrl = url
.replace("/api/auth/verify-email", "/account/verify-email")
.replace("/api/", "/");
logger.debug("Transformed URL", { ...fctx, transformedUrl });
// Simulate email sending with 90/10 success/failure
const success = Math.random() > 0.1;
if (!success) {
logger.error("Failed to send email change verification link", {
...fctx,
error: "Simulated email service failure",
});
return ResultAsync.fromPromise(
Promise.reject(
authErrors.emailChangeVerificationFailed(
fctx,
"Simulated email service failure",
),
),
(error) => error as Err,
);
}
logger.info("Email change verification sent successfully", {
...fctx,
newEmail,
});
return ResultAsync.fromSafePromise(Promise.resolve(undefined));
}
swapAccountPasswordForTwoFactor(
fctx: FlowExecCtx,
ctx: MiddlewareContext<
@@ -100,47 +53,6 @@ export class AuthController {
};
});
}
sendMagicLink(
fctx: FlowExecCtx,
email: string,
token: string,
url: string,
): ResultAsync<void, Err> {
logger.info("Sending magic link", { ...fctx, email });
logger.debug("Original URL", { ...fctx, url });
const transformedUrl = url
.replace("/api/auth/magic-link/verify", "/auth/magic-link")
.replace("/api/", "/");
logger.debug("Transformed URL", { ...fctx, transformedUrl });
// Simulate email sending with 90/10 success/failure
const success = true;
if (!success) {
logger.error("Failed to send magic link email", {
...fctx,
error: "Simulated email service failure",
});
return ResultAsync.fromPromise(
Promise.reject(
authErrors.magicLinkEmailFailed(
fctx,
"Simulated email service failure",
),
),
(error) => error as Err,
);
}
logger.info("Magic link email sent successfully (NOT REALLY)", {
...fctx,
email,
});
return ResultAsync.fromSafePromise(Promise.resolve(undefined));
}
}
export function getAuthController(): AuthController {

View File

@@ -3,33 +3,6 @@ import { getError } from "@pkg/logger";
import { ERROR_CODES, type Err } from "@pkg/result";
export const authErrors = {
emailSendFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email",
description: "An error occurred while sending the email",
detail,
}),
magicLinkEmailFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send magic link email",
description: "An error occurred while sending the magic link",
detail,
}),
emailChangeVerificationFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email change verification link",
description: "An error occurred while sending the verification email",
detail,
}),
passwordRotationFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({
flowId: fctx.flowId,