Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions app/src/lib/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface UserProfile {
id: string;
username: string;
email: string | null;
gravatar_email: string | null;
two_factor_enabled: boolean;
force_password_change: boolean;
}
Expand Down Expand Up @@ -151,6 +152,78 @@ export async function validateSession(token: string): Promise<UserProfile> {
return res.json();
}

export async function changeEmail(token: string, newEmail: string): Promise<void> {
const res = await fetch(`${API_BASE}/change-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ new_email: newEmail }),
});
if (!res.ok) throw new Error(`change-email failed: ${res.status}`);
}

export async function changeGravatarEmail(token: string, gravatarEmail: string | null): Promise<void> {
const res = await fetch(`${API_BASE}/change-gravatar-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ gravatar_email: gravatarEmail }),
});
if (!res.ok) throw new Error(`change-gravatar-email failed: ${res.status}`);
}

export async function gravatarUrl(email: string, size = 80): Promise<string> {
const normalized = email.trim().toLowerCase();
const encoded = new TextEncoder().encode(normalized);
const digest = await crypto.subtle.digest('SHA-256', encoded);
const hash = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=404`;
}

export interface Setup2FAResponse {
secret: string;
qr_code: string;
}

export async function setup2FA(token: string): Promise<Setup2FAResponse> {
const res = await fetch(`${API_BASE}/2fa/setup`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`2fa setup failed: ${res.status}`);
return res.json();
}

export async function enable2FA(token: string, code: string): Promise<void> {
const res = await fetch(`${API_BASE}/2fa/enable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(`2fa enable failed: ${res.status}`);
}

export async function disable2FA(token: string, code: string): Promise<void> {
const res = await fetch(`${API_BASE}/2fa/disable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(`2fa disable failed: ${res.status}`);
}

export class TwoFactorRequiredError extends Error {
constructor(
public readonly username: string,
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/auth/store.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function createAuthStore() {
id: response.user_id,
username: response.username,
email: null,
gravatar_email: null,
two_factor_enabled: response.two_factor_enabled,
force_password_change: response.force_password_change,
};
Expand Down
293 changes: 293 additions & 0 deletions app/src/lib/components/settings/account-settings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
import Spinner from "$lib/components/ui/spinner/spinner.svelte";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import { auth } from "$lib/auth/store.svelte";
import {
changeEmail,
changeGravatarEmail,
changePassword,
gravatarUrl,
setup2FA,
enable2FA,
disable2FA,
} from "$lib/auth/api";
import { toast } from "svelte-sonner";

let email = $state(auth.user?.email ?? "");
let emailSaving = $state(false);

let gravatarEmail = $state(auth.user?.gravatar_email ?? "");
let gravatarSaving = $state(false);
let avatarPreview = $state<string | null>(null);

const gravatarChanged = $derived(gravatarEmail.trim() !== (auth.user?.gravatar_email ?? ""));

$effect(() => {
const source = auth.user?.gravatar_email;
if (!source) {
avatarPreview = null;
return;
}
gravatarUrl(source, 96).then((url) => (avatarPreview = url));
});

let oldPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
let passwordSaving = $state(false);

let twoFactorStep = $state<"idle" | "enrolling" | "disabling">("idle");
let qrCode = $state("");
let otpValue = $state("");
let twoFactorBusy = $state(false);

const emailChanged = $derived(email.trim() !== "" && email !== (auth.user?.email ?? ""));
const passwordValid = $derived(newPassword.length >= 8 && newPassword === confirmPassword);

async function saveEmail() {
if (!auth.token || !emailChanged) return;
emailSaving = true;
try {
await changeEmail(auth.token, email.trim());
auth.setUser({ ...auth.user!, email: email.trim() });
toast.success("Email updated");
} catch {
toast.error("Failed to update email");
} finally {
emailSaving = false;
}
}

async function saveGravatarEmail() {
if (!auth.token || !gravatarChanged) return;
gravatarSaving = true;
try {
const trimmed = gravatarEmail.trim();
await changeGravatarEmail(auth.token, trimmed === "" ? null : trimmed);
auth.setUser({ ...auth.user!, gravatar_email: trimmed === "" ? null : trimmed });
toast.success("Gravatar updated");
} catch {
toast.error("Failed to update gravatar");
} finally {
gravatarSaving = false;
}
}

async function savePassword() {
if (!auth.token || !passwordValid) return;
passwordSaving = true;
try {
await changePassword(auth.token, oldPassword, newPassword);
oldPassword = "";
newPassword = "";
confirmPassword = "";
toast.success("Password updated");
} catch {
toast.error("Failed to update password", { description: "Check your current password" });
} finally {
passwordSaving = false;
}
}

async function toggleTwoFactor(next: boolean) {
if (!auth.token) return;
if (next) {
twoFactorBusy = true;
try {
const response = await setup2FA(auth.token);
qrCode = response.qr_code;
twoFactorStep = "enrolling";
} catch {
toast.error("Failed to start 2FA setup");
} finally {
twoFactorBusy = false;
}
} else {
twoFactorStep = "disabling";
otpValue = "";
}
}

async function confirmEnable() {
if (!auth.token || otpValue.length !== 6) return;
twoFactorBusy = true;
try {
await enable2FA(auth.token, otpValue);
auth.setUser({ ...auth.user!, two_factor_enabled: true });
twoFactorStep = "idle";
otpValue = "";
toast.success("Two-factor authentication enabled");
} catch {
toast.error("Invalid code");
otpValue = "";
} finally {
twoFactorBusy = false;
}
}

async function confirmDisable() {
if (!auth.token || otpValue.length !== 6) return;
twoFactorBusy = true;
try {
await disable2FA(auth.token, otpValue);
auth.setUser({ ...auth.user!, two_factor_enabled: false });
twoFactorStep = "idle";
otpValue = "";
toast.success("Two-factor authentication disabled");
} catch {
toast.error("Invalid code");
otpValue = "";
} finally {
twoFactorBusy = false;
}
}

function cancelTwoFactorStep() {
twoFactorStep = "idle";
otpValue = "";
qrCode = "";
}
</script>

<div class="flex flex-col gap-8 max-w-xl">
<section class="flex flex-col gap-4">
<h3 class="text-sm font-semibold">Profile</h3>
<div class="flex items-center gap-4">
<Avatar.Root class="size-16 rounded-lg">
{#if avatarPreview}
<Avatar.Image src={avatarPreview} alt="Gravatar avatar" />
{/if}
<Avatar.Fallback class="rounded-lg text-lg">
{(auth.user?.username ?? "??").slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="flex-1 flex flex-col gap-1.5">
<Label for="settings-gravatar" class="text-xs text-muted-foreground">Gravatar email</Label>
<div class="flex gap-2">
<Input
id="settings-gravatar"
type="email"
bind:value={gravatarEmail}
placeholder="you@example.com"
/>
<Button onclick={saveGravatarEmail} disabled={!gravatarChanged || gravatarSaving} size="sm">
{#if gravatarSaving}<Spinner />{:else}Save{/if}
</Button>
</div>
<p class="text-xs text-muted-foreground">
Avatar is pulled from <a href="https://gravatar.com/profile" target="_blank" rel="noreferrer" class="underline">gravatar.com</a>
</p>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="settings-username" class="text-xs text-muted-foreground">Username</Label>
<Input id="settings-username" value={auth.user?.username ?? ""} disabled />
</div>
<div class="flex flex-col gap-1.5">
<Label for="settings-email" class="text-xs text-muted-foreground">Email</Label>
<div class="flex gap-2">
<Input id="settings-email" type="email" bind:value={email} placeholder="you@example.com" />
<Button onclick={saveEmail} disabled={!emailChanged || emailSaving} size="sm">
{#if emailSaving}<Spinner />{:else}Save{/if}
</Button>
</div>
</div>
</section>

<section class="flex flex-col gap-4 pt-6 border-t">
<h3 class="text-sm font-semibold">Password</h3>
<div class="flex flex-col gap-1.5">
<Label for="settings-old-password" class="text-xs text-muted-foreground">Current password</Label>
<Input id="settings-old-password" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="settings-new-password" class="text-xs text-muted-foreground">New password</Label>
<Input id="settings-new-password" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="settings-confirm-password" class="text-xs text-muted-foreground">Confirm new password</Label>
<Input id="settings-confirm-password" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
<Button onclick={savePassword} disabled={!passwordValid || passwordSaving} size="sm" class="w-fit">
{#if passwordSaving}<Spinner />{:else}Update password{/if}
</Button>
</section>

<section class="flex flex-col gap-4 pt-6 border-t">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold">Two-factor authentication</h3>
<p class="text-xs text-muted-foreground mt-0.5">Require an authenticator code at login</p>
</div>
<Switch
checked={auth.user?.two_factor_enabled ?? false}
disabled={twoFactorBusy || twoFactorStep !== "idle"}
onCheckedChange={toggleTwoFactor}
/>
</div>

{#if twoFactorStep === "enrolling"}
<div class="flex flex-col gap-4 p-4 rounded-lg border bg-muted/30">
<p class="text-xs text-muted-foreground">Scan with your authenticator app, then enter the code</p>
{#if qrCode}
<img
src="data:image/png;base64,{qrCode}"
alt="2FA QR code"
class="size-40 rounded-md self-center bg-white p-2"
/>
{/if}
<InputOTP.Root maxlength={6} bind:value={otpValue}>
{#snippet children({ cells })}
<InputOTP.Group class="flex-1">
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} class="flex-1 w-full" />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group class="flex-1">
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} class="flex-1 w-full" />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<div class="flex gap-2">
<Button onclick={confirmEnable} disabled={otpValue.length !== 6 || twoFactorBusy} size="sm">
{#if twoFactorBusy}<Spinner />{:else}Confirm{/if}
</Button>
<Button onclick={cancelTwoFactorStep} variant="outline" size="sm">Cancel</Button>
</div>
</div>
{:else if twoFactorStep === "disabling"}
<div class="flex flex-col gap-4 p-4 rounded-lg border bg-muted/30">
<p class="text-xs text-muted-foreground">Enter your current code to disable 2FA</p>
<InputOTP.Root maxlength={6} bind:value={otpValue}>
{#snippet children({ cells })}
<InputOTP.Group class="flex-1">
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} class="flex-1 w-full" />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group class="flex-1">
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} class="flex-1 w-full" />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<div class="flex gap-2">
<Button onclick={confirmDisable} disabled={otpValue.length !== 6 || twoFactorBusy} variant="destructive" size="sm">
{#if twoFactorBusy}<Spinner />{:else}Disable{/if}
</Button>
<Button onclick={cancelTwoFactorStep} variant="outline" size="sm">Cancel</Button>
</div>
</div>
{/if}
</section>
</div>
1 change: 1 addition & 0 deletions app/src/lib/components/settings/general-settings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p class="text-sm text-muted-foreground">No general settings configured.</p>
Loading
Loading