terminal, chat and files are granted by default; permissions screen simplified

DEFAULTS. Every role now starts with the three confined capabilities at write, seeded in
bootstrap. These are what the platform is FOR — an account that signs in and reaches none of
them is not restricted, it is useless, and making the owner grant them by hand first is a
step with no decision in it.

Seeded as real rows rather than implied by absence, which keeps the table's one rule intact:
a missing row means no access, always, with no exception to remember. Revoking one therefore
works like revoking anything else — the row goes and nothing puts it back. Done in bootstrap
because that happens exactly once per install, so seeding can never fight a later revocation.
Non-fatal: an owner whose roles hold nothing is a one-click fix, while failing bootstrap over
it leaves a platform with no account at all.

`app` capabilities are deliberately not defaulted — they reach data the owner may not intend
to share, and each needs a sidecar before it means anything.

SCREEN. Role selection is tabs rather than a dropdown: three roles are the axis you move
along, and a select hid two of them behind a click while giving no sense of which one you are
editing. Row descriptions are gone — with three rows called Terminal, Chat and Files they
explained nothing — and the "needs a Linux account" warning went with them, since every
account now gets one at creation, so it was noise about a state that no longer occurs on its
own. `needsOsAccount` is removed from the API too, not just hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:25:31 +00:00
co-authored by Claude Opus 5
parent aaeb3424ab
commit f0af7237db
4 changed files with 60 additions and 30 deletions
+19 -1
View File
@@ -1,5 +1,6 @@
import type { Handler } from 'hono';
import { getUserCount, createUser } from 'officerdb';
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
import { DEFAULT_ROLE_CAPABILITIES } from '@@/capabilities/registry';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { rememberUser } from '@@/_middlewares';
@@ -44,6 +45,23 @@ export const bootstrapHandler: Handler = async function (ctx) {
role: 'Super Admin',
});
// Every other role starts with the baseline: terminal, chat and files at write. Done here because
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
// revocation — take one of these away and nothing puts it back.
//
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
// failing bootstrap over it would leave a platform with no account at all.
try {
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
await replaceRoleGrants(
role,
DEFAULT_ROLE_CAPABILITIES.map((capability) => ({ capability, level: 'write' as const })),
);
}
} catch (ex) {
console.warn('[bootstrap] could not seed default role capabilities', ex);
}
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's
// very first sign-in would be filed as an unknown identity.
rememberUser(user);
@@ -95,8 +95,6 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
routes: c.routes ?? [],
hasPersonalWrites: !!c.personal?.length,
/** `confined` needs a Linux account per member to mean anything — the UI says so next to the row. */
needsOsAccount: c.kind === 'confined',
});
return ctx.json({
+16
View File
@@ -396,6 +396,22 @@ export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
*/
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
/**
* What every role starts with on a fresh install: the three confined capabilities, at `write`.
*
* These are the baseline the platform is FOR — a terminal, a file browser and chat. An account that can sign
* in and reach none of them is not a restricted account, it is a useless one, and making the owner grant them
* by hand before anyone can do anything is a step with no decision in it.
*
* Seeded as real rows rather than implied by absence, which keeps the table's one rule intact: a missing row
* means no access, always, with no exceptions to remember. So revoking one of these works exactly like
* revoking anything else — the row goes, and nothing puts it back.
*
* `app` capabilities are deliberately NOT here. Those reach data the owner may not intend to share, and each
* needs a sidecar installed before it means anything anyway.
*/
export const DEFAULT_ROLE_CAPABILITIES: string[] = CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key);
/** Available to every signed-in account without a grant. */
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');