300 lines
10 KiB
Svelte
300 lines
10 KiB
Svelte
<script lang="ts">
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import { onMount } from 'svelte';
|
||
import { roleLabels, type AuthUser } from '$lib/auth.js';
|
||
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
|
||
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
|
||
let exampleDate = $state<Date | null>(null);
|
||
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
|
||
onMount(() => {
|
||
exampleDate = new Date();
|
||
});
|
||
import { page } from '$app/stores';
|
||
import { invalidateAll } from '$app/navigation';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
let currentPassword = $state('');
|
||
let newPassword = $state('');
|
||
let confirmPassword = $state('');
|
||
let busy = $state(false);
|
||
let signingOut = $state(false);
|
||
let error = $state('');
|
||
let notice = $state('');
|
||
const user = $derived<AuthUser | null>($page.data.user ?? null);
|
||
async function changePassword(event: SubmitEvent) {
|
||
event.preventDefault();
|
||
if (busy || signingOut) return;
|
||
error = '';
|
||
notice = '';
|
||
if (newPassword !== confirmPassword) {
|
||
error = 'The new passwords do not match.';
|
||
return;
|
||
}
|
||
const required = user?.mustChangePassword;
|
||
busy = true;
|
||
try {
|
||
const response = await fetch('/api/auth/password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ currentPassword, newPassword })
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
|
||
currentPassword = '';
|
||
newPassword = '';
|
||
confirmPassword = '';
|
||
if (required) {
|
||
window.location.assign('/');
|
||
return;
|
||
}
|
||
await invalidateAll();
|
||
notice = 'Your password has been changed.';
|
||
} catch (cause) {
|
||
error = (cause as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
async function logout() {
|
||
if (busy || signingOut) return;
|
||
signingOut = true;
|
||
error = '';
|
||
try {
|
||
const response = await fetch('/api/auth/logout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: '{}'
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.json();
|
||
throw new Error(body.error ?? 'Could not sign out.');
|
||
}
|
||
window.location.assign('/login');
|
||
} catch (cause) {
|
||
error = (cause as Error).message;
|
||
signingOut = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
|
||
<section class="page">
|
||
<SectionHeader
|
||
title="My account"
|
||
subtitle="Manage your password, display preferences, and current session."
|
||
/>
|
||
{#if user}
|
||
<section class="panel identity">
|
||
<div>
|
||
<h2>{user.displayName}</h2>
|
||
<p>@{user.username} · <span class="role">{roleLabels[user.role]}</span></p>
|
||
</div>
|
||
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
|
||
>Sign out</Button
|
||
>
|
||
</section>
|
||
{#if user.mustChangePassword}<p class="required" role="status">
|
||
Your account uses a temporary password. Choose a new password before continuing to
|
||
Electric Cherry.
|
||
</p>{/if}
|
||
<section class="panel">
|
||
<h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
|
||
class="notice"
|
||
role="status"
|
||
>
|
||
{notice}
|
||
</p>{/if}
|
||
<form onsubmit={changePassword} aria-busy={busy}>
|
||
<fieldset disabled={busy || signingOut}>
|
||
<Field label="Username" hiddenLabel class="password-username"
|
||
><Input
|
||
type="text"
|
||
value={user.username}
|
||
autocomplete="username"
|
||
aria-label="Username"
|
||
readonly
|
||
tabindex={-1}
|
||
/></Field
|
||
>
|
||
<Field
|
||
forId="current-password"
|
||
label={user.mustChangePassword ? 'Temporary password' : 'Current password'}
|
||
>
|
||
<Input
|
||
id="current-password"
|
||
name="currentPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
required
|
||
maxlength={128}
|
||
bind:value={currentPassword}
|
||
/></Field
|
||
>
|
||
<Field forId="new-password" label="New password">
|
||
<Input
|
||
id="new-password"
|
||
name="newPassword"
|
||
type="password"
|
||
autocomplete="new-password"
|
||
required
|
||
minlength={8}
|
||
maxlength={128}
|
||
bind:value={newPassword}
|
||
aria-describedby="new-password-help"
|
||
/></Field
|
||
>
|
||
<p id="new-password-help" class="hint">
|
||
Use 8–128 characters and a password you do not use elsewhere.
|
||
</p>
|
||
<Field forId="confirm-password" label="Confirm new password">
|
||
<Input
|
||
id="confirm-password"
|
||
name="confirmPassword"
|
||
type="password"
|
||
autocomplete="new-password"
|
||
required
|
||
minlength={8}
|
||
maxlength={128}
|
||
bind:value={confirmPassword}
|
||
/></Field
|
||
>
|
||
<div>
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
loading={busy}
|
||
disabled={busy || signingOut}
|
||
>{user.mustChangePassword
|
||
? 'Save password and continue'
|
||
: 'Change password'}</Button
|
||
>
|
||
</div>
|
||
</fieldset>
|
||
</form>
|
||
</section>
|
||
<section class="panel" aria-labelledby="display-heading">
|
||
<h2 id="display-heading">Display preferences</h2>
|
||
<Field class="timezone" label="Timezone">
|
||
<Select bind:value={$timeZone} aria-describedby="timezone-help">
|
||
{#each zones as zone}<option value={zone}
|
||
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
|
||
? ' (default)'
|
||
: ''}</option
|
||
>{/each}
|
||
</Select>
|
||
</Field>
|
||
<p id="timezone-help">
|
||
Dates and times throughout the app use this timezone, including daylight saving
|
||
changes. Saved automatically in this browser.
|
||
</p>
|
||
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
|
||
{#if $timeZonePersistenceError}<p role="status">
|
||
Browser storage is unavailable. Your timezone applies for this visit but cannot
|
||
be saved.
|
||
</p>{/if}
|
||
</section>
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
.panel :global(.timezone) {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
font-size: var(--text-sm);
|
||
max-width: 420px;
|
||
}
|
||
.panel :global(.timezone ~ p) {
|
||
font-size: var(--text-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
.page {
|
||
max-width: 760px;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: 24px;
|
||
}
|
||
.panel {
|
||
padding: 24px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
h2 {
|
||
font-size: var(--text-md);
|
||
margin: 0;
|
||
}
|
||
.identity {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
}
|
||
.identity > div {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.identity p {
|
||
margin: 8px 0 0;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.role {
|
||
text-transform: capitalize;
|
||
}
|
||
.required {
|
||
padding: 16px;
|
||
margin: 0;
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
border-left: 3px solid var(--accent-primary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
}
|
||
form {
|
||
margin-top: 24px;
|
||
max-width: 460px;
|
||
}
|
||
fieldset {
|
||
border: 0;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 18px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel :global(.password-username) {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
clip-path: inset(50%);
|
||
overflow: hidden;
|
||
padding: 0;
|
||
border: 0;
|
||
}
|
||
.hint {
|
||
margin: -8px 0 0;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
line-height: 1.6;
|
||
}
|
||
.error {
|
||
color: var(--state-danger);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.notice {
|
||
color: var(--state-online);
|
||
font-size: var(--text-sm);
|
||
}
|
||
@media (max-width: 540px) {
|
||
.identity {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|