feat(auth): Active Devices — view/revoke mobile sessions
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.
- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
additive via the ALTER section; seeded to now on insert). With single-use
rotation each login/refresh inserts a fresh row, so the active row's timestamp
is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
/auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
device out), plus the PlayerLogin change to honor the mobile SSO bridge's
{ redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
server suite green (274); client builds.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,10 @@ export const api = {
|
||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||
authProviders: () => req('/auth/providers'),
|
||||
// Active mobile device sessions (role-agnostic self-service under /auth/me).
|
||||
// List the active ones and revoke a single device by its session id.
|
||||
mySessions: () => req('/auth/me/sessions'),
|
||||
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
|
||||
@@ -295,6 +295,73 @@ function LinkedAccounts() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Active mobile device sessions ──────────────────────────────────────────
|
||||
function ActiveDevices() {
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSessions(await api.mySessions())
|
||||
} catch {
|
||||
setError('Could not load your devices.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function revoke(id) {
|
||||
if (!window.confirm('Sign this device out? It will need to sign in again.')) return
|
||||
setBusyId(id)
|
||||
try {
|
||||
await api.revokeMySession(id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not sign that device out.')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (d) => {
|
||||
const t = d ? new Date(d) : null
|
||||
return t && !Number.isNaN(t.getTime()) ? t.toLocaleString() : '—'
|
||||
}
|
||||
|
||||
if (error) return (
|
||||
<Section title="Active devices"><ErrorState message={error} /></Section>
|
||||
)
|
||||
if (!sessions) return null
|
||||
|
||||
return (
|
||||
<Section title="Active devices">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Devices signed in to the mobile app. Sign one out to revoke its access — it may keep working for
|
||||
a few minutes until its current token expires.
|
||||
</p>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No mobile devices are signed in.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{sessions.map((s) => (
|
||||
<div key={s.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' }}>
|
||||
{s.deviceName || s.userAgent || 'Mobile device'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem' }}>Last active {fmt(s.lastUsedAt)}</div>
|
||||
</div>
|
||||
<button onClick={() => revoke(s.id)} disabled={busyId === s.id} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
{busyId === s.id ? 'Signing out…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
@@ -347,6 +414,7 @@ export default function PlayerAccount() {
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
<ActiveDevices />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,14 @@ export default function PlayerLogin() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo } = await ssoLoginTotp(code)
|
||||
const { returnTo, redirect } = await ssoLoginTotp(code)
|
||||
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute
|
||||
// deep link (e.g. runicgateway://…) to hand the app its one-time code.
|
||||
// React Router can't navigate a custom scheme, so leave the SPA for it.
|
||||
if (redirect) {
|
||||
window.location.href = redirect
|
||||
return
|
||||
}
|
||||
navigate(returnTo || '/account', { replace: true })
|
||||
} else {
|
||||
const u = await loginTotp(challenge, code)
|
||||
|
||||
Reference in New Issue
Block a user