import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { KeyRound, Link2, Loader2, ShieldAlert, ShieldCheck, UserPlus } from 'lucide-react' import { Modal, Panel } from '../components/ui' import { request } from '../lib/api' import { CAP, can, hasTool } from '../lib/can' import { useAuth } from '../lib/useAuth' type Person = { id: string email: string full_name: string role: string status: string mfa_enrolled: boolean last_login_at?: string tools: string[] } // The authority order, mirrored from internal/platform/security. The server is the authority; this only // shapes what is offered, and every action is refused independently by the API. const RANK: Record = { viewer: 0, member: 1, recruiter: 2, manager: 3, admin: 4, owner: 5, superuser: 6, } const ROLE_NOTE: Record = { viewer: 'Reads candidates and searches. Changes nothing.', member: 'Reads candidates and searches. Changes nothing.', recruiter: 'Works a desk: candidates, job orders, slates, exports.', manager: 'A recruiter, plus moving work between people and reading the audit trail.', admin: 'Manages accounts and privacy, plus everything a manager does.', owner: 'Everything, including organization settings. Cannot create another owner.', superuser: 'Everything, and the only role that can grant superuser. Held by a named few.', } // The four products, in the order the navigation presents them. const TOOLS: { key: string; label: string; blurb: string }[] = [ { key: 'missionmatch', label: 'missionMATCH', blurb: 'Command Center, candidates, the brain' }, { key: 'screenmatch', label: 'screenMATCH', blurb: 'Single candidate qualification' }, { key: 'slatematch', label: 'slateMATCH', blurb: 'Proposal candidate slates' }, { key: 'recruitmatch', label: 'recruitMATCH', blurb: 'Clients, job orders, pipeline' }, ] const inputCls = 'w-full rounded-lg border border-line/70 bg-navy-900 px-3 py-2 text-sm text-ink outline-none focus:border-blue' export function Admin() { const { principal } = useAuth() const qc = useQueryClient() const q = useQuery({ queryKey: ['people'], queryFn: () => request('/users') }) const [inviteOpen, setInviteOpen] = useState(false) const [email, setEmail] = useState('') const [name, setName] = useState('') const [role, setRole] = useState('recruiter') const [newTools, setNewTools] = useState([]) const [link, setLink] = useState('') const [editing, setEditing] = useState(null) const [draftTools, setDraftTools] = useState([]) const invalidate = () => void qc.invalidateQueries({ queryKey: ['people'] }) const invite = useMutation({ mutationFn: (v: { email: string; full_name: string; role: string; tools: string[] }) => request<{ token: string; user_id: string }>('/users/invite', { method: 'POST', body: JSON.stringify(v) }), onSuccess: (r) => { // There is no email transport at pilot, so the one time link is handed back for the inviter to // deliver. Saying so is better than a screen that implies a message was sent. // // The shape must match what AcceptInvite actually reads: the route is /accept and the parameter is // `token`. This previously produced `/?invite=`, which is wrong on both counts, so every // invitation ever issued from this screen landed on the login page with the token silently ignored // and no way for the recipient to set a password. It failed as a dead link rather than an error, // which is why it survived: the inviter sees a plausible URL and only the recipient sees nothing. setLink(`${window.location.origin}/accept?token=${r.token}`) invalidate() }, }) const changeRole = useMutation({ mutationFn: (v: { id: string; role: string }) => request(`/users/${v.id}/role`, { method: 'PATCH', body: JSON.stringify({ role: v.role }) }), onSuccess: invalidate, }) const changeTools = useMutation({ mutationFn: (v: { id: string; tools: string[] }) => request(`/users/${v.id}/tools`, { method: 'PATCH', body: JSON.stringify({ tools: v.tools }) }), onSuccess: () => { setEditing(null) invalidate() }, }) const changeStatus = useMutation({ mutationFn: (v: { id: string; status: string }) => request(`/users/${v.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: v.status }) }), onSuccess: invalidate, }) const resetMFA = useMutation({ mutationFn: (id: string) => request(`/users/${id}/mfa/reset`, { method: 'POST' }), onSuccess: invalidate, }) const myRank = RANK[principal?.role ?? ''] ?? -1 const canInvite = can(principal, CAP.userInvite) const canRole = can(principal, CAP.userRoleChange) const canDisable = can(principal, CAP.userDisable) // Only roles strictly below the actor's own may be granted, which is why an admin sees no admin option. // Superuser is the single exception: it is the one tier that may grant itself, because a tier that // cannot reproduce can only ever shrink, and an organization that loses its last holder would have no // in application way back. const grantable = Object.keys(RANK).filter( (r) => (RANK[r] ?? 99) < myRank || (r === 'superuser' && principal?.role === 'superuser'), ) // An actor may only confer a tool they hold themselves, so the ceiling on what can be spread is what // the actor already has. The server enforces this; offering more here would only produce a refusal. const grantableTools = TOOLS.filter((t) => hasTool(principal, t.key)) const toggle = (list: string[], key: string, set: (v: string[]) => void) => set(list.includes(key) ? list.filter((k) => k !== key) : [...list, key]) if (q.isLoading) return Loading accounts… if (q.isError) { return ( Could not load accounts. {(q.error as Error)?.message} ) } const people = q.data ?? [] return (
    {people.map((p) => { const theirRank = RANK[p.role] ?? 99 const outranked = myRank > theirRank const isMe = p.id === principal?.user_id const actionable = outranked && !isMe return (
  • {p.full_name} {isMe && you} {p.role === 'superuser' && } {p.role} {p.status !== 'active' && ( {p.status} )} {!p.mfa_enrolled && ( no MFA )}

    {p.email} {p.last_login_at ? ` · last in ${new Date(p.last_login_at).toLocaleDateString()}` : ' · never signed in'}

    {ROLE_NOTE[p.role]}

    {/* What this person can SEE, stated plainly. An account with nothing granted signs in successfully and finds an empty application, which reads as a bug unless it is said. */}
    {p.tools.length === 0 ? ( No tools granted, this account can sign in but sees nothing ) : ( TOOLS.filter((t) => p.tools.includes(t.key)).map((t) => ( {t.label} )) )}
    {canRole && actionable && grantable.length > 0 && ( )} {canRole && actionable && ( )} {canDisable && actionable && ( <> {p.status !== 'invited' && ( )} {p.status === 'invited' && Waiting on them to accept} )} {!actionable && (canRole || canDisable) && ( {isMe ? 'You cannot change your own access' : `Only someone above ${p.role} can change this`} )}
  • ) })}
{(changeRole.isError || changeStatus.isError || resetMFA.isError || changeTools.isError) && (

{((changeRole.error ?? changeStatus.error ?? resetMFA.error ?? changeTools.error) as Error)?.message}

)} {/* The rules that look like bugs unless they are stated. */}

Role is what someone may DO. Tools are what they may SEE. They are set separately.

You cannot change your own role or your own tools, which is what stops an accidental self change.

You can only grant a role below your own, so an admin cannot create another admin.

You can only grant a tool you hold yourself, so access cannot be widened through a new account.

Superuser is the only role that can grant superuser, and only a superuser can create one.

{/* Tool entitlement editor */} setEditing(null)} title={editing ? `Tools for ${editing.full_name}` : ''}>

Choose what this person sees. Unticking a tool removes it from their navigation and refuses its endpoints; it does not change what they may do inside the tools they keep.

{TOOLS.map((t) => { const mine = hasTool(principal, t.key) const on = draftTools.includes(t.key) // A tool the actor does not hold can be REMOVED but not granted: taking access away is the // safe direction, so the box is usable when ticked and locked when it is not. const locked = !mine && !on return ( ) })}
{draftTools.length === 0 && (

With nothing selected this person can sign in and will see an empty application.

)}
{/* Account creation */} setInviteOpen(false)} title="Create an account">
{link ? ( <>

Account created. There is no email delivery yet, so send this link to them yourself.

The link is single use and expires. Treat it like a password.

) : ( <>
Tools, what they may see
{grantableTools.map((t) => ( ))}
{newTools.length === 0 && ( Nothing selected: this account will sign in to an empty application. )}
{invite.isError &&

{(invite.error as Error).message}

}
)}
) }