Commit 82c5267dfaef573c2df388553ea0bbea98cb3b06
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
Showing
13 changed files
with
547 additions
and
1 deletions
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 '@/layout/AppLayout' |
| 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' | ... | ... |
-
mentioned in commit 34c8c92f