diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
index 484c5be5..86fbe11b 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
@@ -20,8 +20,6 @@ type CapabilityInfo = {
description: string;
routes: string[];
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' };
@@ -101,21 +99,27 @@ export const PermissionsSection = () => {
return (
+ {/* 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. */}
-
-
Role
-
setRole(value)}>
-
-
-
-
- {data.roles.map((r) => (
-
- {r}
-
- ))}
-
-
+
+ {data.roles.map((r) => (
+ 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}
+
+ ))}
{saving && }
@@ -134,17 +138,11 @@ export const PermissionsSection = () => {
const level = draft[capability.key] ?? 'none';
return (
-
-
{capability.label}
-
{capability.description}
- {/* Said on the row rather than in a footnote, because the grant genuinely does nothing
- without it and the fix is on the Accounts tab two clicks away. */}
- {capability.needsOsAccount && (
-
- Needs a Linux account — grant does nothing until the member has one
-
- )}
-
+ {/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
+ they explained nothing anyone needed — and the "needs a Linux account" line went with them:
+ every account gets one at creation, so warning about it on every row was noise about a state
+ that no longer occurs on its own. */}
+
{capability.label}
setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts
index cc849d14..58551e49 100644
--- a/src/servers/api/auth/bootstrap.ts
+++ b/src/servers/api/auth/bootstrap.ts
@@ -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);
diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts
index 9e668494..aff7d331 100644
--- a/src/servers/api/users/capabilities-routes.ts
+++ b/src/servers/api/users/capabilities-routes.ts
@@ -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({
diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts
index 4ab75659..5a9528f6 100644
--- a/src/servers/capabilities/registry.ts
+++ b/src/servers/capabilities/registry.ts
@@ -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');