From 82c5267dfaef573c2df388553ea0bbea98cb3b06 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 10:45:03 +0800 Subject: [PATCH] feat(identity+web): R2 identity screens — users, roles, role assignment --- pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml | 4 +++- platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt | 4 ++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt | 2 ++ web/src/App.tsx | 8 ++++++++ web/src/api/client.ts | 21 +++++++++++++++++++++ web/src/layout/AppLayout.tsx | 7 +++++++ web/src/pages/CreateUserPage.tsx | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/pages/RolesPage.tsx | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/pages/UserDetailPage.tsx | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/pages/UsersPage.tsx | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/types/api.ts | 17 +++++++++++++++++ 13 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt create mode 100644 web/src/pages/CreateUserPage.tsx create mode 100644 web/src/pages/RolesPage.tsx create mode 100644 web/src/pages/UserDetailPage.tsx create mode 100644 web/src/pages/UsersPage.tsx diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt new file mode 100644 index 0000000..31878af --- /dev/null +++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/RoleService.kt @@ -0,0 +1,72 @@ +package org.vibeerp.pbc.identity.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.pbc.identity.domain.Role +import org.vibeerp.pbc.identity.domain.UserRole +import org.vibeerp.pbc.identity.infrastructure.RoleJpaRepository +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository +import org.vibeerp.pbc.identity.infrastructure.UserRoleJpaRepository +import java.util.UUID + +@Service +@Transactional +class RoleService( + private val roles: RoleJpaRepository, + private val users: UserJpaRepository, + private val userRoles: UserRoleJpaRepository, +) { + + @Transactional(readOnly = true) + fun listRoles(): List = roles.findAll() + + @Transactional(readOnly = true) + fun findRoleByCode(code: String): Role? = roles.findByCode(code) + + fun createRole(command: CreateRoleCommand): Role { + require(!roles.existsByCode(command.code)) { + "role code '${command.code}' already exists" + } + return roles.save( + Role( + code = command.code, + name = command.name, + description = command.description, + ), + ) + } + + @Transactional(readOnly = true) + fun findUserRoleCodes(userId: UUID): List = + userRoles.findRoleCodesByUserId(userId) + + fun assignRole(userId: UUID, roleCode: String) { + val user = users.findById(userId).orElseThrow { + NoSuchElementException("user not found: $userId") + } + val role = roles.findByCode(roleCode) + ?: throw NoSuchElementException("role not found: $roleCode") + + require(!userRoles.existsByUserIdAndRoleId(user.id, role.id)) { + "user '${user.username}' already has role '$roleCode'" + } + userRoles.save(UserRole(userId = user.id, roleId = role.id)) + } + + fun revokeRole(userId: UUID, roleCode: String) { + val role = roles.findByCode(roleCode) + ?: throw NoSuchElementException("role not found: $roleCode") + + val assignments = userRoles.findByUserId(userId) + val match = assignments.find { it.roleId == role.id } + ?: throw NoSuchElementException("user $userId does not have role '$roleCode'") + + userRoles.delete(match) + } +} + +data class CreateRoleCommand( + val code: String, + val name: String, + val description: String? = null, +) diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt new file mode 100644 index 0000000..ae65984 --- /dev/null +++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/RoleController.kt @@ -0,0 +1,98 @@ +package org.vibeerp.pbc.identity.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.identity.application.CreateRoleCommand +import org.vibeerp.pbc.identity.application.RoleService +import org.vibeerp.pbc.identity.domain.Role +import org.vibeerp.platform.security.authz.RequirePermission +import java.util.UUID + +/** + * REST API for role management and user-role assignment. + * + * Mounted at `/api/v1/identity/roles`. The role CRUD endpoints + * let the SPA's identity admin screen create and list roles. + * The nested `/users/{userId}/roles` endpoints let an admin + * assign and revoke roles for a specific user. + */ +@RestController +@RequestMapping("/api/v1/identity") +class RoleController( + private val roleService: RoleService, +) { + + @GetMapping("/roles") + @RequirePermission("identity.role.read") + fun listRoles(): List = + roleService.listRoles().map { it.toResponse() } + + @PostMapping("/roles") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission("identity.role.create") + fun createRole(@RequestBody @Valid request: CreateRoleRequest): RoleResponse = + roleService.createRole( + CreateRoleCommand( + code = request.code, + name = request.name, + description = request.description, + ), + ).toResponse() + + @GetMapping("/users/{userId}/roles") + @RequirePermission("identity.user.read") + fun listUserRoles(@PathVariable userId: UUID): List = + roleService.findUserRoleCodes(userId) + + @PostMapping("/users/{userId}/roles/{roleCode}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission("identity.role.assign") + fun assignRole( + @PathVariable userId: UUID, + @PathVariable roleCode: String, + ) { + roleService.assignRole(userId, roleCode) + } + + @DeleteMapping("/users/{userId}/roles/{roleCode}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission("identity.role.assign") + fun revokeRole( + @PathVariable userId: UUID, + @PathVariable roleCode: String, + ) { + roleService.revokeRole(userId, roleCode) + } +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateRoleRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 256) val name: String, + val description: String? = null, +) + +data class RoleResponse( + val id: UUID, + val code: String, + val name: String, + val description: String?, +) + +private fun Role.toResponse() = RoleResponse( + id = this.id, + code = this.code, + name = this.name, + description = this.description, +) diff --git a/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml b/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml index d4951ed..441ec53 100644 --- a/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml +++ b/pbc/pbc-identity/src/main/resources/META-INF/vibe-erp/metadata/identity.yml @@ -28,8 +28,10 @@ permissions: description: Disable a user (soft-delete; row is preserved for audit) - key: identity.role.read description: Read role records + - key: identity.role.create + description: Create new roles - key: identity.role.assign - description: Assign a role to a user + description: Assign or revoke a role for a user menus: - path: /identity/users diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt index d200536..354b143 100644 --- a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt @@ -83,6 +83,10 @@ class SpaController { // Finance "/journal-entries", "/journal-entries/", "/journal-entries/**", + + // System / identity + "/users", "/users/", "/users/**", + "/roles", "/roles/", "/roles/**", ], ) fun spa(): String = "forward:/index.html" diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt index 868f00c..7cb3846 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt @@ -110,6 +110,8 @@ class SecurityConfiguration { "/work-orders", "/work-orders/**", "/shop-floor", "/shop-floor/**", "/journal-entries", "/journal-entries/**", + "/users", "/users/**", + "/roles", "/roles/**", ).permitAll() // Anything else — return 401 so a typoed deep link diff --git a/web/src/App.tsx b/web/src/App.tsx index eb62e7f..4a33548 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,10 @@ import { AppLayout } from '@/layout/AppLayout' import { ProtectedRoute } from '@/components/ProtectedRoute' import { LoginPage } from '@/pages/LoginPage' import { DashboardPage } from '@/pages/DashboardPage' +import { UsersPage } from '@/pages/UsersPage' +import { CreateUserPage } from '@/pages/CreateUserPage' +import { UserDetailPage } from '@/pages/UserDetailPage' +import { RolesPage } from '@/pages/RolesPage' import { ItemsPage } from '@/pages/ItemsPage' import { CreateItemPage } from '@/pages/CreateItemPage' import { UomsPage } from '@/pages/UomsPage' @@ -46,6 +50,10 @@ export default function App() { } > } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 369778c..ce79197 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -31,12 +31,14 @@ import type { MetaInfo, Partner, PurchaseOrder, + Role, SalesOrder, ShopFloorEntry, StockBalance, StockMovement, TokenPair, Uom, + User, WorkOrder, } from '@/types/api' @@ -131,6 +133,25 @@ export const auth = { }), } +// ─── Identity ──────────────────────────────────────────────────────── + +export const identity = { + listUsers: () => apiFetch('/api/v1/identity/users'), + getUser: (id: string) => apiFetch(`/api/v1/identity/users/${id}`), + createUser: (body: { + username: string; displayName: string; email?: string | null + }) => apiFetch('/api/v1/identity/users', { method: 'POST', body: JSON.stringify(body) }), + listRoles: () => apiFetch('/api/v1/identity/roles'), + createRole: (body: { + code: string; name: string; description?: string | null + }) => apiFetch('/api/v1/identity/roles', { method: 'POST', body: JSON.stringify(body) }), + getUserRoles: (userId: string) => apiFetch(`/api/v1/identity/users/${userId}/roles`), + assignRole: (userId: string, roleCode: string) => + apiFetch(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'POST' }, false), + revokeRole: (userId: string, roleCode: string) => + apiFetch(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'DELETE' }, false), +} + // ─── Catalog ───────────────────────────────────────────────────────── export const catalog = { diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 9be70c8..237390e 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -65,6 +65,13 @@ const NAV: NavGroup[] = [ heading: 'Finance', items: [{ to: '/journal-entries', label: 'Journal Entries' }], }, + { + heading: 'System', + items: [ + { to: '/users', label: 'Users' }, + { to: '/roles', label: 'Roles' }, + ], + }, ] export function AppLayout() { diff --git a/web/src/pages/CreateUserPage.tsx b/web/src/pages/CreateUserPage.tsx new file mode 100644 index 0000000..917e50c --- /dev/null +++ b/web/src/pages/CreateUserPage.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { identity } from '@/api/client' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +export function CreateUserPage() { + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [displayName, setDisplayName] = useState('') + const [email, setEmail] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + const user = await identity.createUser({ + username, displayName, email: email || null, + }) + navigate(`/users/${user.id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/users')}>Cancel} + /> +
+
+ + setUsername(e.target.value)} + placeholder="jdoe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setDisplayName(e.target.value)} + placeholder="Jane Doe" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+ {error && } + + +
+ ) +} diff --git a/web/src/pages/RolesPage.tsx b/web/src/pages/RolesPage.tsx new file mode 100644 index 0000000..2579872 --- /dev/null +++ b/web/src/pages/RolesPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { identity } from '@/api/client' +import type { Role } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DataTable, type Column } from '@/components/DataTable' + +export function RolesPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [creating, setCreating] = useState(false) + + const load = () => { + identity + .listRoles() + .then(setRows) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const onCreate = async (e: FormEvent) => { + e.preventDefault() + setCreating(true) + setError(null) + try { + await identity.createRole({ code, name }) + setCode('') + setName('') + setShowCreate(false) + load() + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setCreating(false) + } + } + + const columns: Column[] = [ + { header: 'Code', key: 'code', render: (r) => {r.code} }, + { header: 'Name', key: 'name' }, + { header: 'Description', key: 'description', render: (r) => r.description ?? '—' }, + ] + + return ( +
+ setShowCreate(!showCreate)}> + {showCreate ? 'Cancel' : '+ New Role'} + + } + /> + {showCreate && ( +
+
+ + setCode(e.target.value)} + placeholder="sales-clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> +
+
+ + setName(e.target.value)} + placeholder="Sales Clerk" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> +
+ +
+ )} + {loading && } + {error && } + {!loading && !error && } +
+ ) +} diff --git a/web/src/pages/UserDetailPage.tsx b/web/src/pages/UserDetailPage.tsx new file mode 100644 index 0000000..677ae9e --- /dev/null +++ b/web/src/pages/UserDetailPage.tsx @@ -0,0 +1,111 @@ +// 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(null) + const [allRoles, setAllRoles] = useState([]) + const [userRoleCodes, setUserRoleCodes] = useState([]) + const [loading, setLoading] = useState(true) + const [acting, setActing] = useState(false) + const [error, setError] = useState(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 + if (error && !user) return + if (!user) return + + return ( +
+ navigate('/users')}> + ← Back + + } + /> + + {error && } + +
+

Roles

+

+ Toggle roles on/off. Changes take effect on the user's next login. +

+ {allRoles.length === 0 && ( +

No roles defined yet. Create one on the Roles page.

+ )} +
+ {allRoles.map((role) => { + const has = userRoleCodes.includes(role.code) + return ( +
+
+ {role.code} + {role.name} +
+ +
+ ) + })} +
+
+
+ ) +} diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx new file mode 100644 index 0000000..3929653 --- /dev/null +++ b/web/src/pages/UsersPage.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { identity } from '@/api/client' +import type { User } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DataTable, type Column } from '@/components/DataTable' + +export function UsersPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + identity + .listUsers() + .then(setRows) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, []) + + const columns: Column[] = [ + { + header: 'Username', + key: 'username', + render: (r) => ( + + {r.username} + + ), + }, + { header: 'Display name', key: 'displayName' }, + { header: 'Email', key: 'email', render: (r) => r.email ?? '—' }, + { + header: 'Enabled', + key: 'enabled', + render: (r) => + r.enabled ? ( + Active + ) : ( + Disabled + ), + }, + ] + + return ( +
+ + New User} + /> + {loading && } + {error && } + {!loading && !error && } +
+ ) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 8e78ba3..a05db2b 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,23 @@ export interface TokenPair { tokenType: string } +// ─── Identity (pbc-identity) ───────────────────────────────────────── + +export interface User { + id: string + username: string + displayName: string + email: string | null + enabled: boolean +} + +export interface Role { + id: string + code: string + name: string + description: string | null +} + // ─── Catalog (pbc-catalog) ─────────────────────────────────────────── export type ItemType = 'GOOD' | 'SERVICE' | 'DIGITAL' -- libgit2 0.22.2