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,8 +28,10 @@ permissions: | ||
| 28 | description: Disable a user (soft-delete; row is preserved for audit) | 28 | description: Disable a user (soft-delete; row is preserved for audit) |
| 29 | - key: identity.role.read | 29 | - key: identity.role.read |
| 30 | description: Read role records | 30 | description: Read role records |
| 31 | + - key: identity.role.create | ||
| 32 | + description: Create new roles | ||
| 31 | - key: identity.role.assign | 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 | menus: | 36 | menus: |
| 35 | - path: /identity/users | 37 | - path: /identity/users |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
| @@ -83,6 +83,10 @@ class SpaController { | @@ -83,6 +83,10 @@ class SpaController { | ||
| 83 | 83 | ||
| 84 | // Finance | 84 | // Finance |
| 85 | "/journal-entries", "/journal-entries/", "/journal-entries/**", | 85 | "/journal-entries", "/journal-entries/", "/journal-entries/**", |
| 86 | + | ||
| 87 | + // System / identity | ||
| 88 | + "/users", "/users/", "/users/**", | ||
| 89 | + "/roles", "/roles/", "/roles/**", | ||
| 86 | ], | 90 | ], |
| 87 | ) | 91 | ) |
| 88 | fun spa(): String = "forward:/index.html" | 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,6 +110,8 @@ class SecurityConfiguration { | ||
| 110 | "/work-orders", "/work-orders/**", | 110 | "/work-orders", "/work-orders/**", |
| 111 | "/shop-floor", "/shop-floor/**", | 111 | "/shop-floor", "/shop-floor/**", |
| 112 | "/journal-entries", "/journal-entries/**", | 112 | "/journal-entries", "/journal-entries/**", |
| 113 | + "/users", "/users/**", | ||
| 114 | + "/roles", "/roles/**", | ||
| 113 | ).permitAll() | 115 | ).permitAll() |
| 114 | 116 | ||
| 115 | // Anything else — return 401 so a typoed deep link | 117 | // Anything else — return 401 so a typoed deep link |
web/src/App.tsx
| @@ -13,6 +13,10 @@ import { AppLayout } from '@/layout/AppLayout' | @@ -13,6 +13,10 @@ import { AppLayout } from '@/layout/AppLayout' | ||
| 13 | import { ProtectedRoute } from '@/components/ProtectedRoute' | 13 | import { ProtectedRoute } from '@/components/ProtectedRoute' |
| 14 | import { LoginPage } from '@/pages/LoginPage' | 14 | import { LoginPage } from '@/pages/LoginPage' |
| 15 | import { DashboardPage } from '@/pages/DashboardPage' | 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 | import { ItemsPage } from '@/pages/ItemsPage' | 20 | import { ItemsPage } from '@/pages/ItemsPage' |
| 17 | import { CreateItemPage } from '@/pages/CreateItemPage' | 21 | import { CreateItemPage } from '@/pages/CreateItemPage' |
| 18 | import { UomsPage } from '@/pages/UomsPage' | 22 | import { UomsPage } from '@/pages/UomsPage' |
| @@ -46,6 +50,10 @@ export default function App() { | @@ -46,6 +50,10 @@ export default function App() { | ||
| 46 | } | 50 | } |
| 47 | > | 51 | > |
| 48 | <Route index element={<DashboardPage />} /> | 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 | <Route path="items" element={<ItemsPage />} /> | 57 | <Route path="items" element={<ItemsPage />} /> |
| 50 | <Route path="items/new" element={<CreateItemPage />} /> | 58 | <Route path="items/new" element={<CreateItemPage />} /> |
| 51 | <Route path="uoms" element={<UomsPage />} /> | 59 | <Route path="uoms" element={<UomsPage />} /> |
web/src/api/client.ts
| @@ -31,12 +31,14 @@ import type { | @@ -31,12 +31,14 @@ import type { | ||
| 31 | MetaInfo, | 31 | MetaInfo, |
| 32 | Partner, | 32 | Partner, |
| 33 | PurchaseOrder, | 33 | PurchaseOrder, |
| 34 | + Role, | ||
| 34 | SalesOrder, | 35 | SalesOrder, |
| 35 | ShopFloorEntry, | 36 | ShopFloorEntry, |
| 36 | StockBalance, | 37 | StockBalance, |
| 37 | StockMovement, | 38 | StockMovement, |
| 38 | TokenPair, | 39 | TokenPair, |
| 39 | Uom, | 40 | Uom, |
| 41 | + User, | ||
| 40 | WorkOrder, | 42 | WorkOrder, |
| 41 | } from '@/types/api' | 43 | } from '@/types/api' |
| 42 | 44 | ||
| @@ -131,6 +133,25 @@ export const auth = { | @@ -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 | // ─── Catalog ───────────────────────────────────────────────────────── | 155 | // ─── Catalog ───────────────────────────────────────────────────────── |
| 135 | 156 | ||
| 136 | export const catalog = { | 157 | export const catalog = { |
web/src/layout/AppLayout.tsx
| @@ -65,6 +65,13 @@ const NAV: NavGroup[] = [ | @@ -65,6 +65,13 @@ const NAV: NavGroup[] = [ | ||
| 65 | heading: 'Finance', | 65 | heading: 'Finance', |
| 66 | items: [{ to: '/journal-entries', label: 'Journal Entries' }], | 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 | export function AppLayout() { | 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,6 +40,23 @@ export interface TokenPair { | ||
| 40 | tokenType: string | 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 | // ─── Catalog (pbc-catalog) ─────────────────────────────────────────── | 60 | // ─── Catalog (pbc-catalog) ─────────────────────────────────────────── |
| 44 | 61 | ||
| 45 | export type ItemType = 'GOOD' | 'SERVICE' | 'DIGITAL' | 62 | export type ItemType = 'GOOD' | 'SERVICE' | 'DIGITAL' |
-
mentioned in commit 34c8c92f