Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Admin.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | frontend/src/screens/Admin.tsx |
| Original SHA-256 | b113dbf871801c79a4dd8e495312c96c4b6abcb7cd0427012ddf94a2324db751 |
| Git blob SHA | bcc19a2f887dd037420d82260cc24f8b19d4c0d4 |
| Reading-copy SHA-256 | b113dbf871801c79a4dd8e495312c96c4b6abcb7cd0427012ddf94a2324db751 |
Recovered source transcript
Download complete source text ↓448 lines · 21.4 KB
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<string, number> = {
viewer: 0, member: 1, recruiter: 2, manager: 3, admin: 4, owner: 5, superuser: 6,
}
const ROLE_NOTE: Record<string, string> = {
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<Person[]>('/users') })
const [inviteOpen, setInviteOpen] = useState(false)
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [role, setRole] = useState('recruiter')
const [newTools, setNewTools] = useState<string[]>([])
const [link, setLink] = useState('')
const [editing, setEditing] = useState<Person | null>(null)
const [draftTools, setDraftTools] = useState<string[]>([])
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=<token>`, 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<unknown>(`/users/${v.id}/role`, { method: 'PATCH', body: JSON.stringify({ role: v.role }) }),
onSuccess: invalidate,
})
const changeTools = useMutation({
mutationFn: (v: { id: string; tools: string[] }) =>
request<unknown>(`/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<unknown>(`/users/${v.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: v.status }) }),
onSuccess: invalidate,
})
const resetMFA = useMutation({
mutationFn: (id: string) => request<unknown>(`/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 <Panel className="grid place-items-center py-24 text-muted">Loading accounts…</Panel>
if (q.isError) {
return (
<Panel className="grid place-items-center py-24 text-warn">
Could not load accounts. {(q.error as Error)?.message}
</Panel>
)
}
const people = q.data ?? []
return (
<div className="space-y-4">
<Panel title="Accounts and access" hint={`${people.length} in this organization`}>
<div className="mb-3 flex justify-end">
<button
onClick={() => {
setLink('')
setNewTools(grantableTools.map((t) => t.key))
setInviteOpen(true)
}}
disabled={!canInvite}
title={canInvite ? undefined : 'Creating accounts is an admin action'}
className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2 text-sm font-bold text-white hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
<UserPlus size={14} />
Create an account
</button>
</div>
<ul className="divide-y divide-line/50">
{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 (
<li key={p.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-sm font-semibold">
{p.full_name}
{isMe && <span className="text-[10px] font-bold uppercase text-faint">you</span>}
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide"
style={
p.role === 'superuser'
? { background: 'color-mix(in srgb, var(--color-gold) 22%, transparent)', color: 'var(--color-gold)' }
: { background: 'color-mix(in srgb, var(--color-blue) 20%, transparent)', color: 'var(--color-blue-soft)' }
}
>
{p.role === 'superuser' && <ShieldCheck size={10} />}
{p.role}
</span>
{p.status !== 'active' && (
<span className="text-[10px] font-bold uppercase" style={{ color: 'var(--color-warn)' }}>
{p.status}
</span>
)}
{!p.mfa_enrolled && (
<span
className="flex items-center gap-1 text-[10px] font-bold uppercase"
style={{ color: 'var(--color-risk)' }}
title="This account has no second factor enrolled"
>
<ShieldAlert size={10} />
no MFA
</span>
)}
</p>
<p className="text-[11px] text-faint">
{p.email}
{p.last_login_at ? ` · last in ${new Date(p.last_login_at).toLocaleDateString()}` : ' · never signed in'}
</p>
<p className="text-[10px] text-faint">{ROLE_NOTE[p.role]}</p>
{/* 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. */}
<div className="mt-1.5 flex flex-wrap items-center gap-1">
{p.tools.length === 0 ? (
<span className="text-[10px] font-semibold" style={{ color: 'var(--color-warn)' }}>
No tools granted, this account can sign in but sees nothing
</span>
) : (
TOOLS.filter((t) => p.tools.includes(t.key)).map((t) => (
<span
key={t.key}
className="rounded border border-line/70 px-1.5 py-0.5 text-[10px] font-semibold text-muted"
>
{t.label}
</span>
))
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
{canRole && actionable && grantable.length > 0 && (
<select
value=""
onChange={(e) => e.target.value && changeRole.mutate({ id: p.id, role: e.target.value })}
className="rounded-lg border border-line/70 bg-navy-900 px-2 py-1 text-[11px] text-muted"
>
<option value="">Change role…</option>
{grantable
.filter((r) => r !== p.role)
.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
{canRole && actionable && (
<button
onClick={() => {
setEditing(p)
setDraftTools(p.tools)
}}
className="rounded-lg border border-line/70 px-2.5 py-1 text-[11px] font-semibold text-muted hover:text-ink"
>
Tools…
</button>
)}
{canDisable && actionable && (
<>
{p.status !== 'invited' && (
<button
onClick={() =>
changeStatus.mutate({ id: p.id, status: p.status === 'active' ? 'disabled' : 'active' })
}
className="rounded-lg border border-line/70 px-2.5 py-1 text-[11px] font-semibold text-muted hover:text-ink"
>
{p.status === 'active' ? 'Disable' : 'Enable'}
</button>
)}
{p.status === 'invited' && <span className="text-[10px] text-faint">Waiting on them to accept</span>}
<button
onClick={() => resetMFA.mutate(p.id)}
className="flex items-center gap-1 rounded-lg border border-line/70 px-2.5 py-1 text-[11px] font-semibold text-muted hover:text-ink"
title="Clears their second factor. They enroll again on next sign in."
>
<KeyRound size={11} />
Reset MFA
</button>
</>
)}
{!actionable && (canRole || canDisable) && (
<span className="text-[10px] text-faint">
{isMe ? 'You cannot change your own access' : `Only someone above ${p.role} can change this`}
</span>
)}
</div>
</li>
)
})}
</ul>
{(changeRole.isError || changeStatus.isError || resetMFA.isError || changeTools.isError) && (
<p className="mt-3 text-xs text-risk">
{((changeRole.error ?? changeStatus.error ?? resetMFA.error ?? changeTools.error) as Error)?.message}
</p>
)}
{/* The rules that look like bugs unless they are stated. */}
<div className="mt-4 space-y-1 border-t border-line/50 pt-3 text-[10px] text-faint">
<p>Role is what someone may DO. Tools are what they may SEE. They are set separately.</p>
<p>You cannot change your own role or your own tools, which is what stops an accidental self change.</p>
<p>You can only grant a role below your own, so an admin cannot create another admin.</p>
<p>You can only grant a tool you hold yourself, so access cannot be widened through a new account.</p>
<p>Superuser is the only role that can grant superuser, and only a superuser can create one.</p>
</div>
</Panel>
{/* Tool entitlement editor */}
<Modal open={!!editing} onClose={() => setEditing(null)} title={editing ? `Tools for ${editing.full_name}` : ''}>
<div className="space-y-3">
<p className="text-xs text-muted">
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.
</p>
<div className="space-y-1.5">
{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 (
<label
key={t.key}
className={`flex items-start gap-2.5 rounded-lg border border-line/60 p-2.5 ${locked ? 'opacity-40' : 'cursor-pointer hover:border-line'}`}
>
<input
type="checkbox"
checked={on}
disabled={locked}
onChange={() => toggle(draftTools, t.key, setDraftTools)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold text-ink">{t.label}</span>
<span className="block text-[11px] text-faint">
{t.blurb}
{locked && ' · you do not hold this tool, so you cannot grant it'}
</span>
</span>
</label>
)
})}
</div>
{draftTools.length === 0 && (
<p className="text-[11px]" style={{ color: 'var(--color-warn)' }}>
With nothing selected this person can sign in and will see an empty application.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setEditing(null)}
className="rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Cancel
</button>
<button
onClick={() => editing && changeTools.mutate({ id: editing.id, tools: draftTools })}
disabled={changeTools.isPending}
className="flex items-center gap-2 rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
>
{changeTools.isPending && <Loader2 size={13} className="animate-spin" />}
Save access
</button>
</div>
</div>
</Modal>
{/* Account creation */}
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title="Create an account">
<div className="space-y-3">
{link ? (
<>
<p className="text-sm text-muted">
Account created. There is no email delivery yet, so send this link to them yourself.
</p>
<div className="flex items-center gap-2 rounded-lg border border-line/70 bg-navy-900 p-2">
<Link2 size={13} className="shrink-0 text-faint" />
<input readOnly value={link} className="w-full bg-transparent text-[11px] text-ink outline-none" />
</div>
<p className="text-[10px] text-faint">The link is single use and expires. Treat it like a password.</p>
<div className="flex justify-end">
<button
onClick={() => setInviteOpen(false)}
className="rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Done
</button>
</div>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Email</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} className={inputCls} />
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Full name</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Role, what they may do</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className={inputCls}>
{grantable.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<span className="text-[10px] text-faint">{ROLE_NOTE[role]}</span>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Tools, what they may see</span>
<div className="space-y-1">
{grantableTools.map((t) => (
<label
key={t.key}
className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-line/60 px-2.5 py-2 hover:border-line"
>
<input
type="checkbox"
checked={newTools.includes(t.key)}
onChange={() => toggle(newTools, t.key, setNewTools)}
/>
<span className="text-sm text-ink">{t.label}</span>
<span className="ml-auto text-[10px] text-faint">{t.blurb}</span>
</label>
))}
</div>
{newTools.length === 0 && (
<span className="text-[10px]" style={{ color: 'var(--color-warn)' }}>
Nothing selected: this account will sign in to an empty application.
</span>
)}
</div>
{invite.isError && <p className="text-xs text-risk">{(invite.error as Error).message}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setInviteOpen(false)}
className="rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Cancel
</button>
<button
onClick={() => invite.mutate({ email, full_name: name, role, tools: newTools })}
disabled={!email.trim() || !name.trim() || invite.isPending}
className="rounded-lg bg-gradient-to-r from-blue to-navy-700 px-4 py-2 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
>
Create account
</button>
</div>
</>
)}
</div>
</Modal>
</div>
)
}