import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { KeyRound, Link2, Loader2, PenLine, ShieldAlert, ShieldCheck, UserPlus } from 'lucide-react' import { Link } from 'react-router-dom' import { Modal, Panel } from '../components/ui' import { TeamsPanel } from './TeamsPanel' import { request } from '../lib/api' import { authApi } from '../lib/auth' import { CAP, can, hasTool, orgHasTool, toolDisabledReason } 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, // The platform plane, above and outside the tenant ladder (amendment 6 decision 1). It is listed so a // tenant screen renders an operator row correctly and, more importantly, so `outranked` computes FALSE // for one: without the entry the lookup fell to 99 and an operator read as the LOWEST rank in the // building, which would have offered a client admin buttons to demote the platform seat. operator: 7, } 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 inside this organization, and the only role that can grant superuser.', operator: 'The platform seat, outside this organization. Not assignable from here.', } // 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' }, ] // The seats that hold their tools by rule rather than by grant, mirrored from EffectiveTools in // internal/platform/security. A row for one of these must never be described by its grant rows, because it // has none and does not need any. const byRuleSeat = (role: string) => role === 'superuser' || role === 'operator' 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() // THE ROSTER IS NOT REQUESTED BY SOMEONE WHO MAY NOT SEE IT. // // The nav link to this screen is already gated on userInvite, and the comment there is right that hiding it // is only a courtesy while the API is the real control. But the screen is still reachable by typing the URL, // and this query fired unconditionally, so a recruiter or a manager who did that issued a request the API // correctly refused with 403. Nothing leaked; it simply asked for something it knew it could not have. // // Gate C found it on 29 July 2026, six findings across two roles and three viewports. The previous Gate C // ran as a SUPERUSER, who can list users, so the request succeeded and the finding could not appear. Running // the recorded fixture accounts instead, a recruiter and a manager, is what surfaced it. // // Fixed here rather than added to the sweep's allowed response list, because that list is keyed to role, // status and exact path precisely so it cannot grow to cover a 403 on a data endpoint, and its own comment // says so. Teaching the instrument to expect this would have hidden it instead of removing it. const maySeeRoster = can(principal, CAP.userInvite) const q = useQuery({ queryKey: ['people'], queryFn: () => request('/users'), enabled: maySeeRoster, }) 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 [renaming, setRenaming] = useState(null) const [draftName, setDraftName] = useState('') const [linkOpen, setLinkOpen] = useState(false) 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, }) // RESEND AN INVITATION, amendment 6 section 2 item 5, which lists it in tenant user management beside // invite, rename, and deactivate. The endpoint already existed and nothing called it, so an account left // at invited was unreachable: no live link, no password, and no way for an administrator to issue one // without database access. That is exactly the state the reporting user's production account was in. const resend = useMutation({ mutationFn: (id: string) => request<{ token: string }>(`/users/${id}/recovery`, { method: 'POST' }), onSuccess: (r) => { // Same delivery reality as a new invitation: there is no email transport at pilot, so the one time // link is handed back for the sender to deliver. setLink(`${window.location.origin}/accept?token=${r.token}`) setLinkOpen(true) invalidate() }, }) // TENANT NAME EDITING, amendment 6 decision 5, Track U item 3. Only full_name is sent: the server leaves // an omitted field alone, so correcting a name here cannot blank a title or a phone number the person set // for themselves on their own profile screen. const rename = useMutation({ mutationFn: (v: { id: string; full_name: string }) => authApi.updateUserProfile(v.id, { full_name: v.full_name }), onSuccess: () => { setRenaming(null) 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. // // The list is used for the DEFAULT tick set on a new account. The invite form itself now renders all // four with a stated reason on the ones it cannot grant, per decision 13: silently omitting a product // from the form is the same unexplained absence as greying it out, one step further hidden. const grantableTools = TOOLS.filter((t) => hasTool(principal, t.key) && orgHasTool(principal, t.key)) const toggle = (list: string[], key: string, set: (v: string[]) => void) => set(list.includes(key) ? list.filter((k) => k !== key) : [...list, key]) // Reached by typing the URL without the capability. Say so plainly rather than showing a spinner that will // never resolve, which is what a disabled query renders otherwise. if (!maySeeRoster) { return (

Accounts and access is limited to roles that can manage accounts.

Ask an owner or an admin if you need someone invited or a role changed.

) } 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. */}
    {/* A top seat holds every product BY RULE, so an empty grant list is not an empty account (amendment 6 decision 12). Showing the no tools warning on those rows was the same lie in a different place: it is exactly Patricia's row, and it reads as a broken account rather than as the seat that holds everything. */} {byRuleSeat(p.role) ? ( Holds every product this organization has, by rule as the {p.role === 'operator' ? 'platform seat' : 'top seat'} ) : 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 && ( )} {canRole && actionable && ( )} {canDisable && actionable && ( <> {p.status !== 'invited' && ( )} {p.status === 'invited' && ( <> Waiting on them to accept {/* Gated on the capability the ENDPOINT checks, user.invite, not on the surrounding block's user.disable. The two happen to sit at the same tier today, so this changes nothing now and stops the button becoming a guaranteed 403 if they ever diverge. */} )} )} {!actionable && (canRole || canDisable) && ( {isMe ? ( <> You cannot change your own access.{' '} {/* The defect in one sentence: her own name WAS editable, just nowhere she was looking, so the screen has to say where. */} Edit your own name and title ) : ( `Only someone above ${p.role} can change this` )} )}
  • ) })}
{(changeRole.isError || changeStatus.isError || resetMFA.isError || changeTools.isError || resend.isError) && (

{((changeRole.error ?? changeStatus.error ?? resetMFA.error ?? changeTools.error ?? resend.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.

Anyone can change their own name, title, and phone on their own profile, without asking you.

{/* TEAMS, under the roster rather than in settings. A team is an access grant the moment record scope is narrowed, so it belongs beside the screen that grants roles and tools rather than beside the toggle that gives it meaning. It takes the roster this screen already loaded, so adding somebody to a desk does not fetch the same people a second time on a different key and risk the two disagreeing about who exists. Everyone who can reach this screen may READ the desks; writing is owner tier and the panel offers no controls without it. */} {/* The reissued activation link. It shares the wording of the one shown after creating an account, because it is the same delivery problem: no email transport at pilot, so whoever pressed the button hands the link over themselves. */} setLinkOpen(false)} title="Send them this link">

A fresh activation link was issued. There is no email delivery yet, so send this to them yourself. Any earlier link they were given no longer works.

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

{resend.isError &&

{(resend.error as Error).message}

}
{/* Name correction, admin and above, bounded by outranking at the API. Deliberately name only: it is the field a client needed today, and a colleague's title and phone are theirs to set on their own profile screen rather than an administrator's to overwrite. */} setRenaming(null)} title={renaming ? `Rename ${renaming.full_name}` : ''}>

This changes how they appear everywhere in the application. Their email address, which is how they sign in, does not change.

{rename.isError &&

{(rename.error as Error).message}

}
{/* 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 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. // // NO SILENT GREY, amendment 6 decision 13. A disabled control STATES ITS REASON, and the two // reasons are distinct because they are different facts with different fixes: the tenant // never bought the product, or the actor personally does not hold it. A bare grey checkbox // reads as a broken product, which is exactly how this defect reached Joseph. const reason = toolDisabledReason(principal, t.key, on) const locked = reason !== '' 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
{TOOLS.map((t) => { const on = newTools.includes(t.key) const reason = toolDisabledReason(principal, t.key, on) return ( ) })}
{newTools.length === 0 && ( Nothing selected: this account will sign in to an empty application. )}
{invite.isError &&

{(invite.error as Error).message}

}
)}
) }