Core declares site.footer.status and admin.users.detail in main.jsx and fills both itself, under owner id `core` -- the client twin of registries.registerCore() and the same trick useShardFlags already uses. The rendered page is unchanged; what changes is that the content now arrives the way a module's will. The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six UO sections become UserShardSections.jsx. Both are files rather than inline markup so that the client half of phase 3 deletes a registration and a file instead of editing a core page under extraction pressure -- which is also what proves the mechanism before anything depends on it. The user-detail slot is handed userId and not scope. api.admin.userShard is a UO binding that leaves core with the client half, so a slot passing it would hand a module something core is about to delete; an extension builds its own client for the routes it registered at the other end. Core's own fill now does exactly what the module will. Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway module fills both slots and renders its own label and target in the footer with core's linkStyle, and receives userId on the admin page; a deliberate render failure is contained to that one spot with the slot named in the console; core's own fills leave the pages byte-identical to before; and with no module installed both slots render nothing. Zero CSP reports throughout. Co-Authored-By: Claude <noreply@anthropic.com>
180 lines
6.9 KiB
JavaScript
180 lines
6.9 KiB
JavaScript
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 user’s 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 user’s 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 & 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's own UO sections fill it today (UserShardSections.jsx,
|
||
registered in main.jsx) — MODULE_API.md §3.7. */}
|
||
<Slot name="admin.users.detail" userId={id} />
|
||
</section>
|
||
)
|
||
}
|