login now username+password based
This commit is contained in:
@@ -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>(),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
|
||||||
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,
|
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();
|
||||||
|
|||||||
@@ -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="email"
|
id="username"
|
||||||
name="email"
|
name="username"
|
||||||
type="email"
|
type="text"
|
||||||
required
|
required
|
||||||
minlength={5}
|
minlength={3}
|
||||||
placeholder="you@example.com"
|
maxlength={64}
|
||||||
bind:value={email}
|
placeholder="your-username"
|
||||||
oninput={onInput}
|
bind:value={username}
|
||||||
onfocus={() => (emailFocused = true)}
|
class="h-12"
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{#if emailFocused && email.length > 0 && !isEmailValid}
|
<div class="space-y-2">
|
||||||
<p class="text-xs text-muted-foreground">
|
<label
|
||||||
Please enter a valid email address.
|
for="password"
|
||||||
</p>
|
class="text-foreground flex items-center gap-2 text-sm font-medium"
|
||||||
{/if}
|
>
|
||||||
|
<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>
|
</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>
|
|
||||||
We’ve 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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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}
|
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="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}
|
||||||
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
!!(
|
|
||||||
await db.query.user
|
|
||||||
.findFirst({
|
.findFirst({
|
||||||
where: eq(user.role, UserRoleMap.user),
|
where: or(eq(user.username, resData.username), eq(user.email, email)),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
})
|
})
|
||||||
.execute()
|
.execute();
|
||||||
)?.id
|
|
||||||
) {
|
if (existingUser?.id) {
|
||||||
return new Response("Admin already exists", { status: 400 });
|
return new Response("User already exists", { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
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,
|
username: resData.username,
|
||||||
email: resData.email,
|
email,
|
||||||
emailVerified: false,
|
emailVerified: true,
|
||||||
name: resData.username,
|
name: resData.username,
|
||||||
role: resData.usertype,
|
role: usertype,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user