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:
+22
-24
@@ -20,8 +20,6 @@ type CapabilityInfo = {
|
|||||||
description: string;
|
description: string;
|
||||||
routes: string[];
|
routes: string[];
|
||||||
hasPersonalWrites: boolean;
|
hasPersonalWrites: boolean;
|
||||||
/** Confined: the grant does nothing until the member has a Linux account on this machine. */
|
|
||||||
needsOsAccount: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
||||||
@@ -101,21 +99,27 @@ export const PermissionsSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 p-1">
|
<div className="flex flex-col gap-5 p-1">
|
||||||
|
{/* Tabs rather than a dropdown. There are three roles and they are the axis you move along — a select
|
||||||
|
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
|
||||||
|
buttons, because switching role mutates a draft rather than navigating. */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
|
||||||
<span className="text-sm text-muted-foreground">Role</span>
|
|
||||||
<Select value={activeRole ?? undefined} onValueChange={(value) => setRole(value)}>
|
|
||||||
<SelectTrigger className="w-44">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{data.roles.map((r) => (
|
{data.roles.map((r) => (
|
||||||
<SelectItem key={r} value={r}>
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeRole === r}
|
||||||
|
onClick={() => setRole(r)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
activeRole === r
|
||||||
|
? 'bg-accent font-medium text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{r}
|
{r}
|
||||||
</SelectItem>
|
</button>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={save} disabled={!dirty || saving}>
|
<Button onClick={save} disabled={!dirty || saving}>
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
@@ -134,17 +138,11 @@ export const PermissionsSection = () => {
|
|||||||
const level = draft[capability.key] ?? 'none';
|
const level = draft[capability.key] ?? 'none';
|
||||||
return (
|
return (
|
||||||
<div key={capability.key} className="flex items-center justify-between gap-4 p-3">
|
<div key={capability.key} className="flex items-center justify-between gap-4 p-3">
|
||||||
<div className="min-w-0">
|
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
|
||||||
<div className="text-sm font-medium">{capability.label}</div>
|
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
|
||||||
<div className="text-xs text-muted-foreground">{capability.description}</div>
|
every account gets one at creation, so warning about it on every row was noise about a state
|
||||||
{/* Said on the row rather than in a footnote, because the grant genuinely does nothing
|
that no longer occurs on its own. */}
|
||||||
without it and the fix is on the Accounts tab two clicks away. */}
|
<div className="min-w-0 text-sm font-medium">{capability.label}</div>
|
||||||
{capability.needsOsAccount && (
|
|
||||||
<div className="mt-0.5 text-xs text-amber-500">
|
|
||||||
Needs a Linux account — grant does nothing until the member has one
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Select
|
<Select
|
||||||
value={level}
|
value={level}
|
||||||
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
|
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Handler } from 'hono';
|
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 argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { rememberUser } from '@@/_middlewares';
|
import { rememberUser } from '@@/_middlewares';
|
||||||
@@ -44,6 +45,23 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
role: 'Super Admin',
|
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
|
// 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.
|
// very first sign-in would be filed as an unknown identity.
|
||||||
rememberUser(user);
|
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.
|
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
||||||
routes: c.routes ?? [],
|
routes: c.routes ?? [],
|
||||||
hasPersonalWrites: !!c.personal?.length,
|
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({
|
return ctx.json({
|
||||||
|
|||||||
@@ -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');
|
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. */
|
/** Available to every signed-in account without a grant. */
|
||||||
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');
|
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user