add /settings/user-management
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) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ export function App() {
|
||||
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
|
||||
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
|
||||
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||
<Route path="/settings/user-management" element={<Dashboard.UserManagementSettings />} />
|
||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* 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' && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/user-management">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
User Management
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
|
||||
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<UsersResponse>({
|
||||
queryKey: USERS_KEY,
|
||||
queryFn: () => client.get<UsersResponse>('/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 (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border divide-y">
|
||||
{data.users.map((user) => {
|
||||
const busy = pendingId === user.id;
|
||||
return (
|
||||
<div key={user.id} className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
|
||||
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
{user.status !== 'Active' && ` · ${user.status}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={user.role}
|
||||
disabled={user.isOwner || busy}
|
||||
onValueChange={(role) => void changeRole(user, role)}
|
||||
>
|
||||
<SelectTrigger className="w-40 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.roles.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
disabled={user.isOwner || busy}
|
||||
onClick={() => setConfirmDelete(user)}
|
||||
aria-label={`Remove ${user.email}`}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Removal cascades — passkeys, dashboards, screens, email accounts, playlists — and there is no
|
||||
undo, so it is worth one deliberate confirmation. */}
|
||||
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, email
|
||||
accounts and playlists. It cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => confirmDelete && void remove(confirmDelete)}
|
||||
>
|
||||
Remove account
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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: <UsersSection />,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from './ProfileSettings';
|
||||
export * from './SystemSettings';
|
||||
export * from './AISettings';
|
||||
export * from './IntegrationsSettings';
|
||||
export * from './UserManagement';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ReturnType<typeof getUsers>>[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 });
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user