feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the beginning and there was no such thing. There are exactly two ways an account has ever come into existence: the first admin at setup, and somebody accepting an invite to a project. So the only way to add a person was to share a project with them first. The store has been ready the whole time. CreateInvite already documents that "projectID may be empty, which invites somebody to the instance rather than to a particular project", and AcceptInvite already has the branch that grants no membership when there is none. requireAdmin has existed in the middleware and was used nowhere. This wires those three together and puts a page on them. An invite rather than an account with a password the administrator picks. A password typed here would be known to two people before it was ever used, and would then have to travel to the second one by some channel nobody has thought about. No name is asked for either — the signup form asks the person, and a name an administrator guesses is a name somebody has to correct. Two refusals keep the instance administrable: the last administrator cannot be demoted or deleted, and nobody can delete the account they are signed in as. Both are enforced on the server; the interface simply does not draw the buttons on your own row, because a button whose only purpose is to be refused wastes a click to teach a rule. The last-admin refusal gets its own error code, because "that already exists" is not what happened and this is the one refusal a person needs explained. Deleting an account is the only hard delete in this database, and building the confirmation is what turned up how wide it goes. `projects.owner_id` cascades, which is expected. But `tasks.created_by` cascades too — so removing a colleague deletes every task they ever wrote, including in projects somebody else owns. The count in the list says so, and the confirmation says it in words. Changing that to ON DELETE SET NULL, so the work stays and loses its author, means rebuilding the tasks table and its FTS triggers: worth doing, not worth doing quietly while adding a screen. It is written down in the store instead. Pending invites are listed and can be withdrawn. Only the token's hash is stored, so a link sent to the wrong address cannot be looked up and resent — withdrawing it is the only way to stop it before it expires. Six API tests for the guards and the counts, and two smoke tests: the whole loop from invite to a second browser creating the account and appearing in the list as somebody who is not an administrator, and the absence of buttons on your own row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a8b453843d
commit
e8ebd0fa78
+10
-1
@@ -46,7 +46,9 @@ const MESSAGES = {
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.'
|
||||
inbox_protected: 'Indbakken kan ikke slettes.',
|
||||
last_admin:
|
||||
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -170,6 +172,13 @@ export const api = {
|
||||
listSessions: () => get('/auth/sessions'),
|
||||
endSession: (id) => del(`/auth/sessions/${id}`),
|
||||
|
||||
// --- users, administrators only
|
||||
listUsers: () => get('/users'),
|
||||
inviteUser: (email) => post('/users', { email }),
|
||||
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
|
||||
deleteUser: (id) => del(`/users/${id}`),
|
||||
revokeInvite: (id) => del(`/invites/${id}`),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
* is six places for them to drift apart.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const SECTIONS = [
|
||||
// Brugere is only shown to administrators — the API refuses everybody else, and
|
||||
// a tab that answers 403 is a promise the interface cannot keep. Hidden rather
|
||||
// than disabled: this is not a feature to upsell, it is one that does not
|
||||
// apply to you.
|
||||
let sections = $derived([
|
||||
{ href: '/indstillinger', label: 'Konto' },
|
||||
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
|
||||
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
|
||||
{ href: '/indstillinger/ai', label: 'AI' },
|
||||
{ href: '/indstillinger/tokens', label: 'API-tokens' },
|
||||
...(app.user?.is_admin ? [{ href: '/indstillinger/brugere', label: 'Brugere' }] : []),
|
||||
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
|
||||
];
|
||||
]);
|
||||
|
||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
||||
</script>
|
||||
@@ -32,7 +38,7 @@
|
||||
</header>
|
||||
|
||||
<nav aria-label="Indstillinger">
|
||||
{#each SECTIONS as section (section.href)}
|
||||
{#each sections as section (section.href)}
|
||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script>
|
||||
/**
|
||||
* Brugere: who is on this instance, and how somebody else gets on it.
|
||||
*
|
||||
* There is no open registration and no account created with a password
|
||||
* somebody else chose. Adding a person means issuing an invite with no project
|
||||
* attached; the link ends with a password only its owner has seen. So this page
|
||||
* has two lists — the accounts, and the invites that have gone out and not been
|
||||
* used yet.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
let users = $state([]);
|
||||
let invites = $state([]);
|
||||
let email = $state('');
|
||||
let link = $state('');
|
||||
let errors = $state({});
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await api.listUsers();
|
||||
users = r.users;
|
||||
invites = r.invites;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
errors = {};
|
||||
link = '';
|
||||
try {
|
||||
const result = await api.inviteUser(email);
|
||||
email = '';
|
||||
// Shown whether or not it was emailed. With no mail server this is the
|
||||
// only way it reaches anybody, and with one it saves waiting on
|
||||
// delivery to find out whether it worked.
|
||||
link = result.link;
|
||||
await load();
|
||||
} catch (e) {
|
||||
errors = e.fields ?? {};
|
||||
if (!Object.keys(errors).length) app.toast(humanMessage(e));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setAdmin(user, isAdmin) {
|
||||
const previous = users;
|
||||
users = users.map((u) => (u.id === user.id ? { ...u, is_admin: isAdmin } : u));
|
||||
try {
|
||||
await api.setUserAdmin(user.id, isAdmin);
|
||||
} catch (e) {
|
||||
users = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting an account is the only thing in this app that cannot be undone.
|
||||
*
|
||||
* Everything else is soft-deleted with a trash behind it. This is not, and it
|
||||
* takes more than the person's own work: `tasks.created_by` cascades, so what
|
||||
* they wrote in projects somebody else owns goes too. The confirmation says the
|
||||
* numbers out loud, which is why the list carries them.
|
||||
*/
|
||||
async function remove(user) {
|
||||
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
|
||||
if (user.project_count || user.task_count) {
|
||||
parts.push(
|
||||
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
|
||||
`${count(user.task_count, 'opgave', 'opgaver')} — også opgaver, personen har ` +
|
||||
`skrevet i projekter, andre ejer.`,
|
||||
''
|
||||
);
|
||||
}
|
||||
parts.push('Det kan ikke fortrydes. Der er ingen papirkurv for konti.');
|
||||
if (!confirm(parts.join('\n'))) return;
|
||||
|
||||
const previous = users;
|
||||
users = users.filter((u) => u.id !== user.id);
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
} catch (e) {
|
||||
users = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(pending) {
|
||||
if (!confirm(`Træk invitationen til ${pending.email} tilbage? Linket holder op med at virke.`))
|
||||
return;
|
||||
|
||||
const previous = invites;
|
||||
invites = invites.filter((i) => i.id !== pending.id);
|
||||
try {
|
||||
await api.revokeInvite(pending.id);
|
||||
} catch (e) {
|
||||
invites = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'aldrig logget ind';
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
if (seconds < 60) return 'lige nu';
|
||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
const expires = (iso) =>
|
||||
new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Inviter en bruger</h2>
|
||||
<p class="hint">
|
||||
Der er ingen åben registrering. Du sender et link, og personen vælger selv navn
|
||||
og adgangskode — så er der ikke en adgangskode, to mennesker kender.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={invite}>
|
||||
<div class="field">
|
||||
<label for="ny-email">E-mailadresse</label>
|
||||
<input
|
||||
id="ny-email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="navn@example.dk"
|
||||
required
|
||||
aria-invalid={errors.email ? 'true' : undefined}
|
||||
/>
|
||||
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if link}
|
||||
<div class="field">
|
||||
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
|
||||
<code class="link-out">{link}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if invites.length}
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Afventer svar</h2>
|
||||
<p class="hint">
|
||||
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
|
||||
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="rows">
|
||||
{#each invites as pending (pending.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="primary-line">{pending.email}</span>
|
||||
<span class="secondary">
|
||||
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
||||
· udløber {expires(pending.expires_at)}
|
||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Konti</h2>
|
||||
<p class="hint">Alle på denne instans.</p>
|
||||
</header>
|
||||
|
||||
<ul class="rows">
|
||||
{#each users as user (user.id)}
|
||||
<li>
|
||||
<span class="avatar" style="background: {user.avatar_color}">
|
||||
{user.name[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<div class="what">
|
||||
<span class="primary-line">
|
||||
{user.name}
|
||||
{#if user.is_admin}<span class="badge">administrator</span>{/if}
|
||||
{#if user.self}<span class="badge quiet">dig</span>{/if}
|
||||
</span>
|
||||
<span class="secondary">
|
||||
{user.email} · {ago(user.last_seen_at)}
|
||||
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nobody demotes or deletes themselves from here. The server refuses
|
||||
both, and a button that exists only to be refused is a button that
|
||||
wastes a click to teach you a rule. -->
|
||||
{#if !user.self}
|
||||
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
|
||||
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
|
||||
</button>
|
||||
<button class="danger" onclick={() => remove(user)}>Slet</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rows li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.primary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.badge.quiet {
|
||||
color: var(--ink-faint);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.link-out {
|
||||
display: block;
|
||||
margin-top: var(--s1);
|
||||
padding: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user