Files
website/client/src/routes/admin/views/UserDetail.jsx
wtclaude f7d27f7a06 refactor(client): delete the UO client half (phase 3, slice 3)
35 files and 5,332 lines out — twelve public pages, seven admin views, two
player views, eight components, the two `data/` leaves and the three `lib/`
ones, plus the two tests that came with them. §2.7.1's estimate of 51 files /
~3,700 lines was measured differently and is corrected in the docs PR.

The seams core keeps, each smaller than what it replaced:

Nine rows leave the public header and six leave the admin sidebar, and both
lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already
handled a module page's heading, so the six TITLES entries and the
`/admin/characters` branch of `sectionTitle` simply go.

`/player` had `PlayerCharacters` as its index — a UO page — and rather than name
a replacement or invent a landing screen it now resolves to the first row of the
portal nav this viewer can reach (`firstDestinationFor`, beside
`allowedPathsFor` and reading the BASE nav for the same reason: an override is
presentation and where everybody lands is behaviour). With the module installed
that is still Characters, so a player's first screen after signing in does not
change. Deliberately generic and deliberately not in the portal layout — the
admin index is the same question with a hardcoded answer, and if the two
logged-in areas ever become one this is what serves both.

`game_account_signup` goes with the rest of core's UO prose: the mode list, the
derived public flag, the validation and a Site Settings field whose help text
named Bridge.cfg. The row itself is untouched and module-uo reads it through
ctx.settings — the data stays, the semantics move.

KNOWN BREAK, accepted by the org lead: the shipped Android app reads
`gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a
`= false` default so nothing crashes; the app silently stops offering
game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope here, recorded in the Android plan, and it lands well
before this workstream's cutover reaches `main`.

620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged;
routes.guards unchanged. The OpenAPI spec loses exactly one property, and only
because it was hand-written in swagger.js — regeneration alone would have left
the spec documenting a field core no longer returns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:39:55 -05:00

181 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import Slot from '../../../modules/Slot.jsx'
// Admin view of one user: who they are, their security posture (trusted devices
// and MFA), and then whatever the installed module contributes about them —
// today core's own UO footprint, via the `admin.users.detail` extension slot
// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
// a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
editor: 'badge-editor',
moderator: 'badge-moderator',
player: 'badge-player',
}
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this users trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this users two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Security &amp; two-factor</SectionTitle>
{devices == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : devices.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
) : (
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map((d) => (
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{devices && devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke all trusted devices
</button>
)}
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
Reset two-factor
</button>
</div>
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section>
)
}
export default function UserDetail() {
const { id } = useParams()
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this user." />
return (
<section>
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to users
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{user.username}
</span>
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
<span
className="sans"
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
>
{user.status || 'active'}
</span>
</div>
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
{user.email && <span>{user.email}</span>}
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
</div>
</div>
<SecurityAdmin userId={id} />
{/* Whatever the installed module has to say about this user, or nothing
at all — core filled this with its own UO sections until Phase 3 slice
3, and now nothing does unless a module is installed
(MODULE_API.md §3.7). */}
<Slot name="admin.users.detail" userId={id} />
</section>
)
}