Commit 82c5267dfaef573c2df388553ea0bbea98cb3b06

Authored by zichun
1 parent 25353240

feat(identity+web): R2 identity screens — users, roles, role assignment

Closes the R2 gap: an admin can now manage users and roles
entirely from the SPA without touching curl or Swagger UI.

Backend (pbc-identity):
  - New RoleService with createRole, assignRole, revokeRole,
    findUserRoleCodes, listRoles. Each method validates
    existence + idempotency (duplicate assignment rejected,
    missing role rejected).
  - New RoleController at /api/v1/identity/roles (CRUD) +
    /api/v1/identity/users/{userId}/roles/{roleCode}
    (POST assign, DELETE revoke). All permission-gated:
    identity.role.read, identity.role.create,
    identity.role.assign.
  - identity.yml updated: added identity.role.create permission.

SPA (web/):
  - UsersPage — list with username link to detail, "+ New User"
  - CreateUserPage — username, display name, email form
  - UserDetailPage — shows user info + role toggle list. Each
    role has an Assign/Revoke button that takes effect on the
    user's next login (JWT carries roles from login time).
  - RolesPage — list with inline create form (code + name)
  - Sidebar gains "System" section with Users + Roles links
  - API client + types: identity.listUsers, getUser, createUser,
    listRoles, createRole, getUserRoles, assignRole, revokeRole

Infrastructure:
  - SpaController: added /users/** and /roles/** forwarding
  - SecurityConfiguration: added /users/** and /roles/** to the
    SPA permitAll block
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.identity.domain.Role
  6 +import org.vibeerp.pbc.identity.domain.UserRole
  7 +import org.vibeerp.pbc.identity.infrastructure.RoleJpaRepository
  8 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  9 +import org.vibeerp.pbc.identity.infrastructure.UserRoleJpaRepository
  10 +import java.util.UUID
  11 +
  12 +@Service
  13 +@Transactional
  14 +class RoleService(
  15 + private val roles: RoleJpaRepository,
  16 + private val users: UserJpaRepository,
  17 + private val userRoles: UserRoleJpaRepository,
  18 +) {
  19 +
  20 + @Transactional(readOnly = true)
  21 + fun listRoles(): List<Role> = roles.findAll()
  22 +
  23 + @Transactional(readOnly = true)
  24 + fun findRoleByCode(code: String): Role? = roles.findByCode(code)
  25 +
  26 + fun createRole(command: CreateRoleCommand): Role {
  27 + require(!roles.existsByCode(command.code)) {
  28 + "role code '${command.code}' already exists"
  29 + }
  30 + return roles.save(
  31 + Role(
  32 + code = command.code,
  33 + name = command.name,
  34 + description = command.description,
  35 + ),
  36 + )
  37 + }
  38 +
  39 + @Transactional(readOnly = true)
  40 + fun findUserRoleCodes(userId: UUID): List<String> =
  41 + userRoles.findRoleCodesByUserId(userId)
  42 +
  43 + fun assignRole(userId: UUID, roleCode: String) {
  44 + val user = users.findById(userId).orElseThrow {
  45 + NoSuchElementException("user not found: $userId")
  46 + }
  47 + val role = roles.findByCode(roleCode)
  48 + ?: throw NoSuchElementException("role not found: $roleCode")
  49 +
  50 + require(!userRoles.existsByUserIdAndRoleId(user.id, role.id)) {
  51 + "user '${user.username}' already has role '$roleCode'"
  52 + }
  53 + userRoles.save(UserRole(userId = user.id, roleId = role.id))
  54 + }
  55 +
  56 + fun revokeRole(userId: UUID, roleCode: String) {
  57 + val role = roles.findByCode(roleCode)
  58 + ?: throw NoSuchElementException("role not found: $roleCode")
  59 +
  60 + val assignments = userRoles.findByUserId(userId)
  61 + val match = assignments.find { it.roleId == role.id }
  62 + ?: throw NoSuchElementException("user $userId does not have role '$roleCode'")
  63 +
  64 + userRoles.delete(match)
  65 + }
  66 +}
  67 +
  68 +data class CreateRoleCommand(
  69 + val code: String,
  70 + val name: String,
  71 + val description: String? = null,
  72 +)
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.Size
  6 +import org.springframework.http.HttpStatus
  7 +import org.springframework.web.bind.annotation.GetMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PostMapping
  10 +import org.springframework.web.bind.annotation.DeleteMapping
  11 +import org.springframework.web.bind.annotation.RequestBody
  12 +import org.springframework.web.bind.annotation.RequestMapping
  13 +import org.springframework.web.bind.annotation.ResponseStatus
  14 +import org.springframework.web.bind.annotation.RestController
  15 +import org.vibeerp.pbc.identity.application.CreateRoleCommand
  16 +import org.vibeerp.pbc.identity.application.RoleService
  17 +import org.vibeerp.pbc.identity.domain.Role
  18 +import org.vibeerp.platform.security.authz.RequirePermission
  19 +import java.util.UUID
  20 +
  21 +/**
  22 + * REST API for role management and user-role assignment.
  23 + *
  24 + * Mounted at `/api/v1/identity/roles`. The role CRUD endpoints
  25 + * let the SPA's identity admin screen create and list roles.
  26 + * The nested `/users/{userId}/roles` endpoints let an admin
  27 + * assign and revoke roles for a specific user.
  28 + */
  29 +@RestController
  30 +@RequestMapping("/api/v1/identity")
  31 +class RoleController(
  32 + private val roleService: RoleService,
  33 +) {
  34 +
  35 + @GetMapping("/roles")
  36 + @RequirePermission("identity.role.read")
  37 + fun listRoles(): List<RoleResponse> =
  38 + roleService.listRoles().map { it.toResponse() }
  39 +
  40 + @PostMapping("/roles")
  41 + @ResponseStatus(HttpStatus.CREATED)
  42 + @RequirePermission("identity.role.create")
  43 + fun createRole(@RequestBody @Valid request: CreateRoleRequest): RoleResponse =
  44 + roleService.createRole(
  45 + CreateRoleCommand(
  46 + code = request.code,
  47 + name = request.name,
  48 + description = request.description,
  49 + ),
  50 + ).toResponse()
  51 +
  52 + @GetMapping("/users/{userId}/roles")
  53 + @RequirePermission("identity.user.read")
  54 + fun listUserRoles(@PathVariable userId: UUID): List<String> =
  55 + roleService.findUserRoleCodes(userId)
  56 +
  57 + @PostMapping("/users/{userId}/roles/{roleCode}")
  58 + @ResponseStatus(HttpStatus.NO_CONTENT)
  59 + @RequirePermission("identity.role.assign")
  60 + fun assignRole(
  61 + @PathVariable userId: UUID,
  62 + @PathVariable roleCode: String,
  63 + ) {
  64 + roleService.assignRole(userId, roleCode)
  65 + }
  66 +
  67 + @DeleteMapping("/users/{userId}/roles/{roleCode}")
  68 + @ResponseStatus(HttpStatus.NO_CONTENT)
  69 + @RequirePermission("identity.role.assign")
  70 + fun revokeRole(
  71 + @PathVariable userId: UUID,
  72 + @PathVariable roleCode: String,
  73 + ) {
  74 + roleService.revokeRole(userId, roleCode)
  75 + }
  76 +}
  77 +
  78 +// ─── DTOs ────────────────────────────────────────────────────────────
  79 +
  80 +data class CreateRoleRequest(
  81 + @field:NotBlank @field:Size(max = 64) val code: String,
  82 + @field:NotBlank @field:Size(max = 256) val name: String,
  83 + val description: String? = null,
  84 +)
  85 +
  86 +data class RoleResponse(
  87 + val id: UUID,
  88 + val code: String,
  89 + val name: String,
  90 + val description: String?,
  91 +)
  92 +
  93 +private fun Role.toResponse() = RoleResponse(
  94 + id = this.id,
  95 + code = this.code,
  96 + name = this.name,
  97 + description = this.description,
  98 +)
... ...
pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml
... ... @@ -28,8 +28,10 @@ permissions:
28 28 description: Disable a user (soft-delete; row is preserved for audit)
29 29 - key: identity.role.read
30 30 description: Read role records
  31 + - key: identity.role.create
  32 + description: Create new roles
31 33 - key: identity.role.assign
32   - description: Assign a role to a user
  34 + description: Assign or revoke a role for a user
33 35  
34 36 menus:
35 37 - path: /identity/users
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
... ... @@ -83,6 +83,10 @@ class SpaController {
83 83  
84 84 // Finance
85 85 "/journal-entries", "/journal-entries/", "/journal-entries/**",
  86 +
  87 + // System / identity
  88 + "/users", "/users/", "/users/**",
  89 + "/roles", "/roles/", "/roles/**",
86 90 ],
87 91 )
88 92 fun spa(): String = "forward:/index.html"
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
... ... @@ -110,6 +110,8 @@ class SecurityConfiguration {
110 110 "/work-orders", "/work-orders/**",
111 111 "/shop-floor", "/shop-floor/**",
112 112 "/journal-entries", "/journal-entries/**",
  113 + "/users", "/users/**",
  114 + "/roles", "/roles/**",
113 115 ).permitAll()
114 116  
115 117 // Anything else — return 401 so a typoed deep link
... ...
web/src/App.tsx
... ... @@ -13,6 +13,10 @@ import { AppLayout } from &#39;@/layout/AppLayout&#39;
13 13 import { ProtectedRoute } from '@/components/ProtectedRoute'
14 14 import { LoginPage } from '@/pages/LoginPage'
15 15 import { DashboardPage } from '@/pages/DashboardPage'
  16 +import { UsersPage } from '@/pages/UsersPage'
  17 +import { CreateUserPage } from '@/pages/CreateUserPage'
  18 +import { UserDetailPage } from '@/pages/UserDetailPage'
  19 +import { RolesPage } from '@/pages/RolesPage'
16 20 import { ItemsPage } from '@/pages/ItemsPage'
17 21 import { CreateItemPage } from '@/pages/CreateItemPage'
18 22 import { UomsPage } from '@/pages/UomsPage'
... ... @@ -46,6 +50,10 @@ export default function App() {
46 50 }
47 51 >
48 52 <Route index element={<DashboardPage />} />
  53 + <Route path="users" element={<UsersPage />} />
  54 + <Route path="users/new" element={<CreateUserPage />} />
  55 + <Route path="users/:id" element={<UserDetailPage />} />
  56 + <Route path="roles" element={<RolesPage />} />
49 57 <Route path="items" element={<ItemsPage />} />
50 58 <Route path="items/new" element={<CreateItemPage />} />
51 59 <Route path="uoms" element={<UomsPage />} />
... ...
web/src/api/client.ts
... ... @@ -31,12 +31,14 @@ import type {
31 31 MetaInfo,
32 32 Partner,
33 33 PurchaseOrder,
  34 + Role,
34 35 SalesOrder,
35 36 ShopFloorEntry,
36 37 StockBalance,
37 38 StockMovement,
38 39 TokenPair,
39 40 Uom,
  41 + User,
40 42 WorkOrder,
41 43 } from '@/types/api'
42 44  
... ... @@ -131,6 +133,25 @@ export const auth = {
131 133 }),
132 134 }
133 135  
  136 +// ─── Identity ────────────────────────────────────────────────────────
  137 +
  138 +export const identity = {
  139 + listUsers: () => apiFetch<User[]>('/api/v1/identity/users'),
  140 + getUser: (id: string) => apiFetch<User>(`/api/v1/identity/users/${id}`),
  141 + createUser: (body: {
  142 + username: string; displayName: string; email?: string | null
  143 + }) => apiFetch<User>('/api/v1/identity/users', { method: 'POST', body: JSON.stringify(body) }),
  144 + listRoles: () => apiFetch<Role[]>('/api/v1/identity/roles'),
  145 + createRole: (body: {
  146 + code: string; name: string; description?: string | null
  147 + }) => apiFetch<Role>('/api/v1/identity/roles', { method: 'POST', body: JSON.stringify(body) }),
  148 + getUserRoles: (userId: string) => apiFetch<string[]>(`/api/v1/identity/users/${userId}/roles`),
  149 + assignRole: (userId: string, roleCode: string) =>
  150 + apiFetch<void>(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'POST' }, false),
  151 + revokeRole: (userId: string, roleCode: string) =>
  152 + apiFetch<void>(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'DELETE' }, false),
  153 +}
  154 +
134 155 // ─── Catalog ─────────────────────────────────────────────────────────
135 156  
136 157 export const catalog = {
... ...
web/src/layout/AppLayout.tsx
... ... @@ -65,6 +65,13 @@ const NAV: NavGroup[] = [
65 65 heading: 'Finance',
66 66 items: [{ to: '/journal-entries', label: 'Journal Entries' }],
67 67 },
  68 + {
  69 + heading: 'System',
  70 + items: [
  71 + { to: '/users', label: 'Users' },
  72 + { to: '/roles', label: 'Roles' },
  73 + ],
  74 + },
68 75 ]
69 76  
70 77 export function AppLayout() {
... ...
web/src/pages/CreateUserPage.tsx 0 → 100644
  1 +import { useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { identity } from '@/api/client'
  4 +import { PageHeader } from '@/components/PageHeader'
  5 +import { ErrorBox } from '@/components/ErrorBox'
  6 +
  7 +export function CreateUserPage() {
  8 + const navigate = useNavigate()
  9 + const [username, setUsername] = useState('')
  10 + const [displayName, setDisplayName] = useState('')
  11 + const [email, setEmail] = useState('')
  12 + const [submitting, setSubmitting] = useState(false)
  13 + const [error, setError] = useState<Error | null>(null)
  14 +
  15 + const onSubmit = async (e: FormEvent) => {
  16 + e.preventDefault()
  17 + setError(null)
  18 + setSubmitting(true)
  19 + try {
  20 + const user = await identity.createUser({
  21 + username, displayName, email: email || null,
  22 + })
  23 + navigate(`/users/${user.id}`)
  24 + } catch (err: unknown) {
  25 + setError(err instanceof Error ? err : new Error(String(err)))
  26 + } finally {
  27 + setSubmitting(false)
  28 + }
  29 + }
  30 +
  31 + return (
  32 + <div>
  33 + <PageHeader
  34 + title="New User"
  35 + subtitle="Create a user account. Assign roles on the detail page after creation."
  36 + actions={<button className="btn-secondary" onClick={() => navigate('/users')}>Cancel</button>}
  37 + />
  38 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-lg">
  39 + <div>
  40 + <label className="block text-sm font-medium text-slate-700">Username</label>
  41 + <input type="text" required value={username} onChange={(e) => setUsername(e.target.value)}
  42 + placeholder="jdoe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  43 + </div>
  44 + <div>
  45 + <label className="block text-sm font-medium text-slate-700">Display name</label>
  46 + <input type="text" required value={displayName} onChange={(e) => setDisplayName(e.target.value)}
  47 + placeholder="Jane Doe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  48 + </div>
  49 + <div>
  50 + <label className="block text-sm font-medium text-slate-700">Email (optional)</label>
  51 + <input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
  52 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  53 + </div>
  54 + {error && <ErrorBox error={error} />}
  55 + <button type="submit" className="btn-primary" disabled={submitting}>
  56 + {submitting ? 'Creating...' : 'Create User'}
  57 + </button>
  58 + </form>
  59 + </div>
  60 + )
  61 +}
... ...
web/src/pages/RolesPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { identity } from '@/api/client'
  3 +import type { Role } from '@/types/api'
  4 +import { PageHeader } from '@/components/PageHeader'
  5 +import { Loading } from '@/components/Loading'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +import { DataTable, type Column } from '@/components/DataTable'
  8 +
  9 +export function RolesPage() {
  10 + const [rows, setRows] = useState<Role[]>([])
  11 + const [error, setError] = useState<Error | null>(null)
  12 + const [loading, setLoading] = useState(true)
  13 + const [showCreate, setShowCreate] = useState(false)
  14 + const [code, setCode] = useState('')
  15 + const [name, setName] = useState('')
  16 + const [creating, setCreating] = useState(false)
  17 +
  18 + const load = () => {
  19 + identity
  20 + .listRoles()
  21 + .then(setRows)
  22 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  23 + .finally(() => setLoading(false))
  24 + }
  25 +
  26 + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
  27 +
  28 + const onCreate = async (e: FormEvent) => {
  29 + e.preventDefault()
  30 + setCreating(true)
  31 + setError(null)
  32 + try {
  33 + await identity.createRole({ code, name })
  34 + setCode('')
  35 + setName('')
  36 + setShowCreate(false)
  37 + load()
  38 + } catch (err: unknown) {
  39 + setError(err instanceof Error ? err : new Error(String(err)))
  40 + } finally {
  41 + setCreating(false)
  42 + }
  43 + }
  44 +
  45 + const columns: Column<Role>[] = [
  46 + { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> },
  47 + { header: 'Name', key: 'name' },
  48 + { header: 'Description', key: 'description', render: (r) => r.description ?? '—' },
  49 + ]
  50 +
  51 + return (
  52 + <div>
  53 + <PageHeader
  54 + title="Roles"
  55 + subtitle="Named bundles of permissions. The 'admin' role has all permissions by default."
  56 + actions={
  57 + <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
  58 + {showCreate ? 'Cancel' : '+ New Role'}
  59 + </button>
  60 + }
  61 + />
  62 + {showCreate && (
  63 + <form onSubmit={onCreate} className="card p-4 mb-4 max-w-lg flex items-end gap-3">
  64 + <div className="flex-1">
  65 + <label className="block text-xs font-medium text-slate-700">Code</label>
  66 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  67 + placeholder="sales-clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  68 + </div>
  69 + <div className="flex-1">
  70 + <label className="block text-xs font-medium text-slate-700">Name</label>
  71 + <input type="text" required value={name} onChange={(e) => setName(e.target.value)}
  72 + placeholder="Sales Clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  73 + </div>
  74 + <button type="submit" className="btn-primary" disabled={creating}>
  75 + {creating ? '...' : 'Create'}
  76 + </button>
  77 + </form>
  78 + )}
  79 + {loading && <Loading />}
  80 + {error && <ErrorBox error={error} />}
  81 + {!loading && !error && <DataTable rows={rows} columns={columns} />}
  82 + </div>
  83 + )
  84 +}
... ...
web/src/pages/UserDetailPage.tsx 0 → 100644
  1 +// User detail page with role assignment.
  2 +//
  3 +// Displays user info and lets an admin assign/revoke roles in one
  4 +// click. The role changes take effect on the user's NEXT login
  5 +// (the JWT carries the roles claim from login time).
  6 +
  7 +import { useCallback, useEffect, useState } from 'react'
  8 +import { useNavigate, useParams } from 'react-router-dom'
  9 +import { identity } from '@/api/client'
  10 +import type { Role, User } from '@/types/api'
  11 +import { PageHeader } from '@/components/PageHeader'
  12 +import { Loading } from '@/components/Loading'
  13 +import { ErrorBox } from '@/components/ErrorBox'
  14 +
  15 +export function UserDetailPage() {
  16 + const { id = '' } = useParams<{ id: string }>()
  17 + const navigate = useNavigate()
  18 + const [user, setUser] = useState<User | null>(null)
  19 + const [allRoles, setAllRoles] = useState<Role[]>([])
  20 + const [userRoleCodes, setUserRoleCodes] = useState<string[]>([])
  21 + const [loading, setLoading] = useState(true)
  22 + const [acting, setActing] = useState(false)
  23 + const [error, setError] = useState<Error | null>(null)
  24 +
  25 + const refresh = useCallback(async () => {
  26 + const [u, roles, uRoles] = await Promise.all([
  27 + identity.getUser(id),
  28 + identity.listRoles(),
  29 + identity.getUserRoles(id),
  30 + ])
  31 + setUser(u)
  32 + setAllRoles(roles)
  33 + setUserRoleCodes(uRoles)
  34 + }, [id])
  35 +
  36 + useEffect(() => {
  37 + refresh()
  38 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  39 + .finally(() => setLoading(false))
  40 + }, [refresh])
  41 +
  42 + const toggle = async (roleCode: string, has: boolean) => {
  43 + setActing(true)
  44 + setError(null)
  45 + try {
  46 + if (has) {
  47 + await identity.revokeRole(id, roleCode)
  48 + } else {
  49 + await identity.assignRole(id, roleCode)
  50 + }
  51 + await refresh()
  52 + } catch (e: unknown) {
  53 + setError(e instanceof Error ? e : new Error(String(e)))
  54 + } finally {
  55 + setActing(false)
  56 + }
  57 + }
  58 +
  59 + if (loading) return <Loading />
  60 + if (error && !user) return <ErrorBox error={error} />
  61 + if (!user) return <ErrorBox error="User not found" />
  62 +
  63 + return (
  64 + <div>
  65 + <PageHeader
  66 + title={user.displayName}
  67 + subtitle={`@${user.username} · ${user.enabled ? 'Active' : 'Disabled'}${user.email ? ' · ' + user.email : ''}`}
  68 + actions={
  69 + <button className="btn-secondary" onClick={() => navigate('/users')}>
  70 + ← Back
  71 + </button>
  72 + }
  73 + />
  74 +
  75 + {error && <ErrorBox error={error} />}
  76 +
  77 + <div className="card p-5 max-w-lg">
  78 + <h2 className="mb-3 text-base font-semibold text-slate-800">Roles</h2>
  79 + <p className="mb-4 text-xs text-slate-400">
  80 + Toggle roles on/off. Changes take effect on the user's next login.
  81 + </p>
  82 + {allRoles.length === 0 && (
  83 + <p className="text-sm text-slate-400">No roles defined yet. Create one on the Roles page.</p>
  84 + )}
  85 + <div className="space-y-2">
  86 + {allRoles.map((role) => {
  87 + const has = userRoleCodes.includes(role.code)
  88 + return (
  89 + <div
  90 + key={role.id}
  91 + className="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2"
  92 + >
  93 + <div>
  94 + <span className="font-mono text-sm font-medium text-slate-700">{role.code}</span>
  95 + <span className="ml-2 text-xs text-slate-400">{role.name}</span>
  96 + </div>
  97 + <button
  98 + className={has ? 'btn-danger text-xs' : 'btn-primary text-xs'}
  99 + disabled={acting}
  100 + onClick={() => toggle(role.code, has)}
  101 + >
  102 + {has ? 'Revoke' : 'Assign'}
  103 + </button>
  104 + </div>
  105 + )
  106 + })}
  107 + </div>
  108 + </div>
  109 + </div>
  110 + )
  111 +}
... ...
web/src/pages/UsersPage.tsx 0 → 100644
  1 +import { useEffect, useState } from 'react'
  2 +import { Link } from 'react-router-dom'
  3 +import { identity } from '@/api/client'
  4 +import type { User } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { Loading } from '@/components/Loading'
  7 +import { ErrorBox } from '@/components/ErrorBox'
  8 +import { DataTable, type Column } from '@/components/DataTable'
  9 +
  10 +export function UsersPage() {
  11 + const [rows, setRows] = useState<User[]>([])
  12 + const [error, setError] = useState<Error | null>(null)
  13 + const [loading, setLoading] = useState(true)
  14 +
  15 + useEffect(() => {
  16 + identity
  17 + .listUsers()
  18 + .then(setRows)
  19 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  20 + .finally(() => setLoading(false))
  21 + }, [])
  22 +
  23 + const columns: Column<User>[] = [
  24 + {
  25 + header: 'Username',
  26 + key: 'username',
  27 + render: (r) => (
  28 + <Link to={`/users/${r.id}`} className="font-mono text-brand-600 hover:underline">
  29 + {r.username}
  30 + </Link>
  31 + ),
  32 + },
  33 + { header: 'Display name', key: 'displayName' },
  34 + { header: 'Email', key: 'email', render: (r) => r.email ?? '—' },
  35 + {
  36 + header: 'Enabled',
  37 + key: 'enabled',
  38 + render: (r) =>
  39 + r.enabled ? (
  40 + <span className="text-emerald-600">Active</span>
  41 + ) : (
  42 + <span className="text-slate-400">Disabled</span>
  43 + ),
  44 + },
  45 + ]
  46 +
  47 + return (
  48 + <div>
  49 + <PageHeader
  50 + title="Users"
  51 + subtitle="User accounts in this instance. The admin role has all permissions."
  52 + actions={<Link to="/users/new" className="btn-primary">+ New User</Link>}
  53 + />
  54 + {loading && <Loading />}
  55 + {error && <ErrorBox error={error} />}
  56 + {!loading && !error && <DataTable rows={rows} columns={columns} />}
  57 + </div>
  58 + )
  59 +}
... ...
web/src/types/api.ts
... ... @@ -40,6 +40,23 @@ export interface TokenPair {
40 40 tokenType: string
41 41 }
42 42  
  43 +// ─── Identity (pbc-identity) ─────────────────────────────────────────
  44 +
  45 +export interface User {
  46 + id: string
  47 + username: string
  48 + displayName: string
  49 + email: string | null
  50 + enabled: boolean
  51 +}
  52 +
  53 +export interface Role {
  54 + id: string
  55 + code: string
  56 + name: string
  57 + description: string | null
  58 +}
  59 +
43 60 // ─── Catalog (pbc-catalog) ───────────────────────────────────────────
44 61  
45 62 export type ItemType = 'GOOD' | 'SERVICE' | 'DIGITAL'
... ...