Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+279
-46
@@ -4,73 +4,306 @@
|
||||
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(); });
|
||||
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('');
|
||||
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($page.data.user);
|
||||
async function changePassword(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
|
||||
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
|
||||
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; }
|
||||
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 = '';
|
||||
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.'); }
|
||||
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; }
|
||||
} 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." />
|
||||
<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">{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}>
|
||||
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
|
||||
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
|
||||
<label for="new-password">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" /></label>
|
||||
<p id="new-password-help" class="hint">Use 8–128 characters and a password you do not use elsewhere.</p>
|
||||
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="8" maxlength="128" bind:value={confirmPassword} /></label>
|
||||
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
|
||||
</fieldset></form>
|
||||
<section class="panel identity">
|
||||
<div>
|
||||
<h2>{user.displayName}</h2>
|
||||
<p>@{user.username} · <span class="role">{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}>
|
||||
<input
|
||||
type="text"
|
||||
class="password-username"
|
||||
value={user.username}
|
||||
autocomplete="username"
|
||||
aria-label="Username"
|
||||
readonly
|
||||
tabindex="-1"
|
||||
/>
|
||||
<label for="current-password"
|
||||
>{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input
|
||||
id="current-password"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
maxlength="128"
|
||||
bind:value={currentPassword}
|
||||
/></label
|
||||
>
|
||||
<label for="new-password"
|
||||
>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"
|
||||
/></label
|
||||
>
|
||||
<p id="new-password-help" class="hint">
|
||||
Use 8–128 characters and a password you do not use elsewhere.
|
||||
</p>
|
||||
<label for="confirm-password"
|
||||
>Confirm new password<input
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="128"
|
||||
bind:value={confirmPassword}
|
||||
/></label
|
||||
>
|
||||
<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>
|
||||
<label class="timezone"
|
||||
>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>
|
||||
</label>
|
||||
<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>
|
||||
<section class="panel" aria-labelledby="display-heading">
|
||||
<h2 id="display-heading">Display preferences</h2>
|
||||
<label class="timezone">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>
|
||||
</label>
|
||||
<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>
|
||||
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
|
||||
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
|
||||
.timezone ~ p { font-size:var(--text-sm); color:var(--text-secondary); }
|
||||
.page { max-width:760px; display:grid; 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 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; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .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; } }
|
||||
.timezone {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-sm);
|
||||
max-width: 420px;
|
||||
}
|
||||
.timezone select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.timezone ~ p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.page {
|
||||
max-width: 760px;
|
||||
display: grid;
|
||||
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 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;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
font: inherit;
|
||||
}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user