Add optional 30-day remembered sessions
CI / checks (push) Successful in 6m29s

This commit is contained in:
2026-10-13 14:42:53 -04:00
parent e85dc58130
commit 82e07ea33e
13 changed files with 677 additions and 72 deletions
+6 -1
View File
@@ -7,7 +7,12 @@ export const POST: RequestHandler = async ({ request, cookies, locals, url, getC
consumeAuthAttempt(getClientAddress(), user.id, 'password');
const body = await authBody(request);
return sessionResponse(
await changePassword(user.id, body.currentPassword, body.newPassword),
await changePassword(
user.id,
body.currentPassword,
body.newPassword,
locals.sessionHash ?? ''
),
cookies,
url
);
+7 -2
View File
@@ -2,10 +2,15 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import { createUser, listUsers } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users: listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => {
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
try {
return json(
{ user: await createUser(requireUser(locals.user).id, await authBody(request)) },
{
user: await createUser(requireUser(locals.user).id, await authBody(request), {
tokenHash: locals.sessionHash ?? '',
clientAddress: getClientAddress()
})
},
{ status: 201 }
);
} catch (cause) {
+10 -2
View File
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateUser } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const PATCH: RequestHandler = async ({ request, params, locals }) => {
export const PATCH: RequestHandler = async ({ request, params, locals, getClientAddress }) => {
try {
return json({
user: await updateUser(requireUser(locals.user).id, params.id, await authBody(request))
user: await updateUser(
requireUser(locals.user).id,
params.id,
await authBody(request),
{
tokenHash: locals.sessionHash ?? '',
clientAddress: getClientAddress()
}
)
});
} catch (cause) {
return authError(cause);
+35 -1
View File
@@ -6,6 +6,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let username = $state('');
let password = $state('');
let rememberMe = $state(false);
let busy = $state(false);
let error = $state('');
function destination(): string {
@@ -28,7 +29,7 @@
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, rememberMe })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.');
@@ -76,6 +77,20 @@
bind:value={password}
/></Field
>
<div class="remember-device">
<label>
<input
type="checkbox"
name="rememberMe"
bind:checked={rememberMe}
aria-describedby="remember-help"
/>
Keep me signed in on this device
</label>
<p id="remember-help">
Stay signed in for 30 days between visits. Use only on a device you trust.
</p>
</div>
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
</fieldset>
</form>
@@ -111,6 +126,25 @@
font-size: var(--text-sm);
line-height: 1.6;
}
.remember-device label {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
font-size: var(--text-sm);
}
.remember-device input {
width: 18px;
height: 18px;
flex-shrink: 0;
accent-color: var(--accent-primary);
}
.remember-device p {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.help {
margin: 24px 0 0;
}
@@ -0,0 +1,6 @@
import { readSessionHash } from '$lib/server/auth/index.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => ({
rememberedSession: !!(locals.sessionHash && readSessionHash(locals.sessionHash)?.remembered)
});
+33 -1
View File
@@ -23,6 +23,7 @@
let username = $state('');
let displayName = $state('');
let password = $state('');
let currentPassword = $state('');
let role = $state<UserRole>('viewer');
let enabled = $state(true);
let showPassword = $state(false);
@@ -62,7 +63,11 @@
];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin');
const rememberedSession = $derived($page.data.rememberedSession);
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
$effect(() => {
if (!createOpen && !editOpen) currentPassword = '';
});
async function load() {
loading = true;
@@ -118,6 +123,7 @@
});
function openCreate() {
if (!administrator) return;
currentPassword = '';
username = '';
displayName = '';
password = '';
@@ -130,6 +136,7 @@
}
function openEdit(user: AuthUser) {
if (!administrator) return;
currentPassword = '';
editing = user;
username = user.username;
displayName = user.displayName;
@@ -151,7 +158,7 @@
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName, password, role })
body: JSON.stringify({ username, displayName, password, role, currentPassword })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
@@ -162,6 +169,7 @@
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
@@ -183,6 +191,7 @@
displayName,
role,
disabled: !enabled,
currentPassword,
...(password ? { password } : {})
})
});
@@ -200,6 +209,7 @@
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
@@ -370,6 +380,26 @@
{/if}
</section>
{#snippet accountPassword(id: string)}
{#if rememberedSession}
<Field forId={id} label="Your current password">
<Input
{id}
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength={128}
bind:value={currentPassword}
aria-describedby={`${id}-help`}
/>
</Field>
<p class="hint" id={`${id}-help`}>
Confirm your password to make account changes on this remembered device.
</p>
{/if}
{/snippet}
{#if administrator}<Dialog
bind:open={createOpen}
title="Add user"
@@ -439,6 +469,7 @@
<label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label
>
{@render accountPassword('create-current-password')}
<div class="form-actions">
<Button
disabled={busy}
@@ -518,6 +549,7 @@
><input type="checkbox" bind:checked={showPassword} />Show temporary
password</label
>{/if}
{@render accountPassword('edit-current-password')}
<div class="form-actions">
<Button
disabled={busy}