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

View File

@@ -1,54 +1,32 @@
import { apiClient } from "$lib/global.stores";
import { authClient } from "$lib/auth.client"; import { authClient } from "$lib/auth.client";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { page } from "$app/state";
import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { ResultAsync, errAsync, okAsync } from "neverthrow";
import type { Err } from "@pkg/result"; import type { Err } from "@pkg/result";
class AuthViewModel { class AuthViewModel {
loggingIn = $state(false); loggingIn = $state(false);
loginError = $state<string | null>(null);
async handleGoogleOAuthLogin() { async loginWithCredentials(data: FormData): Promise<boolean> {
const result = await ResultAsync.fromPromise( const username = data.get("username")?.toString().trim();
authClient.signIn.social({ provider: "google" }), const password = data.get("password")?.toString();
(error): Err => ({
code: "NETWORK_ERROR",
message: "Failed to initiate Google OAuth login",
description: "Network request failed",
detail: error instanceof Error ? error.message : String(error),
}),
);
result.match( if (!username || username.length < 3) {
() => { toast.error("Please enter a valid username");
// OAuth flow will redirect, no action needed return false;
}, }
(error) => {
toast.error("Failed to initiate Google login", {
description: error.description,
});
},
);
}
async loginWithEmail(data: FormData): Promise<boolean> { if (!password || password.length < 6) {
const email = data.get("email")?.toString(); toast.error("Please enter a valid password");
if (!email || email.length < 5) {
toast.error("Please enter a valid email");
return false; return false;
} }
this.loggingIn = true; this.loggingIn = true;
this.loginError = null;
const result = await ResultAsync.fromPromise( const result = await ResultAsync.fromPromise(
authClient.signIn.magicLink({ email }), authClient.signIn.username({ username, password }),
(error): Err => ({ (error): Err => ({
code: "NETWORK_ERROR", code: "NETWORK_ERROR",
message: "Failed to send magic link", message: "Failed to login",
description: "Network request failed", description: "Network request failed",
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
}), }),
@@ -57,10 +35,10 @@ class AuthViewModel {
if (response.error) { if (response.error) {
return errAsync({ return errAsync({
code: "API_ERROR", code: "API_ERROR",
message: response.error.message ?? "Something went wrong", message: response.error.message ?? "Invalid credentials",
description: description:
response.error.statusText ?? response.error.statusText ??
"Please try another login method or try again later", "Please check your username and password",
detail: response.error.statusText ?? "Unknown error", detail: response.error.statusText ?? "Unknown error",
}); });
} }
@@ -69,26 +47,18 @@ class AuthViewModel {
const success = result.match( const success = result.match(
() => { () => {
this.loginError = null; toast.success("Login successful", {
toast.success("Login request sent", { description: "Redirecting...",
description: "Check your email for a magic link to log in",
}); });
setTimeout(() => {
window.location.href = "/";
}, 500);
return true; return true;
}, },
(error) => { (error) => {
// Set error message for UI display toast.error(error.message ?? "Invalid credentials", {
const errorMessage = error.message ?? "Something went wrong"; description: error.description,
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,
});
}
return false; return false;
}, },
); );
@@ -96,138 +66,6 @@ class AuthViewModel {
this.loggingIn = false; this.loggingIn = false;
return success; 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(); export const authVM = new AuthViewModel();

View File

@@ -3,152 +3,76 @@
import Button from "$lib/components/ui/button/button.svelte"; import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte"; import Input from "$lib/components/ui/input/input.svelte";
import { authVM } from "$lib/domains/security/auth.vm.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 username = $state<string>("");
let submittedEmail = $state<string>(""); let password = $state<string>("");
let email = $state<string>("");
let emailFocused = $state<boolean>(false);
let isEmailValid = $state<boolean>(false);
// Validate email as user types const canSubmit = $derived(
$effect(() => { username.trim().length >= 3 && password.length >= 6 && !authVM.loggingIn,
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;
}
async function onSubmit( async function onSubmit(
e: SubmitEvent & { currentTarget: HTMLFormElement }, e: SubmitEvent & { currentTarget: HTMLFormElement },
) { ) {
e.preventDefault(); e.preventDefault();
if (!isEmailValid || authVM.loggingIn) return; if (!canSubmit) return;
await authVM.loginWithCredentials(new FormData(e.currentTarget));
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();
} }
</script> </script>
<form class="space-y-4" onsubmit={onSubmit} aria-describedby="login-status"> <form class="space-y-4" onsubmit={onSubmit}>
<!-- Email -->
<div class="space-y-2"> <div class="space-y-2">
<label <label
for="email" for="username"
class="text-foreground flex items-center gap-2 text-sm font-medium" class="text-foreground flex items-center gap-2 text-sm font-medium"
> >
<Icon icon={AtSign} cls="h-4 w-4 text-muted-foreground" /> <Icon icon={AtSign} cls="h-4 w-4 text-muted-foreground" />
<span>Email address</span> <span>Username</span>
</label> </label>
<div class="relative"> <Input
<Input id="username"
id="email" name="username"
name="email" type="text"
type="email" required
required minlength={3}
minlength={5} maxlength={64}
placeholder="you@example.com" placeholder="your-username"
bind:value={email} bind:value={username}
oninput={onInput} class="h-12"
onfocus={() => (emailFocused = true)} />
onblur={() => (emailFocused = false)} </div>
class="h-12 pr-12"
aria-invalid={!isEmailValid && email.length > 0} <div class="space-y-2">
/> <label
<!-- Right adornment: validation / loader --> for="password"
<div class="absolute top-1/2 right-3 -translate-y-1/2"> class="text-foreground flex items-center gap-2 text-sm font-medium"
{#if authVM.loggingIn} >
<Icon <Icon icon={KeyRound} cls="h-4 w-4 text-muted-foreground" />
icon={Loader2} <span>Password</span>
cls="h-5 w-5 animate-spin text-muted-foreground" </label>
/> <Input
{:else if isEmailValid && email.length > 0} id="password"
<Icon icon={CheckCircle} cls="h-5 w-5 text-emerald-500" /> name="password"
{:else if email.length > 0} type="password"
<!-- keep space balanced with a subtle icon state --> required
<Icon minlength={6}
icon={AtSign} placeholder="Your password"
cls="h-5 w-5 text-muted-foreground/50" bind:value={password}
/> class="h-12"
{/if} />
</div>
</div>
{#if emailFocused && email.length > 0 && !isEmailValid}
<p class="text-xs text-muted-foreground">
Please enter a valid email address.
</p>
{/if}
</div> </div>
<!-- Submit -->
<Button <Button
type="submit" 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" 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} {#if authVM.loggingIn}
<Icon icon={Loader2} cls="mr-2 h-5 w-5 animate-spin" /> <Icon icon={Loader2} cls="mr-2 h-5 w-5 animate-spin" />
Sending Magic Link... Signing In...
{:else} {:else}
Send Magic Link Sign In
{/if} {/if}
</Button> </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> </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 { accountVM } from "$lib/domains/account/account.vm.svelte";
import { breadcrumbs } from "$lib/global.stores"; import { breadcrumbs } from "$lib/global.stores";
import AtSign from "@lucide/svelte/icons/at-sign"; 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 Save from "@lucide/svelte/icons/save";
import Upload from "@lucide/svelte/icons/upload"; import Upload from "@lucide/svelte/icons/upload";
import User from "@lucide/svelte/icons/user"; import User from "@lucide/svelte/icons/user";
@@ -22,14 +22,15 @@
const user = $state(data.user!); const user = $state(data.user!);
// Separate form state for profile and email // Separate form state for profile and password
let profileData = $state({ let profileData = $state({
name: user.name ?? "", name: user.name ?? "",
username: user.username ?? "", username: user.username ?? "",
}); });
let emailData = $state({ let passwordData = $state({
email: user.email, password: "",
confirmPassword: "",
}); });
// Handle profile form submission (name, username) // Handle profile form submission (name, username)
@@ -38,11 +39,20 @@
await accountVM.updateProfile(profileData); await accountVM.updateProfile(profileData);
} }
// Handle email form submission // Handle password form submission
async function handleEmailSubmit(e: SubmitEvent) { async function handlePasswordSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (user.email !== emailData.email) { if (
await accountVM.changeEmail(emailData.email); 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.Footer>
</Card.Root> </Card.Root>
<!-- Email Settings --> <!-- Password Settings -->
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Email Settings</Card.Title> <Card.Title>Password Settings</Card.Title>
<Card.Description> <Card.Description>
Manage your email address and verification status. Update your account password.
</Card.Description> </Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<form onsubmit={handleEmailSubmit} class="space-y-4"> <form onsubmit={handlePasswordSubmit} class="space-y-4">
<!-- Email -->
<div class="grid grid-cols-1 gap-1.5"> <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
icon={Mail} icon={KeyRound}
cls="h-3.5 w-3.5 text-muted-foreground" cls="h-3.5 w-3.5 text-muted-foreground"
/> />
Email Address New Password
</Label> </Label>
<Input <Input
id="email" id="password"
type="email" type="password"
bind:value={emailData.email} bind:value={passwordData.password}
placeholder="your.email@example.com" 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} 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>
<div class="flex justify-end"> <div class="flex justify-end">
<Button <Button
type="submit" type="submit"
disabled={accountVM.emailLoading || disabled={accountVM.passwordLoading ||
user.email === emailData.email} passwordData.password.length < 6 ||
passwordData.password !==
passwordData.confirmPassword}
variant="outline" variant="outline"
class="w-full sm:w-auto" class="w-full sm:w-auto"
> >
{#if accountVM.emailLoading} {#if accountVM.passwordLoading}
<Icon icon={Mail} cls="h-4 w-4 mr-2 animate-spin" /> <Icon
Sending... icon={KeyRound}
cls="h-4 w-4 mr-2 animate-spin"
/>
Updating...
{:else} {:else}
<Icon icon={Mail} cls="h-4 w-4 mr-2" /> <Icon icon={KeyRound} cls="h-4 w-4 mr-2" />
Update Email Update Password
{/if} {/if}
</Button> </Button>
</div> </div>
@@ -239,8 +254,7 @@
</Card.Content> </Card.Content>
<Card.Footer> <Card.Footer>
<p class="text-muted-foreground text-xs"> <p class="text-muted-foreground text-xs">
Changing your email will require verification of the new Choose a strong password with at least 6 characters.
address.
</p> </p>
</Card.Footer> </Card.Footer>
</Card.Root> </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 { 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 type { RequestHandler } from "./$types";
import { settings } from "@pkg/settings"; import { settings } from "@pkg/settings";
import { logger } from "@pkg/logger"; import { logger } from "@pkg/logger";
import { db, eq } from "@pkg/db"; import { db, eq, or } from "@pkg/db";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import * as v from "valibot"; import * as v from "valibot";
@@ -48,45 +49,81 @@ export const POST: RequestHandler = async ({ request }) => {
const data = await request.json(); const data = await request.json();
const _schema = v.object({ const _schema = v.object({
username: v.string(), username: v.string(),
email: v.string(), password: v.pipe(v.string(), v.minLength(6)),
usertype: v.enum(UserRoleMap), email: v.optional(v.string()),
usertype: v.optional(v.enum(UserRoleMap)),
}); });
const res = v.safeParse(_schema, data); const res = v.safeParse(_schema, data);
if (!res.success) { if (!res.success) {
return new Response("Invalid data", { status: 400 }); 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 ( const existingUser = await db.query.user
!!( .findFirst({
await db.query.user where: or(eq(user.username, resData.username), eq(user.email, email)),
.findFirst({ columns: { id: true },
where: eq(user.role, UserRoleMap.user), })
columns: { id: true }, .execute();
})
.execute() if (existingUser?.id) {
)?.id return new Response("User already exists", { status: 409 });
) {
return new Response("Admin already exists", { status: 400 });
} }
const resData = res.output;
logger.info( logger.info(
`Creating ${resData.username} | ${resData.email} (${resData.usertype})`, `Creating debug user ${resData.username} | ${email} (${usertype})`,
); );
const out = await db.insert(user).values({ const userId = nanoid();
id: nanoid(), const accountId = nanoid();
username: resData.username, const hashedPassword = await auth.$context.then((ctx) =>
email: resData.email, ctx.password.hash(resData.password),
emailVerified: false, );
name: resData.username,
role: resData.usertype, await db.transaction(async (tx) => {
createdAt: new Date(), await tx
updatedAt: new Date(), .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> </Title>
</Card.Title> </Card.Title>
<Card.Description class="text-muted-foreground"> <Card.Description class="text-muted-foreground">
Sign in to your account to continue Sign in with your username and password
</Card.Description> </Card.Description>
</div> </div>
</div> </div>
@@ -54,12 +54,6 @@
<Card.Content class="px-8 pb-8"> <Card.Content class="px-8 pb-8">
<EmailLoginForm /> <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.Content>
<Card.Footer class="flex items-center justify-between"> <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 { import {
admin, admin,
customSession, customSession,
magicLink,
multiSession, multiSession,
username, username,
} from "better-auth/plugins"; } from "better-auth/plugins";
import { getUserController, UserController } from "../user/controller";
import { AuthController, getAuthController } from "./controller";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { FlowExecCtx } from "@/core/flow.execution.context";
import { UserRoleMap } from "@domains/user/data"; import { UserRoleMap } from "@domains/user/data";
import { getRedisInstance } from "@pkg/keystore"; import { getRedisInstance } from "@pkg/keystore";
import { APIError } from "better-auth/api";
import { settings } from "@core/settings"; import { settings } from "@core/settings";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { logger } from "@pkg/logger"; import { logger } from "@pkg/logger";
import { db, schema } from "@pkg/db"; 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; const COOKIE_CACHE_MAX_AGE = 60 * 5;
const USERNAME_REGEX = /^[a-zA-Z0-9_]+$/;
// 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;
}
export const auth = betterAuth({ export const auth = betterAuth({
trustedOrigins: ["http://localhost:5173", settings.betterAuthUrl], trustedOrigins: ["http://localhost:5173", settings.betterAuthUrl],
@@ -66,48 +33,7 @@ export const auth = betterAuth({
minUsernameLength: 5, minUsernameLength: 5,
maxUsernameLength: 20, maxUsernameLength: 20,
usernameValidator: async (username) => { usernameValidator: async (username) => {
const fctx = createAuthFlowContext("username-check"); return USERNAME_REGEX.test(username);
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,
});
}
}, },
}), }),
admin({ admin({
@@ -157,38 +83,6 @@ export const auth = betterAuth({
}, },
}, },
user: { 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", modelName: "user",
additionalFields: { additionalFields: {
onboardingDone: { onboardingDone: {

View File

@@ -2,61 +2,14 @@ import { AuthContext, MiddlewareContext, MiddlewareOptions } from "better-auth";
import { AccountRepository } from "../user/account.repository"; import { AccountRepository } from "../user/account.repository";
import { FlowExecCtx } from "@/core/flow.execution.context"; import { FlowExecCtx } from "@/core/flow.execution.context";
import { ResultAsync } from "neverthrow"; import { ResultAsync } from "neverthrow";
import type { Err } from "@pkg/result";
import { authErrors } from "./errors"; import { authErrors } from "./errors";
import { logger } from "@pkg/logger"; import { logger } from "@pkg/logger";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
export class AuthController { export class AuthController {
private readonly mins = 10;
constructor(private accountRepo: AccountRepository) {} 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( swapAccountPasswordForTwoFactor(
fctx: FlowExecCtx, fctx: FlowExecCtx,
ctx: MiddlewareContext< 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 { export function getAuthController(): AuthController {

View File

@@ -3,33 +3,6 @@ import { getError } from "@pkg/logger";
import { ERROR_CODES, type Err } from "@pkg/result"; import { ERROR_CODES, type Err } from "@pkg/result";
export const authErrors = { 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 => passwordRotationFailed: (fctx: FlowExecCtx, detail: string): Err =>
getError({ getError({
flowId: fctx.flowId, flowId: fctx.flowId,