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 | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/Settings.tsx |
| Original SHA-256 | a6ecb51138ddebb6275d6b523ff98617dcbb394ec5ae4786647975039f7e095d |
| Git blob SHA | c85d1282830f3a4395ec73d90eeff54f7033108a |
| Reading-copy SHA-256 | a6ecb51138ddebb6275d6b523ff98617dcbb394ec5ae4786647975039f7e095d |
Recovered source transcript
Download complete source text ↓258 lines · 10.6 KB
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { KeyRound, LogOut, ShieldCheck, UserCog } from 'lucide-react'
import { Link } from 'react-router-dom'
import { authApi } from '../lib/auth'
import { useAuth } from '../lib/useAuth'
import { CAP, can } from '../lib/can'
import { TimeZonePanel } from './TimeZonePanel'
import {
InviteDefaultsPanel,
MailPanel,
RecordScopePanel,
SettingsLoadFailure,
WhoSeesWhatPanel,
WrapRatePanel,
AedtNoticePanel,
} from './OrgSettingsPanels'
import { Badge, Panel } from '../components/ui'
export function Settings() {
const { principal, logout } = useAuth()
return (
<div className="space-y-4">
{/* SELF SERVICE PROFILE, amendment 6 decision 4, Track U item 2. It sits FIRST and above the
password form because it is the thing a person most often comes here to do, and because the
defect this closes was that there was no path at all: a name was written once at invite
acceptance and never again, by anyone, so correcting a typo escalated to the top of the ladder
and failed there too. Every role sees this panel; editing your own name is not an act of
authority, so there is no capability check around it. */}
<ProfilePanel />
<AccountPanel onLogout={logout} role={principal?.role ?? ''} orgID={principal?.org_id ?? ''} />
{/* Account management lives on ONE screen. Settings used to carry its own Team panel: a second
user list on a different query key, whose invite sent no tool set and so created accounts
entitled to nothing, and whose role changes did not invalidate the other screen. Two surfaces
for the same thing disagreed about what access a person had, which is the worst possible thing
for an access control screen to be ambiguous about. */}
{can(principal, CAP.userInvite) && (
<Panel title="Accounts and access" hint="people, roles, and tools">
<p className="text-sm text-muted">
Create accounts, set roles, and choose which tools each person can see.
</p>
<Link
to="/admin"
className="mt-3 inline-flex rounded-lg border border-line/70 px-3 py-2 text-sm font-semibold text-muted hover:text-ink"
>
Open accounts and access
</Link>
</Panel>
)}
{/* Owner only: the server refuses these for anyone else, so offering them would be buttons that fail.
The order is the order an owner sets a company up in. Mail comes first because it is the one that
unblocks everything else: until a message can leave, an invitation is a link somebody has to copy
into their own mail client by hand. Then who sees what, then which records they see at all, then
the defaults nobody thinks about until one of them is wrong.
The TIME ZONE stays where it was, in its own panel, and is deliberately not repeated here. It lives
on the organization and drives the task queue, the daily report and every period rollup. A second
control would let somebody set a working day that the rest of the product ignores. */}
{can(principal, CAP.orgSettings) && (
<>
<SettingsLoadFailure />
<MailPanel />
<WhoSeesWhatPanel />
<RecordScopePanel />
<WrapRatePanel />
<AedtNoticePanel />
<TimeZonePanel />
<InviteDefaultsPanel />
</>
)}
<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>
)
}
const fieldCls =
'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'
// ProfilePanel is the self service identity edit, available to every role on their OWN account.
//
// The server takes the subject from the session, never from this form, so there is no user id in the
// request and no way to aim it at another account. On success the session is refreshed rather than the
// local state patched, so the new name comes back from the server and renders everywhere the identity
// renders (the shell header, the accounts list) instead of only in the box that was typed into.
function ProfilePanel() {
const { principal, refresh } = useAuth()
const [fullName, setFullName] = useState('')
const [title, setTitle] = useState('')
const [phone, setPhone] = useState('')
const [msg, setMsg] = useState('')
// Seeded from the session once it resolves. Without this the fields render empty on first paint and an
// unedited save would read as blanking a title the person already had.
useEffect(() => {
setFullName(principal?.full_name ?? '')
setTitle(principal?.title ?? '')
setPhone(principal?.phone ?? '')
}, [principal?.full_name, principal?.title, principal?.phone])
const save = useMutation({
mutationFn: () => authApi.updateOwnProfile({ full_name: fullName, title, phone }),
onSuccess: async () => {
setMsg('Saved. Your name now shows everywhere you appear.')
await refresh()
},
onError: (e: Error) => setMsg(e.message || 'Could not save your profile.'),
})
const dirty =
fullName !== (principal?.full_name ?? '') ||
title !== (principal?.title ?? '') ||
phone !== (principal?.phone ?? '')
return (
<Panel title="Your profile" hint="you can change these yourself">
<form
className="max-w-md space-y-2.5"
onSubmit={(e) => {
e.preventDefault()
setMsg('')
if (!fullName.trim()) {
setMsg('A name is required.')
return
}
save.mutate()
}}
>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Full name</span>
<input value={fullName} maxLength={120} onChange={(e) => setFullName(e.target.value)} className={fieldCls} />
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Title</span>
<input
value={title}
maxLength={120}
placeholder="Director of Recruiting"
onChange={(e) => setTitle(e.target.value)}
className={fieldCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">Phone</span>
<input
value={phone}
maxLength={40}
placeholder="417 555 0134"
onChange={(e) => setPhone(e.target.value)}
className={fieldCls}
/>
</label>
<p className="text-[11px] text-faint">
Your email address is your sign in and is not changed here. Ask an administrator to change it.
</p>
{msg && <p className="text-[11px] text-muted">{msg}</p>}
<button
type="submit"
disabled={save.isPending || !dirty}
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"
>
<UserCog size={14} /> {save.isPending ? 'Saving…' : 'Save profile'}
</button>
</form>
</Panel>
)
}
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>
)
}