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);