diff --git a/app/src/lib/auth/api.ts b/app/src/lib/auth/api.ts index 6554eb23..7b333602 100644 --- a/app/src/lib/auth/api.ts +++ b/app/src/lib/auth/api.ts @@ -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; } @@ -151,6 +152,78 @@ export async function validateSession(token: string): Promise { return res.json(); } +export async function changeEmail(token: string, newEmail: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, diff --git a/app/src/lib/auth/store.svelte.ts b/app/src/lib/auth/store.svelte.ts index beecab6a..8c1d1703 100644 --- a/app/src/lib/auth/store.svelte.ts +++ b/app/src/lib/auth/store.svelte.ts @@ -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, }; diff --git a/app/src/lib/components/settings/account-settings.svelte b/app/src/lib/components/settings/account-settings.svelte new file mode 100644 index 00000000..9e5c98ac --- /dev/null +++ b/app/src/lib/components/settings/account-settings.svelte @@ -0,0 +1,293 @@ + + +
+
+

Profile

+
+ + {#if avatarPreview} + + {/if} + + {(auth.user?.username ?? "??").slice(0, 2).toUpperCase()} + + +
+ +
+ + +
+

+ Avatar is pulled from gravatar.com +

+
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+

Password

+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+

Two-factor authentication

+

Require an authenticator code at login

+
+ +
+ + {#if twoFactorStep === "enrolling"} +
+

Scan with your authenticator app, then enter the code

+ {#if qrCode} + 2FA QR code + {/if} + + {#snippet children({ cells })} + + {#each cells.slice(0, 3) as cell (cell)} + + {/each} + + + + {#each cells.slice(3, 6) as cell (cell)} + + {/each} + + {/snippet} + +
+ + +
+
+ {:else if twoFactorStep === "disabling"} +
+

Enter your current code to disable 2FA

+ + {#snippet children({ cells })} + + {#each cells.slice(0, 3) as cell (cell)} + + {/each} + + + + {#each cells.slice(3, 6) as cell (cell)} + + {/each} + + {/snippet} + +
+ + +
+
+ {/if} +
+
diff --git a/app/src/lib/components/settings/general-settings.svelte b/app/src/lib/components/settings/general-settings.svelte new file mode 100644 index 00000000..bbe69da5 --- /dev/null +++ b/app/src/lib/components/settings/general-settings.svelte @@ -0,0 +1 @@ +

No general settings configured.

diff --git a/app/src/lib/components/settings/settings-dialog.svelte b/app/src/lib/components/settings/settings-dialog.svelte new file mode 100644 index 00000000..a39c9f9b --- /dev/null +++ b/app/src/lib/components/settings/settings-dialog.svelte @@ -0,0 +1,55 @@ + + + settingsDialog.set(value)}> + +
+ +
+ {#if activeSection === "account"} + + {:else if activeSection === "general"} + + {:else if activeSection === "update"} + + {:else if activeSection === "logs"} + + {/if} +
+
+
+
diff --git a/app/src/lib/components/settings/settings-store.svelte.ts b/app/src/lib/components/settings/settings-store.svelte.ts new file mode 100644 index 00000000..807f8169 --- /dev/null +++ b/app/src/lib/components/settings/settings-store.svelte.ts @@ -0,0 +1,16 @@ +let open = $state(false); + +export const settingsDialog = { + get open() { + return open; + }, + show() { + open = true; + }, + hide() { + open = false; + }, + set(value: boolean) { + open = value; + }, +}; diff --git a/app/src/lib/components/settings/update-settings.svelte b/app/src/lib/components/settings/update-settings.svelte index c4df2faf..2301a0e5 100644 --- a/app/src/lib/components/settings/update-settings.svelte +++ b/app/src/lib/components/settings/update-settings.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte"; import * as Card from "$lib/components/ui/card/index.js"; import { Button } from "$lib/components/ui/button/index.js"; + import { Switch } from "$lib/components/ui/switch/index.js"; import { auth } from "$lib/auth/store.svelte"; import { getUpdateStatus, @@ -216,18 +217,15 @@ Available releases Select a version to update or downgrade -