+
+ {/* Removal cascades — passkeys, dashboards, screens, email accounts, playlists — and there is no
+ undo, so it is worth one deliberate confirmation. */}
+ !open && setConfirmDelete(null)}>
+
+
+ Remove {confirmDelete?.email}?
+
+ This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, email
+ accounts and playlists. It cannot be undone.
+
+
+
+ Cancel
+ confirmDelete && void remove(confirmDelete)}
+ >
+ Remove account
+
+
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx
new file mode 100644
index 00000000..e30aeb75
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx
@@ -0,0 +1,51 @@
+import { useMemo } from 'react';
+import { Users, UserCog } from 'lucide-react';
+import type { LayoutNode, PanelComponents } from 'officerdev';
+import { WorkspaceLayout } from 'officerdev';
+import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
+import { UsersSection } from './UsersSection';
+
+// Owner-only. Every endpoint behind this screen is gated by ownerGate in users-router.ts, and the
+// global backstop already confines a non-owner token to /api/auth + /api/music — so a Member reaching
+// this route sees the list fail to load rather than someone else's accounts. The route is not hidden
+// from them, because hiding a screen is not access control and pretending otherwise invites someone to
+// rely on it.
+const sections: SettingsSection[] = [
+ {
+ key: 'users',
+ icon: Users,
+ title: 'Accounts',
+ description: 'Who has access, and as what',
+ content: ,
+ },
+];
+
+const { Sidebar, Content } = createSettingsPanelComponents({
+ globalKey: 'USER_MANAGEMENT_SELECTED',
+ sidebarIcon: UserCog,
+ sidebarLabel: 'Users',
+ sections,
+});
+
+const layout: LayoutNode = {
+ type: 'group',
+ id: 'user-management-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'user-management-left', appType: null }, size: 20 },
+ { node: { type: 'panel', id: 'user-management-right', appType: null }, size: 80 },
+ ],
+};
+
+export const UserManagementSettings = () => {
+ const panelComponents: PanelComponents = useMemo(
+ () => ({ 'user-management-left': Sidebar, 'user-management-right': Content }),
+ [],
+ );
+
+ return (
+
+ {}} components={panelComponents} />
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
index 60703437..8575a131 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx
@@ -2,3 +2,4 @@ export * from './ProfileSettings';
export * from './SystemSettings';
export * from './AISettings';
export * from './IntegrationsSettings';
+export * from './UserManagement';
diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts
index 0e82de3a..181ae9bc 100644
--- a/src/apps/officer-web/state/usePageTitle.ts
+++ b/src/apps/officer-web/state/usePageTitle.ts
@@ -10,6 +10,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/settings/ai'), title: 'AI Settings' },
{ match: (p) => p.startsWith('/settings/profile'), title: 'Profile' },
{ match: (p) => p.startsWith('/settings/integrations'), title: 'Integrations' },
+ { match: (p) => p.startsWith('/settings/user-management'), title: 'User Management' },
{ match: (p) => p.startsWith('/settings'), title: 'Settings' },
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
{ match: (p) => p.startsWith('/email'), title: 'Email' },
diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts
index bb0ddc3f..3a5f3194 100644
--- a/src/databases/officer_db/src/index.ts
+++ b/src/databases/officer_db/src/index.ts
@@ -219,7 +219,7 @@ export type { WalletChainSnapshot } from './schema/wallet';
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
// column definition is the only place that list should exist.
-export { USER_ROLES } from './schema/auth';
+export { USER_ROLES, OWNER_USER_ID } from './schema/auth';
export type { UserRole } from './schema/auth';
export { db } from './db';
diff --git a/src/servers/api/users/manage-users.ts b/src/servers/api/users/manage-users.ts
new file mode 100644
index 00000000..07b57098
--- /dev/null
+++ b/src/servers/api/users/manage-users.ts
@@ -0,0 +1,84 @@
+import type { Handler } from 'hono';
+import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
+import type { UserRole } from 'officerdb';
+import * as errors from '@@/custom-errors';
+
+// Owner-only management of the other accounts. Everything here is gated by ownerGate in
+// users-router.ts; these handlers assume the caller is the Super Admin.
+//
+// The password hash never leaves this file — listing users is not a reason to hand them out.
+
+type PublicUser = {
+ id: number;
+ email: string;
+ name: string | null;
+ username: string | null;
+ avatar: string | null;
+ status: string;
+ role: string;
+ createdAt: Date;
+ /** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
+ isOwner: boolean;
+};
+
+const toPublicUser = (u: Awaited>[number]): PublicUser => ({
+ id: u.id,
+ email: u.email,
+ name: u.name,
+ username: u.username,
+ avatar: u.avatar,
+ status: u.status,
+ role: u.role,
+ createdAt: u.createdAt,
+ isOwner: u.id === OWNER_USER_ID,
+});
+
+export const listUsersHandler: Handler = async function (ctx) {
+ const users = await getUsers();
+ return ctx.json({
+ users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
+ roles: USER_ROLES,
+ ownerId: OWNER_USER_ID,
+ });
+};
+
+export const updateUserRoleHandler: Handler = async function (ctx) {
+ const id = Number(ctx.req.param('id'));
+ if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
+
+ const { role } = ctx.get('body') as { role?: unknown };
+ if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
+ throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
+ }
+
+ // ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
+ // message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
+ // clicking the dropdown nothing about why.
+ if (id === OWNER_USER_ID && role !== 'Super Admin') {
+ throw errors.FORBIDDEN('The server owner cannot be demoted.');
+ }
+
+ const existing = await getUserById(id);
+ if (!existing) throw errors.NOT_FOUND('User not found');
+
+ const updated = await updateUser(id, { role: role as UserRole });
+ if (!updated) throw errors.NOT_FOUND('User not found');
+ return ctx.json({ user: toPublicUser(updated) });
+};
+
+export const deleteUserHandler: Handler = async function (ctx) {
+ const id = Number(ctx.req.param('id'));
+ if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
+
+ // deleteUser() throws for the owner regardless; this turns it into a 403 with a sentence rather than
+ // an unhandled error.
+ if (id === OWNER_USER_ID) throw errors.FORBIDDEN('The server owner cannot be removed.');
+
+ const existing = await getUserById(id);
+ if (!existing) throw errors.NOT_FOUND('User not found');
+
+ // Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
+ // to them. There is no undo, which is why the UI asks first.
+ await deleteUser(id);
+ return ctx.json({ ok: true });
+};
diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts
index cc9faa13..eff997b2 100644
--- a/src/servers/api/users/users-router.ts
+++ b/src/servers/api/users/users-router.ts
@@ -1,10 +1,26 @@
+import type { MiddlewareHandler } from 'hono';
import { createRouter } from '@@/create-router';
import { originMiddleware } from '@@/_middlewares';
+import { isSuperAdmin } from '@@/super-admin';
+import * as errors from '@@/custom-errors';
import { updateUserHandler } from './update-user';
+import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
-// Self-update. Officer is single-user: the server owner is the only account, so there is no user
-// listing, invitation or deletion — the account is created once by /auth/bootstrap.
+// Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler);
+
+// Everything below manages OTHER accounts and is the owner's alone. The global backstop in
+// originScopeMiddleware already confines a non-owner token to /api/auth + /api/music, so a Member
+// cannot reach these at all; this gate is the explicit statement of intent and gives a clear 403
+// rather than relying on a rule written for a different purpose.
+const ownerGate: MiddlewareHandler = async (ctx, next) => {
+ if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
+ return next();
+};
+
+usersRouter.get('/', ownerGate, listUsersHandler);
+usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
+usersRouter.delete('/:id', ownerGate, deleteUserHandler);