From f939369d032fe6032b312d7e9a67a96f6a73d43f Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Mon, 17 Aug 2026 15:02:50 +0530 Subject: [PATCH 1/2] feat(admin-roles): implement role management features including role creation, editing, and deletion; enhance UI components for roles list and role form; update API endpoints for role operations --- .../scripts/check-contract-drift.mjs | 1 + apps/Administration-FE/scripts/check-i18n.mjs | 2 + .../scripts/generate-client.mjs | 1 + .../src/components/AdminShell.tsx | 113 ++++++- .../src/components/RoleForm.tsx | 197 +++++++++++ .../src/components/RolesList.tsx | 192 +++++++++++ apps/Administration-FE/src/i18n/en.json | 38 ++- apps/Administration-FE/src/lib/admin-api.ts | 45 +++ apps/Administration-FE/src/lib/i18n.ts | 6 + apps/Administration-FE/src/styles/admin.css | 241 +++++++++++++ apps/Administration-FE/src/styles/tokens.css | 2 + .../tests/e2e/admin-roles.spec.ts | 19 ++ .../003_role_description_permissions.py | 59 ++++ .../src/flycatch_api/api/admin_roles.py | 67 ++++ apps/Backend/src/flycatch_api/main.py | 3 +- apps/Backend/src/flycatch_api/models/role.py | 7 +- .../flycatch_api/models/role_permission.py | 12 +- .../src/flycatch_api/schemas/__init__.py | 16 + .../src/flycatch_api/schemas/admin_roles.py | 71 ++++ .../src/flycatch_api/security/dependencies.py | 1 + .../services/bootstrap_service.py | 27 +- .../src/flycatch_api/services/rbac_service.py | 4 + .../src/flycatch_api/services/role_service.py | 276 +++++++++++++++ .../tests/contract/test_openapi_parity.py | 1 + .../tests/integration/test_admin_auth.py | 1 + .../tests/integration/test_admin_roles.py | 189 +++++++++++ .../tests/integration/test_bootstrap.py | 21 +- specs/002-auth-rbac/contracts/README.md | 4 +- .../contracts/admin-rbac.v1.yaml | 4 +- .../contracts/admin-roles.v1.yaml | 318 ++++++++++++++++++ 30 files changed, 1881 insertions(+), 57 deletions(-) create mode 100644 apps/Administration-FE/src/components/RoleForm.tsx create mode 100644 apps/Administration-FE/src/components/RolesList.tsx create mode 100644 apps/Administration-FE/tests/e2e/admin-roles.spec.ts create mode 100644 apps/Backend/alembic/versions/003_role_description_permissions.py create mode 100644 apps/Backend/src/flycatch_api/api/admin_roles.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_roles.py create mode 100644 apps/Backend/src/flycatch_api/services/role_service.py create mode 100644 apps/Backend/tests/integration/test_admin_roles.py create mode 100644 specs/002-auth-rbac/contracts/admin-roles.v1.yaml diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index 1fcf934..92e9b2d 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -9,6 +9,7 @@ const required = [ 'admin-auth.v2.ts', 'admin-rbac.v1.ts', 'admin-management.v2.ts', + 'admin-roles.v1.ts', 'publish.v2.ts', ]; diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs index 055ca56..14b4cf4 100644 --- a/apps/Administration-FE/scripts/check-i18n.mjs +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -8,6 +8,8 @@ const files = [ 'components/SignInForm.tsx', 'components/AdminShell.tsx', 'components/PageEditor.tsx', + 'components/RolesList.tsx', + 'components/RoleForm.tsx', ]; const allowedLiteral = new Set(['Title', 'Description', 'Primary heading', 'Summary', 'Body']); diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index 438ad66..26fecd7 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -14,6 +14,7 @@ const contracts = [ 'admin-auth.v2.yaml', 'admin-rbac.v1.yaml', 'admin-management.v2.yaml', + 'admin-roles.v1.yaml', 'publish.v2.yaml', ]; diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index 4ecb036..d3788bb 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -13,13 +13,22 @@ import { import { hasTokens } from '../lib/token-store'; import { t } from '../lib/i18n'; import PageEditor from './PageEditor'; +import RoleForm from './RoleForm'; +import RolesList from './RolesList'; import SignInForm from './SignInForm'; import SiteSettingsEditor from './SiteSettingsEditor'; -type View = 'site_settings' | 'home'; +type View = 'site_settings' | 'home' | 'roles' | 'role_form'; + +function canManageRolesFrom(session: SessionContext | null): boolean { + return ( + hasPermission(session, 'roles.manage') || Boolean(session?.roles?.includes('administrator')) + ); +} export default function AdminShell() { const [view, setView] = useState('site_settings'); + const [editingRoleId, setEditingRoleId] = useState(null); const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [homePage, setHomePage] = useState | null>(null); @@ -41,6 +50,9 @@ export default function AdminShell() { setSiteSettings(null); setHomePage(null); setWorkspaceError(t('admin.workspace.load_failed')); + if (canManageRolesFrom(nextSession)) { + setView('roles'); + } } finally { setReady(true); } @@ -57,6 +69,26 @@ export default function AdminShell() { }); }, [loadWorkspace]); + useEffect(() => { + if (!session) { + document.title = t('admin.sign_in.title'); + return; + } + if (view === 'home') { + document.title = t('admin.workspace.home_page'); + return; + } + if (view === 'roles') { + document.title = t('admin.workspace.settings'); + return; + } + if (view === 'role_form') { + document.title = editingRoleId ? t('admin.roles.edit') : t('admin.roles.add'); + return; + } + document.title = t('admin.workspace.site_settings'); + }, [session, view, editingRoleId]); + async function refreshData() { const settings = await getSiteSettingsRecord(); setSiteSettings(settings as Record); @@ -82,6 +114,14 @@ export default function AdminShell() { setMessage(null); setError(null); setWorkspaceError(null); + setView('site_settings'); + setEditingRoleId(null); + } + + function openRoles() { + setView('roles'); + setEditingRoleId(null); + setError(null); } if (!ready) { @@ -100,24 +140,28 @@ export default function AdminShell() { ); } - if (!siteSettings || !homePage) { - return ( -
-

{workspaceError || t('admin.workspace.load_failed')}

- -
- ); - } - const canDraft = hasPermission(session, 'drafts.save'); const canPublish = hasPermission(session, 'records.publish'); + const canManageRoles = canManageRolesFrom(session); + const settingsCurrent = view === 'roles' || view === 'role_form'; + + if (!siteSettings || !homePage) { + if (!canManageRoles) { + return ( +
+

{workspaceError || t('admin.workspace.load_failed')}

+ +
+ ); + } + } return (
-
+

{t('admin.workspace.title')}

{session.email}

+ {canManageRoles && ( +
  • + +
  • + )}
    - {message &&

    {message}

    } + {message && view !== 'roles' && view !== 'role_form' &&

    {message}

    } {error && (

    {error}

    )} - {view === 'site_settings' && ( + {view === 'site_settings' && siteSettings && ( )} - {view === 'home' && ( + {view === 'home' && homePage && ( )} + {view === 'roles' && ( + { + setEditingRoleId(null); + setMessage(null); + setView('role_form'); + }} + onEdit={(id) => { + setEditingRoleId(id); + setMessage(null); + setView('role_form'); + }} + /> + )} + {view === 'role_form' && ( + { + setMessage(t('admin.roles.saved')); + openRoles(); + }} + /> + )}
    diff --git a/apps/Administration-FE/src/components/RoleForm.tsx b/apps/Administration-FE/src/components/RoleForm.tsx new file mode 100644 index 0000000..7ea1192 --- /dev/null +++ b/apps/Administration-FE/src/components/RoleForm.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { + AdminApiError, + createRole, + getRole, + getRoleCatalogue, + updateRole, + type RoleCatalogue, + type RoleWrite, +} from '../lib/admin-api'; +import { resourceLabel, t } from '../lib/i18n'; + +interface Props { + roleId: string | null; + onCancel: () => void; + onSaved: () => void; +} + +function permissionKey(resourceId: string, action: string): string { + return `${resourceId}.${action}`; +} + +export default function RoleForm({ roleId, onCancel, onSaved }: Props) { + const [catalogue, setCatalogue] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [grants, setGrants] = useState>(new Set()); + const [isSystem, setIsSystem] = useState(false); + const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + async function load() { + const nextCatalogue = await getRoleCatalogue(); + setCatalogue(nextCatalogue); + if (roleId) { + const role = await getRole(roleId); + setName(role.name); + setDescription(role.description || ''); + setGrants(new Set(role.permissions)); + setIsSystem(role.is_system); + } else { + setName(''); + setDescription(''); + setGrants(new Set()); + setIsSystem(false); + } + setReady(true); + } + load().catch(() => { + setError(t('admin.workspace.load_failed')); + setReady(true); + }); + }, [roleId]); + + function toggle(resourceId: string, action: string) { + const key = permissionKey(resourceId, action); + setGrants((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + async function save(event: FormEvent) { + event.preventDefault(); + setError(null); + setFieldError(null); + if (!name.trim()) { + setFieldError(t('admin.field.required')); + return; + } + const allowed = new Set( + (catalogue?.resources || []).flatMap((resource) => + (catalogue?.actions || []).map((action) => permissionKey(resource.id, action)), + ), + ); + const payload: RoleWrite = { + name: name.trim(), + description: description.trim() || null, + permissions: [...grants].filter((item) => allowed.has(item)), + }; + try { + if (roleId) await updateRole(roleId, payload); + else await createRole(payload); + onSaved(); + } catch (caught) { + if (caught instanceof AdminApiError) { + const detail = caught.detail as { + message_key?: string; + fields?: Record; + }; + const nameKey = detail.fields?.name?.message_key; + const permissionKeyMessage = detail.fields?.permissions?.message_key; + setError(t(nameKey || permissionKeyMessage || detail.message_key || 'admin.action.forbidden')); + return; + } + setError(t('admin.action.forbidden')); + } + } + + if (!ready) { + return

    {t('admin.roles.title')}

    ; + } + + return ( +
    +

    {roleId ? t('admin.roles.edit') : t('admin.roles.add')}

    +
    + + {isSystem &&

    {t('admin.roles.system_protected')}

    } +