UserDetailPage.tsx 3.68 KB
// User detail page with role assignment.
//
// Displays user info and lets an admin assign/revoke roles in one
// click. The role changes take effect on the user's NEXT login
// (the JWT carries the roles claim from login time).

import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { identity } from '@/api/client'
import type { Role, User } from '@/types/api'
import { PageHeader } from '@/components/PageHeader'
import { Loading } from '@/components/Loading'
import { ErrorBox } from '@/components/ErrorBox'

export function UserDetailPage() {
  const { id = '' } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [user, setUser] = useState<User | null>(null)
  const [allRoles, setAllRoles] = useState<Role[]>([])
  const [userRoleCodes, setUserRoleCodes] = useState<string[]>([])
  const [loading, setLoading] = useState(true)
  const [acting, setActing] = useState(false)
  const [error, setError] = useState<Error | null>(null)

  const refresh = useCallback(async () => {
    const [u, roles, uRoles] = await Promise.all([
      identity.getUser(id),
      identity.listRoles(),
      identity.getUserRoles(id),
    ])
    setUser(u)
    setAllRoles(roles)
    setUserRoleCodes(uRoles)
  }, [id])

  useEffect(() => {
    refresh()
      .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
      .finally(() => setLoading(false))
  }, [refresh])

  const toggle = async (roleCode: string, has: boolean) => {
    setActing(true)
    setError(null)
    try {
      if (has) {
        await identity.revokeRole(id, roleCode)
      } else {
        await identity.assignRole(id, roleCode)
      }
      await refresh()
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  if (loading) return <Loading />
  if (error && !user) return <ErrorBox error={error} />
  if (!user) return <ErrorBox error="User not found" />

  return (
    <div>
      <PageHeader
        title={user.displayName}
        subtitle={`@${user.username} · ${user.enabled ? 'Active' : 'Disabled'}${user.email ? ' · ' + user.email : ''}`}
        actions={
          <button className="btn-secondary" onClick={() => navigate('/users')}>
            ← Back
          </button>
        }
      />

      {error && <ErrorBox error={error} />}

      <div className="card p-5 max-w-lg">
        <h2 className="mb-3 text-base font-semibold text-slate-800">Roles</h2>
        <p className="mb-4 text-xs text-slate-400">
          Toggle roles on/off. Changes take effect on the user's next login.
        </p>
        {allRoles.length === 0 && (
          <p className="text-sm text-slate-400">No roles defined yet. Create one on the Roles page.</p>
        )}
        <div className="space-y-2">
          {allRoles.map((role) => {
            const has = userRoleCodes.includes(role.code)
            return (
              <div
                key={role.id}
                className="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2"
              >
                <div>
                  <span className="font-mono text-sm font-medium text-slate-700">{role.code}</span>
                  <span className="ml-2 text-xs text-slate-400">{role.name}</span>
                </div>
                <button
                  className={has ? 'btn-danger text-xs' : 'btn-primary text-xs'}
                  disabled={acting}
                  onClick={() => toggle(role.code, has)}
                >
                  {has ? 'Revoke' : 'Assign'}
                </button>
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}