Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Settings.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 1675031db74316e100782d063e9eda1a597fe3a1 |
| Branch | workstream-a-opportunity-intelligence |
| Path or archive member | frontend/src/screens/Settings.tsx |
| Original SHA-256 | 8e2b703a99b554ad2e710a5ddb17e1baf4c17708a7884aaf894c9efac3005580 |
| Git blob SHA | 759c089a5aca4af7172c63b571b27b4250ab4dd3 |
| Reading-copy SHA-256 | 8e2b703a99b554ad2e710a5ddb17e1baf4c17708a7884aaf894c9efac3005580 |
Every recorded occurrence
These 7 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / frontend/src/screens/Settings.tsx | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
Recovered source transcript
Download complete source text ↓284 lines · 10.3 KB
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { KeyRound, LogOut, ShieldCheck, UserPlus } from 'lucide-react'
import { adminApi, ROLES, type UserSummary } from '../lib/admin'
import { authApi } from '../lib/auth'
import { useAuth } from '../lib/useAuth'
import { Badge, Panel } from '../components/ui'
const isAdmin = (role: string) => role === 'owner' || role === 'admin'
export function Settings() {
const { principal, logout } = useAuth()
return (
<div className="space-y-4">
<AccountPanel onLogout={logout} role={principal?.role ?? ''} orgID={principal?.org_id ?? ''} />
{principal && isAdmin(principal.role) && <TeamPanel meRole={principal.role} />}
<p className="px-1 text-[11px] text-faint">
Federated SSO (Login.gov, Entra Gov, PIV CAC) and public signup are a later sub phase. Invites are
delivered manually at pilot, the link is shown after you invite a teammate.
</p>
</div>
)
}
function AccountPanel({ onLogout, role, orgID }: { onLogout: () => void; role: string; orgID: string }) {
const [oldPw, setOldPw] = useState('')
const [newPw, setNewPw] = useState('')
const [msg, setMsg] = useState('')
const change = useMutation({
mutationFn: () => authApi.changePassword(oldPw, newPw),
onSuccess: () => {
setMsg('Password changed.')
setOldPw('')
setNewPw('')
},
onError: () => setMsg('Could not change the password. Check the current one.'),
})
const logoutAll = useMutation({ mutationFn: () => authApi.logoutAll() })
return (
<Panel title="Your account">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge color="var(--color-blue)" subtle>
{role || 'unknown role'}
</Badge>
<span className="text-[11px] text-faint">org {orgID.slice(0, 8)}</span>
</div>
<form
className="mt-3 max-w-sm space-y-2"
onSubmit={(e) => {
e.preventDefault()
setMsg('')
if (newPw.length < 8) {
setMsg('Use at least 8 characters.')
return
}
change.mutate()
}}
>
<div className="text-[11px] font-bold uppercase tracking-wider text-muted">Change password</div>
<label className="block">
<span className="sr-only">Current password</span>
<input
type="password"
placeholder="Current password"
value={oldPw}
maxLength={64}
onChange={(e) => setOldPw(e.target.value)}
className="w-full rounded-md border border-line/70 bg-navy-950/60 px-3 py-1.5 text-sm outline-none focus:border-blue/60"
/>
</label>
<label className="block">
<span className="sr-only">New password</span>
<input
type="password"
placeholder="New password"
value={newPw}
maxLength={64}
onChange={(e) => setNewPw(e.target.value)}
className="w-full rounded-md border border-line/70 bg-navy-950/60 px-3 py-1.5 text-sm outline-none focus:border-blue/60"
/>
</label>
{msg && <p className="text-[11px] text-muted">{msg}</p>}
<button
type="submit"
disabled={change.isPending}
className="flex items-center gap-1.5 rounded-md bg-blue/20 px-3 py-1.5 text-sm font-semibold text-blue-soft hover:bg-blue/30 disabled:opacity-50"
>
<KeyRound size={14} /> Update password
</button>
</form>
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={() => logoutAll.mutate()}
className="flex items-center gap-1.5 rounded-md border border-line/70 px-3 py-1.5 text-xs font-semibold text-muted hover:text-ink"
>
<ShieldCheck size={13} /> {logoutAll.isSuccess ? 'All sessions revoked' : 'Sign out all sessions'}
</button>
<button
onClick={onLogout}
className="flex items-center gap-1.5 rounded-md border border-line/70 px-3 py-1.5 text-xs font-semibold text-muted hover:border-risk/60 hover:text-risk"
>
<LogOut size={13} /> Sign out
</button>
</div>
</Panel>
)
}
function TeamPanel({ meRole }: { meRole: string }) {
const qc = useQueryClient()
const users = useQuery({ queryKey: ['users'], queryFn: adminApi.listUsers })
const invalidate = () => qc.invalidateQueries({ queryKey: ['users'] })
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [role, setRole] = useState('member')
const [inviteLink, setInviteLink] = useState('')
const invite = useMutation({
mutationFn: () => adminApi.invite(email.trim(), name.trim(), role),
onSuccess: (r) => {
setInviteLink(`${window.location.origin}/accept?token=${r.token}`)
setEmail('')
setName('')
invalidate()
},
})
return (
<Panel title="Team" hint="owner and admin only">
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
if (email.trim() && name.trim()) {
invite.mutate()
}
}}
>
<LabeledInput label="Email" value={email} onChange={setEmail} type="email" />
<LabeledInput label="Full name" value={name} onChange={setName} />
<div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted">Role</div>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-line/70 bg-navy-950/60 px-2 py-1.5 text-sm outline-none"
>
{ROLES.filter((r) => meRole === 'owner' || r !== 'admin').map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={invite.isPending}
className="flex items-center gap-1.5 rounded-md bg-blue/20 px-3 py-1.5 text-sm font-semibold text-blue-soft hover:bg-blue/30 disabled:opacity-50"
>
<UserPlus size={14} /> Invite
</button>
</form>
{inviteLink && (
<div className="mt-2 rounded-md bg-navy-950/60 p-2 text-[11px]">
<span className="text-faint">Send this invite link to the new teammate: </span>
<span className="break-all font-mono text-blue-soft">{inviteLink}</span>
</div>
)}
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[44rem] text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-faint">
<th className="pb-2 font-semibold">User</th>
<th className="pb-2 font-semibold">Role</th>
<th className="pb-2 font-semibold">Status</th>
<th className="pb-2 font-semibold">MFA</th>
<th className="pb-2" />
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{(users.data ?? []).map((u) => (
<UserRow key={u.id} u={u} meRole={meRole} onChanged={invalidate} />
))}
</tbody>
</table>
{users.isLoading && <p className="py-3 text-sm text-muted">Loading team…</p>}
</div>
</Panel>
)
}
function UserRow({ u, meRole, onChanged }: { u: UserSummary; meRole: string; onChanged: () => void }) {
const setRole = useMutation({ mutationFn: (role: string) => adminApi.changeRole(u.id, role), onSuccess: onChanged })
const setStatus = useMutation({
mutationFn: (status: 'active' | 'disabled') => adminApi.changeStatus(u.id, status),
onSuccess: onChanged,
})
const resetMfa = useMutation({ mutationFn: () => adminApi.resetMFA(u.id), onSuccess: onChanged })
const disabled = u.status === 'disabled'
return (
<tr className="align-middle">
<td className="py-2.5 pr-3">
<div className="font-semibold">{u.full_name}</div>
<div className="text-[11px] text-faint">{u.email}</div>
</td>
<td className="py-2.5 pr-3">
{u.role === 'owner' ? (
<Badge color="var(--color-gold)" subtle>
owner
</Badge>
) : (
<select
value={u.role}
onChange={(e) => setRole.mutate(e.target.value)}
disabled={setRole.isPending}
className="rounded-md border border-line/70 bg-navy-950/60 px-2 py-1 text-xs outline-none"
>
{ROLES.filter((r) => meRole === 'owner' || r !== 'admin').map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</td>
<td className="py-2.5 pr-3">
<Badge color={disabled ? 'var(--color-risk)' : 'var(--color-ok)'} subtle>
{u.status}
</Badge>
</td>
<td className="py-2.5 pr-3 text-[11px] text-muted">{u.mfa_enrolled ? 'enrolled' : 'not enrolled'}</td>
<td className="py-2.5 text-right">
{u.role !== 'owner' && (
<div className="inline-flex items-center gap-1.5">
<button
onClick={() => setStatus.mutate(disabled ? 'active' : 'disabled')}
className="rounded-md border border-line/70 px-2 py-1 text-[11px] font-semibold text-muted hover:text-ink"
>
{disabled ? 'Enable' : 'Disable'}
</button>
<button
onClick={() => resetMfa.mutate()}
title="Reset MFA so the user re enrolls at next login"
className="rounded-md border border-line/70 px-2 py-1 text-[11px] font-semibold text-muted hover:text-ink"
>
{resetMfa.isSuccess ? 'MFA reset' : 'Reset MFA'}
</button>
</div>
)}
</td>
</tr>
)
}
function LabeledInput({
label,
value,
onChange,
type = 'text',
}: {
label: string
value: string
onChange: (v: string) => void
type?: string
}) {
return (
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wider text-muted">{label}</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-line/70 bg-navy-950/60 px-3 py-1.5 text-sm outline-none focus:border-blue/60"
/>
</label>
)
}