Skip to content
Closed
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
50 changes: 50 additions & 0 deletions app/src/lib/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,56 @@ 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 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
227 changes: 227 additions & 0 deletions app/src/lib/components/settings/account-settings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
<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 { auth } from "$lib/auth/store.svelte";
import { changeEmail, changePassword, setup2FA, enable2FA, disable2FA } from "$lib/auth/api";
import { toast } from "svelte-sonner";

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

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 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 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>
55 changes: 55 additions & 0 deletions app/src/lib/components/settings/settings-dialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import UserIcon from "@lucide/svelte/icons/user";
import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
import DownloadIcon from "@lucide/svelte/icons/download";
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
import AccountSettings from "./account-settings.svelte";
import GeneralSettings from "./general-settings.svelte";
import UpdateSettings from "./update-settings.svelte";
import LogsSettings from "./logs-settings.svelte";
import { settingsDialog } from "./settings-store.svelte.js";

type Section = "account" | "general" | "update" | "logs";

const sections: { id: Section; label: string; icon: typeof UserIcon }[] = [
{ id: "account", label: "Account", icon: UserIcon },
{ id: "general", label: "General", icon: SlidersHorizontalIcon },
{ id: "update", label: "Update", icon: DownloadIcon },
{ id: "logs", label: "Logs", icon: ScrollTextIcon },
];

let activeSection = $state<Section>("account");
</script>

<Dialog.Root open={settingsDialog.open} onOpenChange={(value) => settingsDialog.set(value)}>
<Dialog.Content class="w-[min(90vw,880px)] h-[min(85vh,640px)] p-0">
<div class="flex h-full">
<nav class="w-56 shrink-0 border-r bg-muted/20 flex flex-col gap-1 p-3">
<Dialog.Title class="px-2 py-2 text-sm font-semibold">Settings</Dialog.Title>
{#each sections as section (section.id)}
<button
onclick={() => (activeSection = section.id)}
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-sm text-left transition-colors {activeSection === section.id
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
>
<section.icon class="size-4" />
{section.label}
</button>
{/each}
</nav>
<div class="flex-1 overflow-y-auto p-6">
{#if activeSection === "account"}
<AccountSettings />
{:else if activeSection === "general"}
<GeneralSettings />
{:else if activeSection === "update"}
<UpdateSettings />
{:else if activeSection === "logs"}
<LogsSettings />
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
16 changes: 16 additions & 0 deletions app/src/lib/components/settings/settings-store.svelte.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
22 changes: 10 additions & 12 deletions app/src/lib/components/settings/update-settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -216,18 +217,15 @@
<Card.Title>Available releases</Card.Title>
<Card.Description>Select a version to update or downgrade</Card.Description>
</div>
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<div class="relative inline-flex">
<input
type="checkbox"
bind:checked={includePre}
onchange={fetchReleases}
class="peer sr-only"
id="include-pre"
/>
<div class="w-8 h-4 rounded-full bg-muted border peer-checked:bg-primary transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-4"></div>
</div>
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none" for="include-pre">
<Switch
id="include-pre"
checked={includePre}
onCheckedChange={(value) => {
includePre = value;
fetchReleases();
}}
/>
Pre-releases
</label>
</div>
Expand Down
Loading
Loading