From a3284512504073fee892686dd684d5b308522b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 14:57:48 +0000 Subject: [PATCH] add /settings/user-management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role column now has somewhere to be used from. Lists every account, changes roles, removes members. API — all owner-only, mounted on the existing users router: GET /api/users list, plus the role enum so the UI never hand-writes the names PATCH /api/users/:id/role change a role DELETE /api/users/:id remove an account ownerGate uses isSuperAdmin, which now reads the role column. The global backstop in originScopeMiddleware already confines a non-owner token to /api/auth + /api/music, so a Member cannot reach any of this — the gate is the explicit statement of intent and gives a clear 403 rather than leaning on a rule written for another purpose. The password hash never leaves the handler: listing accounts is not a reason to hand out hashes, so the response is an explicit shape rather than the row. The owner is refused twice over, in both handlers, before the database has to. ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks that row rather than offering an action that cannot succeed. The menu entry is shown to the owner only. That is tidiness, not access control — the route stays reachable and the endpoints are gated server-side, because a hidden menu item is not a permission and anything relying on it being hidden is already wrong. Said so in the code, next to both. Deleting cascades — passkeys, dashboards, screens, email accounts, playlists — and there is no undo, so it asks first and says what goes. Not built: invitations. Creating an account still means bootstrap or a row by hand; an invite flow needs a token, an email and an acceptance screen, which is its own piece of work. Untested at runtime: the routes 404 on the running server because platform TS does not hot-reload. Everything typechecks, the token path was verified against /api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the restart asymmetry rather than the wiring. Needs `pm2 restart officer`. Co-Authored-By: Claude Opus 5 (1M context) --- src/apps/officer-web/App.tsx | 1 + .../Dashboard/Layout/Header/UserMenu.tsx | 13 +- .../Settings/UserManagement/UsersSection.tsx | 164 ++++++++++++++++++ .../Settings/UserManagement/index.tsx | 51 ++++++ .../Screens/Dashboard/Settings/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + src/databases/officer_db/src/index.ts | 2 +- src/servers/api/users/manage-users.ts | 84 +++++++++ src/servers/api/users/users-router.ts | 20 ++- 9 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx create mode 100644 src/servers/api/users/manage-users.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 0cc28c2f..d88b75bc 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -34,6 +34,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index 3c9575dc..02b7c8e5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import * as Dropdown from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { User, LogOut, Settings, Package, Puzzle, Sun, Moon, Bot } from 'lucide-react'; +import { User, LogOut, Settings, Package, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import { useTranslation } from '@/lib/i18n'; import { useColorMode } from '@/components/ui/ThemeProvider'; @@ -65,6 +65,17 @@ export function UserMenu() { Integrations + {/* Owner only. This is tidiness, not access control — the route stays reachable and its + endpoints are gated server-side by ownerGate, because a hidden menu item is not a permission + and anything relying on it being hidden is already wrong. */} + {user?.role === 'Super Admin' && ( + + + + User Management + + + )} {colorMode === 'dark' ? : } diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx new file mode 100644 index 00000000..4d6b77b6 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Crown, Trash2, Loader2 } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +type ManagedUser = { + id: number; + email: string; + name: string | null; + username: string | null; + status: string; + role: string; + createdAt: string; + isOwner: boolean; +}; + +type UsersResponse = { users: ManagedUser[]; roles: string[]; ownerId: number }; + +const USERS_KEY = ['MANAGED_USERS']; + +export const UsersSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const [pendingId, setPendingId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: USERS_KEY, + queryFn: () => client.get('/users'), + }); + + const changeRole = async (user: ManagedUser, role: string) => { + if (role === user.role) return; + setPendingId(user.id); + try { + await client.patch(`/users/${user.id}/role`, { role }); + await queryClient.invalidateQueries({ queryKey: USERS_KEY }); + toast.success(`${user.email} is now ${role}`); + } catch (ex) { + toast.error(ex instanceof Error ? ex.message : 'Could not change the role'); + } finally { + setPendingId(null); + } + }; + + const remove = async (user: ManagedUser) => { + setPendingId(user.id); + setConfirmDelete(null); + try { + await client.delete(`/users/${user.id}`); + await queryClient.invalidateQueries({ queryKey: USERS_KEY }); + toast.success(`${user.email} removed`); + } catch (ex) { + toast.error(ex instanceof Error ? ex.message : 'Could not remove the account'); + } finally { + setPendingId(null); + } + }; + + if (isLoading) { + return ( +
+ Loading accounts… +
+ ); + } + + if (isError || !data) { + return
Could not load the accounts.
; + } + + return ( +
+

+ Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that + row cannot be changed from here. +

+ +
+ {data.users.map((user) => { + const busy = pendingId === user.id; + return ( +
+
+
+ {user.name || user.username || user.email} + {user.isOwner && } +
+
+ {user.email} + {user.status !== 'Active' && ` · ${user.status}`} +
+
+ + + + +
+ ); + })} +
+ + {/* 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);