create-lego-one 2.0.10 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +179 -0
- package/dist/index.cjs.map +1 -1
- package/package.json +5 -3
- package/template/.cursor/rules/rules.mdc +639 -0
- package/template/.dockerignore +58 -0
- package/template/.env.example +18 -0
- package/template/.eslintignore +5 -0
- package/template/.eslintrc.js +28 -0
- package/template/.prettierignore +6 -0
- package/template/.prettierrc +11 -0
- package/template/CLAUDE.md +634 -0
- package/template/Dockerfile +67 -0
- package/template/PROMPT.md +457 -0
- package/template/README.md +325 -0
- package/template/docker-compose.yml +48 -0
- package/template/docker-entrypoint.sh +23 -0
- package/template/docs/checkpoints/.template.md +64 -0
- package/template/docs/checkpoints/framework/01-infrastructure-setup.md +132 -0
- package/template/docs/checkpoints/framework/02-pocketbase-setup.md +155 -0
- package/template/docs/checkpoints/framework/03-host-kernel.md +170 -0
- package/template/docs/checkpoints/framework/04-auth-system.md +163 -0
- package/template/docs/checkpoints/framework/phase-05-multitenancy-rbac.md +223 -0
- package/template/docs/checkpoints/framework/phase-06-ui-components.md +260 -0
- package/template/docs/checkpoints/framework/phase-07-communication-system.md +276 -0
- package/template/docs/checkpoints/framework/phase-08-plugin-system.md +91 -0
- package/template/docs/checkpoints/framework/phase-09-dashboard-plugin.md +111 -0
- package/template/docs/checkpoints/framework/phase-10-todo-plugin.md +169 -0
- package/template/docs/checkpoints/framework/phase-11-testing.md +264 -0
- package/template/docs/checkpoints/framework/phase-12-deployment.md +294 -0
- package/template/docs/checkpoints/framework/phase-13-documentation.md +312 -0
- package/template/docs/framework/plans/00-index.md +164 -0
- package/template/docs/framework/plans/01-infrastructure-setup.md +855 -0
- package/template/docs/framework/plans/02-pocketbase-setup.md +1374 -0
- package/template/docs/framework/plans/03-host-kernel.md +1518 -0
- package/template/docs/framework/plans/04-auth-system.md +1466 -0
- package/template/docs/framework/plans/05-multitenancy-rbac.md +1527 -0
- package/template/docs/framework/plans/06-ui-components.md +1478 -0
- package/template/docs/framework/plans/07-communication-system.md +1106 -0
- package/template/docs/framework/plans/08-plugin-system.md +1179 -0
- package/template/docs/framework/plans/09-dashboard-plugin.md +1137 -0
- package/template/docs/framework/plans/10-todo-plugin.md +1343 -0
- package/template/docs/framework/plans/11-testing.md +935 -0
- package/template/docs/framework/plans/12-deployment.md +896 -0
- package/template/docs/framework/prompts/0-boilerplate-modernjs.md +151 -0
- package/template/docs/framework/research/00-modernjs-audit.md +488 -0
- package/template/docs/framework/research/01-system-blueprint.md +721 -0
- package/template/docs/framework/research/02-data-migration-protocol.md +699 -0
- package/template/docs/framework/research/03-host-setup.md +714 -0
- package/template/docs/framework/research/04-plugin-architecture.md +645 -0
- package/template/docs/framework/research/05-slot-injection-pattern.md +671 -0
- package/template/docs/framework/research/06-cli-strategy.md +615 -0
- package/template/docs/framework/research/07-deployment.md +629 -0
- package/template/docs/framework/research/README.md +282 -0
- package/template/docs/framework/setup/00-index.md +210 -0
- package/template/docs/framework/setup/01-framework-structure.md +308 -0
- package/template/docs/framework/setup/02-development-workflow.md +405 -0
- package/template/docs/framework/setup/03-environment-setup.md +215 -0
- package/template/docs/framework/setup/04-kernel-architecture.md +499 -0
- package/template/docs/framework/setup/05-plugin-system.md +620 -0
- package/template/docs/framework/setup/06-communication-patterns.md +451 -0
- package/template/docs/framework/setup/07-plugin-development.md +582 -0
- package/template/docs/framework/setup/08-component-library.md +658 -0
- package/template/docs/framework/setup/09-data-integration.md +609 -0
- package/template/docs/framework/setup/10-auth-rbac.md +497 -0
- package/template/docs/framework/setup/11-hooks-api.md +393 -0
- package/template/docs/framework/setup/12-components-api.md +665 -0
- package/template/docs/framework/setup/13-deployment-guide.md +566 -0
- package/template/docs/framework/setup/README.md +548 -0
- package/template/host/e2e/auth.spec.ts +38 -0
- package/template/host/e2e/layout.spec.ts +38 -0
- package/template/host/modern.config.ts +19 -0
- package/template/host/package.json +71 -0
- package/template/host/playwright.config.ts +34 -0
- package/template/host/postcss.config.mjs +6 -0
- package/template/host/src/App.tsx +6 -0
- package/template/host/src/bootstrap.tsx +74 -0
- package/template/host/src/global.css +59 -0
- package/template/host/src/index.ts +2 -0
- package/template/host/src/kernel/__tests__/lib-utils.test.ts +32 -0
- package/template/host/src/kernel/__tests__/rbac-hooks.test.tsx +114 -0
- package/template/host/src/kernel/__tests__/rbac-utils.test.ts +108 -0
- package/template/host/src/kernel/auth/ProtectedRoute.tsx +41 -0
- package/template/host/src/kernel/auth/components/LoginForm.tsx +97 -0
- package/template/host/src/kernel/auth/components/LogoutButton.tsx +79 -0
- package/template/host/src/kernel/auth/hooks.ts +174 -0
- package/template/host/src/kernel/auth/index.ts +5 -0
- package/template/host/src/kernel/auth/schemas.ts +27 -0
- package/template/host/src/kernel/auth/service.ts +197 -0
- package/template/host/src/kernel/auth/types.ts +36 -0
- package/template/host/src/kernel/channels/ChannelBus.ts +181 -0
- package/template/host/src/kernel/channels/ChannelProvider.tsx +57 -0
- package/template/host/src/kernel/channels/events.ts +27 -0
- package/template/host/src/kernel/channels/hooks.ts +168 -0
- package/template/host/src/kernel/channels/index.ts +6 -0
- package/template/host/src/kernel/channels/integrations/ToastIntegration.tsx +60 -0
- package/template/host/src/kernel/channels/plugin-hooks.ts +72 -0
- package/template/host/src/kernel/channels/types.ts +112 -0
- package/template/host/src/kernel/components/__tests__/Badge.test.tsx +35 -0
- package/template/host/src/kernel/components/__tests__/Button.test.tsx +63 -0
- package/template/host/src/kernel/components/__tests__/Input.test.tsx +64 -0
- package/template/host/src/kernel/components/index.ts +32 -0
- package/template/host/src/kernel/components/ui/alert.tsx +58 -0
- package/template/host/src/kernel/components/ui/avatar.tsx +47 -0
- package/template/host/src/kernel/components/ui/badge.tsx +35 -0
- package/template/host/src/kernel/components/ui/button.tsx +50 -0
- package/template/host/src/kernel/components/ui/card.tsx +78 -0
- package/template/host/src/kernel/components/ui/dialog.tsx +116 -0
- package/template/host/src/kernel/components/ui/dropdown-menu.tsx +192 -0
- package/template/host/src/kernel/components/ui/index.ts +7 -0
- package/template/host/src/kernel/components/ui/input.tsx +24 -0
- package/template/host/src/kernel/components/ui/label.tsx +21 -0
- package/template/host/src/kernel/components/ui/popover.tsx +28 -0
- package/template/host/src/kernel/components/ui/progress.tsx +25 -0
- package/template/host/src/kernel/components/ui/scroll-area.tsx +45 -0
- package/template/host/src/kernel/components/ui/select.tsx +155 -0
- package/template/host/src/kernel/components/ui/separator.tsx +28 -0
- package/template/host/src/kernel/components/ui/skeleton.tsx +15 -0
- package/template/host/src/kernel/components/ui/switch.tsx +26 -0
- package/template/host/src/kernel/components/ui/table.tsx +116 -0
- package/template/host/src/kernel/components/ui/tabs.tsx +52 -0
- package/template/host/src/kernel/components/ui/toast.tsx +126 -0
- package/template/host/src/kernel/components/ui/toaster.tsx +34 -0
- package/template/host/src/kernel/components/ui/tooltip.tsx +27 -0
- package/template/host/src/kernel/components/ui/use-toast.ts +183 -0
- package/template/host/src/kernel/index.ts +48 -0
- package/template/host/src/kernel/lib/cn.ts +1 -0
- package/template/host/src/kernel/lib/utils.ts +36 -0
- package/template/host/src/kernel/plugins/Slot.tsx +41 -0
- package/template/host/src/kernel/plugins/SlotProvider.tsx +88 -0
- package/template/host/src/kernel/plugins/index.ts +23 -0
- package/template/host/src/kernel/plugins/loader.ts +122 -0
- package/template/host/src/kernel/plugins/schemas.ts +54 -0
- package/template/host/src/kernel/plugins/store.ts +185 -0
- package/template/host/src/kernel/plugins/types.ts +103 -0
- package/template/host/src/kernel/providers/PocketBaseProvider.tsx +70 -0
- package/template/host/src/kernel/providers/QueryProvider.tsx +28 -0
- package/template/host/src/kernel/providers/ThemeProvider.tsx +25 -0
- package/template/host/src/kernel/providers/index.ts +3 -0
- package/template/host/src/kernel/rbac/components/OrganizationSelector.tsx +69 -0
- package/template/host/src/kernel/rbac/components/PermissionGate.tsx +43 -0
- package/template/host/src/kernel/rbac/hooks.ts +379 -0
- package/template/host/src/kernel/rbac/index.ts +6 -0
- package/template/host/src/kernel/rbac/service.ts +504 -0
- package/template/host/src/kernel/rbac/types.ts +164 -0
- package/template/host/src/kernel/rbac/utils.ts +34 -0
- package/template/host/src/kernel/shared-state/bridge.ts +31 -0
- package/template/host/src/kernel/shared-state/index.ts +3 -0
- package/template/host/src/kernel/shared-state/store.ts +62 -0
- package/template/host/src/kernel/shared-state/types.ts +60 -0
- package/template/host/src/kernel/use-migrations.ts +72 -0
- package/template/host/src/layout/MobileMenu.tsx +61 -0
- package/template/host/src/layout/Shell.tsx +42 -0
- package/template/host/src/layout/Sidebar.tsx +178 -0
- package/template/host/src/layout/Topbar.tsx +50 -0
- package/template/host/src/layout/index.ts +4 -0
- package/template/host/src/lib/pocketbase/client.ts +38 -0
- package/template/host/src/lib/pocketbase/collections/audit_logs.ts +87 -0
- package/template/host/src/lib/pocketbase/collections/index.ts +19 -0
- package/template/host/src/lib/pocketbase/collections/organizations.ts +63 -0
- package/template/host/src/lib/pocketbase/collections/permissions.ts +57 -0
- package/template/host/src/lib/pocketbase/collections/roles.ts +55 -0
- package/template/host/src/lib/pocketbase/collections/todos.ts +74 -0
- package/template/host/src/lib/pocketbase/collections/user_roles.ts +57 -0
- package/template/host/src/lib/pocketbase/collections/users.ts +43 -0
- package/template/host/src/lib/pocketbase/index.ts +5 -0
- package/template/host/src/lib/pocketbase/migrations.ts +44 -0
- package/template/host/src/lib/pocketbase/seed/permissions.ts +8 -0
- package/template/host/src/lib/pocketbase/seed/roles.ts +22 -0
- package/template/host/src/lib/pocketbase/seed.ts +113 -0
- package/template/host/src/lib/pocketbase/types.ts +102 -0
- package/template/host/src/modern.runtime.ts +26 -0
- package/template/host/src/plugins.d.ts +9 -0
- package/template/host/src/providers/PocketBaseProvider.tsx +30 -0
- package/template/host/src/routes/_.tsx +6 -0
- package/template/host/src/routes/dashboard._.tsx +41 -0
- package/template/host/src/routes/index.tsx +93 -0
- package/template/host/src/routes/login.tsx +36 -0
- package/template/host/src/saas.config.ts +52 -0
- package/template/host/src/test/setup.ts +65 -0
- package/template/host/src/test/utils.tsx +69 -0
- package/template/host/src/test/vitest-globals.d.ts +19 -0
- package/template/host/src/vite-env.d.ts +16 -0
- package/template/host/tailwind.config.ts +77 -0
- package/template/host/tsconfig.json +19 -0
- package/template/host/vitest.config.ts +30 -0
- package/template/nginx.conf +72 -0
- package/template/package.json +44 -0
- package/template/packages/plugins/@lego/plugin-dashboard/modern.config.ts +19 -0
- package/template/packages/plugins/@lego/plugin-dashboard/package.json +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/postcss.config.mjs +6 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/App.tsx +27 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/ActivityFeed.tsx +63 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/QuickActionSlot.tsx +11 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/QuickActions.tsx +68 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/SidebarWidget.tsx +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/StatCard.tsx +47 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/global.css +24 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useChannelIntegration.ts +43 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useDashboardStats.ts +65 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/usePocketBase.ts +47 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useRecentActivity.ts +55 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/lib/utils.ts +6 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/pages/DashboardPage.tsx +105 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/plugin.config.ts +121 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/plugin.ts +18 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/vite-env.d.ts +32 -0
- package/template/packages/plugins/@lego/plugin-dashboard/tailwind.config.ts +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/tsconfig.json +18 -0
- package/template/packages/plugins/@lego/plugin-todo/modern.config.ts +18 -0
- package/template/packages/plugins/@lego/plugin-todo/package.json +41 -0
- package/template/packages/plugins/@lego/plugin-todo/postcss.config.mjs +6 -0
- package/template/packages/plugins/@lego/plugin-todo/src/App.tsx +12 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/SidebarWidget.tsx +16 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoDialog.tsx +55 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoFilters.tsx +79 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoForm.tsx +94 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoItem.tsx +121 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoList.tsx +41 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/index.ts +6 -0
- package/template/packages/plugins/@lego/plugin-todo/src/global.css +59 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useCreateTodo.ts +62 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useDeleteTodo.ts +46 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/usePocketBase.ts +38 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useTodos.ts +64 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useUpdateTodo.ts +35 -0
- package/template/packages/plugins/@lego/plugin-todo/src/index.tsx +5 -0
- package/template/packages/plugins/@lego/plugin-todo/src/lib/utils.ts +20 -0
- package/template/packages/plugins/@lego/plugin-todo/src/pages/TodoPage.tsx +89 -0
- package/template/packages/plugins/@lego/plugin-todo/src/plugin.config.ts +104 -0
- package/template/packages/plugins/@lego/plugin-todo/src/plugin.ts +13 -0
- package/template/packages/plugins/@lego/plugin-todo/src/schemas.ts +37 -0
- package/template/packages/plugins/@lego/plugin-todo/src/types.ts +42 -0
- package/template/packages/plugins/@lego/plugin-todo/src/vite-env.d.ts +31 -0
- package/template/packages/plugins/@lego/plugin-todo/tailwind.config.ts +51 -0
- package/template/packages/plugins/@lego/plugin-todo/tsconfig.json +18 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/pocketbase/CHANGELOG.md +911 -0
- package/template/pocketbase/LICENSE.md +17 -0
- package/template/scripts/create-plugin.js +221 -0
- package/template/scripts/deploy.sh +56 -0
- package/template/tsconfig.base.json +26 -0
- package/template/tsconfig.json +8 -0
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
# Multitenancy and RBAC Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For AI Implementing This Plan:** This is document 05 of 13. Complete documents 01-04 first.
|
|
4
|
+
|
|
5
|
+
**Goal:** Implement complete multi-tenancy with organizations and full role-based access control (RBAC) system including roles, permissions, and user management.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Organization-based multi-tenancy with data isolation via PocketBase API rules. RBAC system with roles (Owner, Admin, Member, Guest) and granular permissions. Admin manages users and roles through settings UI.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** PocketBase collections, React hooks, TanStack Query, Zustand, Zod validation
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
- ✅ Completed `01-infrastructure-setup.md`
|
|
16
|
+
- ✅ Completed `02-pocketbase-setup.md` (organizations, roles, permissions, user_roles collections)
|
|
17
|
+
- ✅ Completed `03-host-kernel.md` (host app, shared state)
|
|
18
|
+
- ✅ Completed `04-auth-system.md` (authentication, protected routes)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Task 1: Create Multitenancy Types and Interfaces
|
|
23
|
+
|
|
24
|
+
**Files:**
|
|
25
|
+
- Create: `host/src/kernel/rbac/types.ts`
|
|
26
|
+
|
|
27
|
+
### Step 1: Create RBAC types
|
|
28
|
+
|
|
29
|
+
**File:** `host/src/kernel/rbac/types.ts`
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import type { Record } from 'pocketbase';
|
|
33
|
+
|
|
34
|
+
// Organization types
|
|
35
|
+
export interface Organization {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
ownerId: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
updated: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreateOrganizationData {
|
|
45
|
+
name: string;
|
|
46
|
+
slug: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UpdateOrganizationData {
|
|
50
|
+
name?: string;
|
|
51
|
+
slug?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Role types
|
|
55
|
+
export interface Role {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
isSystem: boolean;
|
|
60
|
+
organizationId?: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
updated: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CreateRoleData {
|
|
66
|
+
name: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
organizationId?: string;
|
|
69
|
+
permissions?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface UpdateRoleData {
|
|
73
|
+
name?: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
permissions?: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Permission types
|
|
79
|
+
export interface Permission {
|
|
80
|
+
id: string;
|
|
81
|
+
resource: string;
|
|
82
|
+
action: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// User role types
|
|
88
|
+
export interface UserRole {
|
|
89
|
+
id: string;
|
|
90
|
+
userId: string;
|
|
91
|
+
roleId: string;
|
|
92
|
+
organizationId: string;
|
|
93
|
+
createdAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AssignRoleData {
|
|
97
|
+
userId: string;
|
|
98
|
+
roleId: string;
|
|
99
|
+
organizationId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// User management types
|
|
103
|
+
export interface UserWithRoles {
|
|
104
|
+
id: string;
|
|
105
|
+
email: string;
|
|
106
|
+
name: string;
|
|
107
|
+
avatar?: string;
|
|
108
|
+
roles: Array<{
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
organizationId: string;
|
|
112
|
+
}>;
|
|
113
|
+
organizations: string[];
|
|
114
|
+
createdAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CreateUserRequest {
|
|
118
|
+
email: string;
|
|
119
|
+
name: string;
|
|
120
|
+
password: string;
|
|
121
|
+
organizationId: string;
|
|
122
|
+
roleIds?: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface UpdateUserRequest {
|
|
126
|
+
name?: string;
|
|
127
|
+
email?: string;
|
|
128
|
+
avatar?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Permission check result
|
|
132
|
+
export interface PermissionCheck {
|
|
133
|
+
allowed: boolean;
|
|
134
|
+
reason?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resource types for permissions
|
|
138
|
+
export type ResourceType =
|
|
139
|
+
| 'organizations'
|
|
140
|
+
| 'users'
|
|
141
|
+
| 'roles'
|
|
142
|
+
| 'permissions'
|
|
143
|
+
| 'todos'
|
|
144
|
+
| 'settings'
|
|
145
|
+
| 'audit_logs'
|
|
146
|
+
| 'all';
|
|
147
|
+
|
|
148
|
+
// Action types for permissions
|
|
149
|
+
export type ActionType =
|
|
150
|
+
| 'create'
|
|
151
|
+
| 'read'
|
|
152
|
+
| 'update'
|
|
153
|
+
| 'delete'
|
|
154
|
+
| 'manage'
|
|
155
|
+
| 'all';
|
|
156
|
+
|
|
157
|
+
// System roles
|
|
158
|
+
export const SYSTEM_ROLES = {
|
|
159
|
+
OWNER: 'Owner',
|
|
160
|
+
ADMIN: 'Admin',
|
|
161
|
+
MEMBER: 'Member',
|
|
162
|
+
GUEST: 'Guest',
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
// Default permissions for system roles
|
|
166
|
+
export const DEFAULT_ROLE_PERMISSIONS: Record<
|
|
167
|
+
string,
|
|
168
|
+
{ resource: ResourceType; action: ActionType }[]
|
|
169
|
+
> = {
|
|
170
|
+
[SYSTEM_ROLES.OWNER]: [
|
|
171
|
+
{ resource: 'all', action: 'all' },
|
|
172
|
+
],
|
|
173
|
+
[SYSTEM_ROLES.ADMIN]: [
|
|
174
|
+
{ resource: 'organizations', action: 'read' },
|
|
175
|
+
{ resource: 'organizations', action: 'update' },
|
|
176
|
+
{ resource: 'users', action: 'create' },
|
|
177
|
+
{ resource: 'users', action: 'read' },
|
|
178
|
+
{ resource: 'users', action: 'update' },
|
|
179
|
+
{ resource: 'users', action: 'delete' },
|
|
180
|
+
{ resource: 'roles', action: 'read' },
|
|
181
|
+
{ resource: 'permissions', action: 'read' },
|
|
182
|
+
{ resource: 'todos', action: 'manage' },
|
|
183
|
+
{ resource: 'settings', action: 'read' },
|
|
184
|
+
{ resource: 'settings', action: 'update' },
|
|
185
|
+
{ resource: 'audit_logs', action: 'read' },
|
|
186
|
+
],
|
|
187
|
+
[SYSTEM_ROLES.MEMBER]: [
|
|
188
|
+
{ resource: 'organizations', action: 'read' },
|
|
189
|
+
{ resource: 'users', action: 'read' },
|
|
190
|
+
{ resource: 'todos', action: 'manage' },
|
|
191
|
+
{ resource: 'settings', action: 'read' },
|
|
192
|
+
],
|
|
193
|
+
[SYSTEM_ROLES.GUEST]: [
|
|
194
|
+
{ resource: 'organizations', action: 'read' },
|
|
195
|
+
{ resource: 'todos', action: 'read' },
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Task 2: Create RBAC Service
|
|
203
|
+
|
|
204
|
+
**Files:**
|
|
205
|
+
- Create: `host/src/kernel/rbac/service.ts`
|
|
206
|
+
|
|
207
|
+
### Step 1: Create RBAC service
|
|
208
|
+
|
|
209
|
+
**File:** `host/src/kernel/rbac/service.ts`
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import PocketBase from 'pocketbase';
|
|
213
|
+
import type {
|
|
214
|
+
Organization,
|
|
215
|
+
CreateOrganizationData,
|
|
216
|
+
UpdateOrganizationData,
|
|
217
|
+
Role,
|
|
218
|
+
CreateRoleData,
|
|
219
|
+
UpdateRoleData,
|
|
220
|
+
Permission,
|
|
221
|
+
UserRole,
|
|
222
|
+
AssignRoleData,
|
|
223
|
+
UserWithRoles,
|
|
224
|
+
CreateUserRequest,
|
|
225
|
+
UpdateUserRequest,
|
|
226
|
+
PermissionCheck,
|
|
227
|
+
ResourceType,
|
|
228
|
+
ActionType,
|
|
229
|
+
} from './types';
|
|
230
|
+
import { SYSTEM_ROLES, DEFAULT_ROLE_PERMISSIONS } from './types';
|
|
231
|
+
import type { ListResult } from 'pocketbase';
|
|
232
|
+
|
|
233
|
+
export class RBACService {
|
|
234
|
+
constructor(private pb: PocketBase) {}
|
|
235
|
+
|
|
236
|
+
// ==================== Organization Management ====================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get all organizations for current user
|
|
240
|
+
*/
|
|
241
|
+
async getUserOrganizations(): Promise<Organization[]> {
|
|
242
|
+
try {
|
|
243
|
+
// The user's organizations are available via the relation
|
|
244
|
+
const userId = this.pb.authStore.record?.id;
|
|
245
|
+
if (!userId) return [];
|
|
246
|
+
|
|
247
|
+
// Get user record with expanded organizations
|
|
248
|
+
const userRecord = await this.pb.collection('users').getOne(userId, {
|
|
249
|
+
expand: 'organizations',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return (userRecord.expand?.organizations || []) as Organization[];
|
|
253
|
+
} catch (error: any) {
|
|
254
|
+
throw this.handleError(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get organization by ID
|
|
260
|
+
*/
|
|
261
|
+
async getOrganization(id: string): Promise<Organization> {
|
|
262
|
+
try {
|
|
263
|
+
return await this.pb.collection('organizations').getOne(id);
|
|
264
|
+
} catch (error: any) {
|
|
265
|
+
throw this.handleError(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get organization by slug
|
|
271
|
+
*/
|
|
272
|
+
async getOrganizationBySlug(slug: string): Promise<Organization> {
|
|
273
|
+
try {
|
|
274
|
+
const result = await this.pb
|
|
275
|
+
.collection('organizations')
|
|
276
|
+
.getList(1, 1, { filter: `slug = "${slug}"` });
|
|
277
|
+
if (result.items.length === 0) {
|
|
278
|
+
throw new Error('Organization not found');
|
|
279
|
+
}
|
|
280
|
+
return result.items[0] as Organization;
|
|
281
|
+
} catch (error: any) {
|
|
282
|
+
throw this.handleError(error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Create new organization
|
|
288
|
+
*/
|
|
289
|
+
async createOrganization(data: CreateOrganizationData): Promise<Organization> {
|
|
290
|
+
try {
|
|
291
|
+
const userId = this.pb.authStore.record?.id;
|
|
292
|
+
if (!userId) throw new Error('Not authenticated');
|
|
293
|
+
|
|
294
|
+
const record = await this.pb.collection('organizations').create({
|
|
295
|
+
...data,
|
|
296
|
+
ownerId: userId,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Assign owner role to creator
|
|
300
|
+
const ownerRole = await this.getRoleByName(SYSTEM_ROLES.OWNER);
|
|
301
|
+
if (ownerRole) {
|
|
302
|
+
await this.assignRole({
|
|
303
|
+
userId,
|
|
304
|
+
roleId: ownerRole.id,
|
|
305
|
+
organizationId: record.id,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return record as Organization;
|
|
310
|
+
} catch (error: any) {
|
|
311
|
+
throw this.handleError(error);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Update organization
|
|
317
|
+
*/
|
|
318
|
+
async updateOrganization(id: string, data: UpdateOrganizationData): Promise<Organization> {
|
|
319
|
+
try {
|
|
320
|
+
return await this.pb.collection('organizations').update(id, data);
|
|
321
|
+
} catch (error: any) {
|
|
322
|
+
throw this.handleError(error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Delete organization
|
|
328
|
+
*/
|
|
329
|
+
async deleteOrganization(id: string): Promise<void> {
|
|
330
|
+
try {
|
|
331
|
+
await this.pb.collection('organizations').delete(id);
|
|
332
|
+
} catch (error: any) {
|
|
333
|
+
throw this.handleError(error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ==================== Role Management ====================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get all roles for an organization
|
|
341
|
+
*/
|
|
342
|
+
async getOrganizationRoles(organizationId: string): Promise<Role[]> {
|
|
343
|
+
try {
|
|
344
|
+
const result = await this.pb.collection('roles').getList(1, 100, {
|
|
345
|
+
filter: `organizationId = "${organizationId}" || organizationId = ""`,
|
|
346
|
+
});
|
|
347
|
+
return result.items as Role[];
|
|
348
|
+
} catch (error: any) {
|
|
349
|
+
throw this.handleError(error);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get role by ID
|
|
355
|
+
*/
|
|
356
|
+
async getRole(id: string): Promise<Role> {
|
|
357
|
+
try {
|
|
358
|
+
return await this.pb.collection('roles').getOne(id);
|
|
359
|
+
} catch (error: any) {
|
|
360
|
+
throw this.handleError(error);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get role by name
|
|
366
|
+
*/
|
|
367
|
+
async getRoleByName(name: string): Promise<Role | null> {
|
|
368
|
+
try {
|
|
369
|
+
const result = await this.pb
|
|
370
|
+
.collection('roles')
|
|
371
|
+
.getList(1, 1, { filter: `name = "${name}" && organizationId = ""` });
|
|
372
|
+
if (result.items.length === 0) return null;
|
|
373
|
+
return result.items[0] as Role;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Create custom role
|
|
381
|
+
*/
|
|
382
|
+
async createRole(data: CreateRoleData): Promise<Role> {
|
|
383
|
+
try {
|
|
384
|
+
return await this.pb.collection('roles').create({
|
|
385
|
+
name: data.name,
|
|
386
|
+
description: data.description,
|
|
387
|
+
organizationId: data.organizationId || '',
|
|
388
|
+
isSystem: false,
|
|
389
|
+
});
|
|
390
|
+
} catch (error: any) {
|
|
391
|
+
throw this.handleError(error);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Update role
|
|
397
|
+
*/
|
|
398
|
+
async updateRole(id: string, data: UpdateRoleData): Promise<Role> {
|
|
399
|
+
try {
|
|
400
|
+
return await this.pb.collection('roles').update(id, data);
|
|
401
|
+
} catch (error: any) {
|
|
402
|
+
throw this.handleError(error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Delete role
|
|
408
|
+
*/
|
|
409
|
+
async deleteRole(id: string): Promise<void> {
|
|
410
|
+
try {
|
|
411
|
+
await this.pb.collection('roles').delete(id);
|
|
412
|
+
} catch (error: any) {
|
|
413
|
+
throw this.handleError(error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ==================== Permission Management ====================
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get all permissions
|
|
421
|
+
*/
|
|
422
|
+
async getAllPermissions(): Promise<Permission[]> {
|
|
423
|
+
try {
|
|
424
|
+
const result = await this.pb.collection('permissions').getList(1, 100, {
|
|
425
|
+
sort: 'resource,action',
|
|
426
|
+
});
|
|
427
|
+
return result.items as Permission[];
|
|
428
|
+
} catch (error: any) {
|
|
429
|
+
throw this.handleError(error);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check if user has permission
|
|
435
|
+
*/
|
|
436
|
+
async hasPermission(
|
|
437
|
+
userId: string,
|
|
438
|
+
organizationId: string,
|
|
439
|
+
resource: ResourceType,
|
|
440
|
+
action: ActionType
|
|
441
|
+
): Promise<PermissionCheck> {
|
|
442
|
+
try {
|
|
443
|
+
// Get all user roles for this organization
|
|
444
|
+
const userRoles = await this.pb.collection('user_roles').getList(1, 50, {
|
|
445
|
+
filter: `userId = "${userId}" && organizationId = "${organizationId}"`,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (userRoles.items.length === 0) {
|
|
449
|
+
return { allowed: false, reason: 'No roles assigned' };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const roleIds = userRoles.items.map((ur: any) => ur.roleId);
|
|
453
|
+
|
|
454
|
+
// Get all permissions for these roles
|
|
455
|
+
const permissions: Permission[] = [];
|
|
456
|
+
|
|
457
|
+
for (const roleId of roleIds) {
|
|
458
|
+
const role = await this.pb.collection('roles').getOne(roleId);
|
|
459
|
+
|
|
460
|
+
// If role has expand permissions
|
|
461
|
+
if (role.expand?.permissions) {
|
|
462
|
+
permissions.push(...role.expand.permissions);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check for system role defaults
|
|
466
|
+
if (role.isSystem) {
|
|
467
|
+
const defaults = DEFAULT_ROLE_PERMISSIONS[role.name];
|
|
468
|
+
if (defaults) {
|
|
469
|
+
// Convert defaults to Permission-like objects
|
|
470
|
+
for (const def of defaults) {
|
|
471
|
+
permissions.push({
|
|
472
|
+
id: `${def.resource}:${def.action}`,
|
|
473
|
+
resource: def.resource,
|
|
474
|
+
action: def.action,
|
|
475
|
+
} as Permission);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Check for matching permission
|
|
482
|
+
const hasAll = permissions.some(p => p.resource === 'all' && p.action === 'all');
|
|
483
|
+
const hasResourceAll = permissions.some(p => p.resource === resource && p.action === 'all');
|
|
484
|
+
const hasActionAll = permissions.some(p => p.resource === 'all' && p.action === action);
|
|
485
|
+
const hasExact = permissions.some(p => p.resource === resource && p.action === action);
|
|
486
|
+
|
|
487
|
+
if (hasAll || hasResourceAll || hasActionAll || hasExact) {
|
|
488
|
+
return { allowed: true };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { allowed: false, reason: 'Permission denied' };
|
|
492
|
+
} catch (error: any) {
|
|
493
|
+
return { allowed: false, reason: error.message };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Get user permissions for an organization
|
|
499
|
+
*/
|
|
500
|
+
async getUserPermissions(
|
|
501
|
+
userId: string,
|
|
502
|
+
organizationId: string
|
|
503
|
+
): Promise<{ resource: ResourceType; action: ActionType }[]> {
|
|
504
|
+
try {
|
|
505
|
+
const userRoles = await this.pb.collection('user_roles').getList(1, 50, {
|
|
506
|
+
filter: `userId = "${userId}" && organizationId = "${organizationId}"`,
|
|
507
|
+
expand: 'roleId',
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const permissions: { resource: ResourceType; action: ActionType }[] = [];
|
|
511
|
+
|
|
512
|
+
for (const item of userRoles.items) {
|
|
513
|
+
const role = (item as any).expand?.roleId;
|
|
514
|
+
if (!role) continue;
|
|
515
|
+
|
|
516
|
+
if (role.isSystem) {
|
|
517
|
+
const defaults = DEFAULT_ROLE_PERMISSIONS[role.name];
|
|
518
|
+
if (defaults) {
|
|
519
|
+
permissions.push(...defaults);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (role.expand?.permissions) {
|
|
524
|
+
for (const perm of role.expand.permissions) {
|
|
525
|
+
permissions.push({
|
|
526
|
+
resource: perm.resource as ResourceType,
|
|
527
|
+
action: perm.action as ActionType,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return permissions;
|
|
534
|
+
} catch (error: any) {
|
|
535
|
+
throw this.handleError(error);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ==================== User Management ====================
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Get all users in an organization
|
|
543
|
+
*/
|
|
544
|
+
async getOrganizationUsers(organizationId: string): Promise<UserWithRoles[]> {
|
|
545
|
+
try {
|
|
546
|
+
// Get users through the user_roles relation
|
|
547
|
+
const userRoles = await this.pb.collection('user_roles').getList(1, 100, {
|
|
548
|
+
filter: `organizationId = "${organizationId}"`,
|
|
549
|
+
expand: 'userId,roleId',
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const usersMap = new Map<string, UserWithRoles>();
|
|
553
|
+
|
|
554
|
+
for (const item of userRoles.items) {
|
|
555
|
+
const ur = item as any;
|
|
556
|
+
const user = ur.expand?.userId;
|
|
557
|
+
const role = ur.expand?.roleId;
|
|
558
|
+
|
|
559
|
+
if (!user) continue;
|
|
560
|
+
|
|
561
|
+
if (!usersMap.has(user.id)) {
|
|
562
|
+
usersMap.set(user.id, {
|
|
563
|
+
id: user.id,
|
|
564
|
+
email: user.email,
|
|
565
|
+
name: user.name,
|
|
566
|
+
avatar: user.avatar,
|
|
567
|
+
roles: [],
|
|
568
|
+
organizations: [],
|
|
569
|
+
createdAt: user.created,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (role) {
|
|
574
|
+
usersMap.get(user.id)!.roles.push({
|
|
575
|
+
id: role.id,
|
|
576
|
+
name: role.name,
|
|
577
|
+
organizationId: role.organizationId || organizationId,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return Array.from(usersMap.values());
|
|
583
|
+
} catch (error: any) {
|
|
584
|
+
throw this.handleError(error);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Assign role to user
|
|
590
|
+
*/
|
|
591
|
+
async assignRole(data: AssignRoleData): Promise<UserRole> {
|
|
592
|
+
try {
|
|
593
|
+
return await this.pb.collection('user_roles').create(data);
|
|
594
|
+
} catch (error: any) {
|
|
595
|
+
throw this.handleError(error);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Remove role from user
|
|
601
|
+
*/
|
|
602
|
+
async removeRole(userRoleId: string): Promise<void> {
|
|
603
|
+
try {
|
|
604
|
+
await this.pb.collection('user_roles').delete(userRoleId);
|
|
605
|
+
} catch (error: any) {
|
|
606
|
+
throw this.handleError(error);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Invite/create user in organization
|
|
612
|
+
*/
|
|
613
|
+
async createUser(data: CreateUserRequest): Promise<void> {
|
|
614
|
+
try {
|
|
615
|
+
// Create user
|
|
616
|
+
const record = await this.pb.collection('users').create({
|
|
617
|
+
email: data.email,
|
|
618
|
+
password: data.password,
|
|
619
|
+
passwordConfirm: data.password,
|
|
620
|
+
name: data.name,
|
|
621
|
+
organizationId: data.organizationId,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Assign roles if provided
|
|
625
|
+
if (data.roleIds && data.roleIds.length > 0) {
|
|
626
|
+
for (const roleId of data.roleIds) {
|
|
627
|
+
await this.assignRole({
|
|
628
|
+
userId: record.id,
|
|
629
|
+
roleId,
|
|
630
|
+
organizationId: data.organizationId,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} catch (error: any) {
|
|
635
|
+
throw this.handleError(error);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Update user
|
|
641
|
+
*/
|
|
642
|
+
async updateUser(userId: string, data: UpdateUserRequest): Promise<void> {
|
|
643
|
+
try {
|
|
644
|
+
await this.pb.collection('users').update(userId, data);
|
|
645
|
+
} catch (error: any) {
|
|
646
|
+
throw this.handleError(error);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Delete user
|
|
652
|
+
*/
|
|
653
|
+
async deleteUser(userId: string): Promise<void> {
|
|
654
|
+
try {
|
|
655
|
+
await this.pb.collection('users').delete(userId);
|
|
656
|
+
} catch (error: any) {
|
|
657
|
+
throw this.handleError(error);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ==================== Audit Logging ====================
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Log an action
|
|
665
|
+
*/
|
|
666
|
+
async logAction(data: {
|
|
667
|
+
userId: string;
|
|
668
|
+
organizationId: string;
|
|
669
|
+
action: string;
|
|
670
|
+
resource: string;
|
|
671
|
+
resourceId?: string;
|
|
672
|
+
details?: Record<string, any>;
|
|
673
|
+
}): Promise<void> {
|
|
674
|
+
try {
|
|
675
|
+
await this.pb.collection('audit_logs').create(data);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
// Log errors should not fail the main operation
|
|
678
|
+
console.error('Failed to log action:', error);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Get audit logs for organization
|
|
684
|
+
*/
|
|
685
|
+
async getAuditLogs(
|
|
686
|
+
organizationId: string,
|
|
687
|
+
page = 1,
|
|
688
|
+
perPage = 50
|
|
689
|
+
): Promise<ListResult> {
|
|
690
|
+
try {
|
|
691
|
+
return await this.pb.collection('audit_logs').getList(page, perPage, {
|
|
692
|
+
filter: `organizationId = "${organizationId}"`,
|
|
693
|
+
sort: '-created',
|
|
694
|
+
expand: 'userId',
|
|
695
|
+
});
|
|
696
|
+
} catch (error: any) {
|
|
697
|
+
throw this.handleError(error);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Handle PocketBase errors
|
|
703
|
+
*/
|
|
704
|
+
private handleError(error: any): Error {
|
|
705
|
+
if (error?.data?.message) {
|
|
706
|
+
return new Error(error.data.message);
|
|
707
|
+
}
|
|
708
|
+
if (error?.message) {
|
|
709
|
+
return new Error(error.message);
|
|
710
|
+
}
|
|
711
|
+
return new Error('An unexpected error occurred');
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Task 3: Create RBAC Hooks
|
|
719
|
+
|
|
720
|
+
**Files:**
|
|
721
|
+
- Create: `host/src/kernel/rbac/hooks.ts`
|
|
722
|
+
|
|
723
|
+
### Step 1: Create RBAC hooks
|
|
724
|
+
|
|
725
|
+
**File:** `host/src/kernel/rbac/hooks.ts`
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
729
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
730
|
+
import { usePocketBase } from '../providers';
|
|
731
|
+
import { RBACService } from './service';
|
|
732
|
+
import type {
|
|
733
|
+
Organization,
|
|
734
|
+
CreateOrganizationData,
|
|
735
|
+
UpdateOrganizationData,
|
|
736
|
+
Role,
|
|
737
|
+
CreateRoleData,
|
|
738
|
+
UpdateRoleData,
|
|
739
|
+
Permission,
|
|
740
|
+
UserRole,
|
|
741
|
+
AssignRoleData,
|
|
742
|
+
UserWithRoles,
|
|
743
|
+
CreateUserRequest,
|
|
744
|
+
UpdateUserRequest,
|
|
745
|
+
ResourceType,
|
|
746
|
+
ActionType,
|
|
747
|
+
} from './types';
|
|
748
|
+
import { useGlobalKernelState } from '../shared-state';
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Get RBAC service instance
|
|
752
|
+
*/
|
|
753
|
+
function useRBACService(): RBACService | null {
|
|
754
|
+
const pb = usePocketBase();
|
|
755
|
+
if (!pb) return null;
|
|
756
|
+
return new RBACService(pb);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Manage current organization
|
|
761
|
+
*/
|
|
762
|
+
export function useCurrentOrganization() {
|
|
763
|
+
const { organization, setOrganization } = useGlobalKernelState();
|
|
764
|
+
const queryClient = useQueryClient();
|
|
765
|
+
|
|
766
|
+
const setCurrentOrganization = useCallback((org: Organization | null) => {
|
|
767
|
+
setOrganization(org);
|
|
768
|
+
// Clear related queries when switching organizations
|
|
769
|
+
queryClient.clear();
|
|
770
|
+
}, [setOrganization, queryClient]);
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
organization,
|
|
774
|
+
setCurrentOrganization,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Get user's organizations
|
|
780
|
+
*/
|
|
781
|
+
export function useOrganizations() {
|
|
782
|
+
const service = useRBACService();
|
|
783
|
+
|
|
784
|
+
return useQuery({
|
|
785
|
+
queryKey: ['organizations'],
|
|
786
|
+
queryFn: () => service?.getUserOrganizations() || [],
|
|
787
|
+
enabled: !!service,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Get single organization
|
|
793
|
+
*/
|
|
794
|
+
export function useOrganization(id: string) {
|
|
795
|
+
const service = useRBACService();
|
|
796
|
+
|
|
797
|
+
return useQuery({
|
|
798
|
+
queryKey: ['organization', id],
|
|
799
|
+
queryFn: () => service?.getOrganization(id),
|
|
800
|
+
enabled: !!service && !!id,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Create organization
|
|
806
|
+
*/
|
|
807
|
+
export function useCreateOrganization() {
|
|
808
|
+
const service = useRBACService();
|
|
809
|
+
const queryClient = useQueryClient();
|
|
810
|
+
const { setCurrentOrganization } = useCurrentOrganization();
|
|
811
|
+
|
|
812
|
+
return useMutation({
|
|
813
|
+
mutationFn: (data: CreateOrganizationData) => {
|
|
814
|
+
if (!service) throw new Error('Service not available');
|
|
815
|
+
return service.createOrganization(data);
|
|
816
|
+
},
|
|
817
|
+
onSuccess: (org) => {
|
|
818
|
+
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
|
819
|
+
setCurrentOrganization(org);
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Update organization
|
|
826
|
+
*/
|
|
827
|
+
export function useUpdateOrganization() {
|
|
828
|
+
const service = useRBACService();
|
|
829
|
+
const queryClient = useQueryClient();
|
|
830
|
+
|
|
831
|
+
return useMutation({
|
|
832
|
+
mutationFn: ({ id, data }: { id: string; data: UpdateOrganizationData }) => {
|
|
833
|
+
if (!service) throw new Error('Service not available');
|
|
834
|
+
return service.updateOrganization(id, data);
|
|
835
|
+
},
|
|
836
|
+
onSuccess: (_, { id }) => {
|
|
837
|
+
queryClient.invalidateQueries({ queryKey: ['organization', id] });
|
|
838
|
+
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Get organization roles
|
|
845
|
+
*/
|
|
846
|
+
export function useOrganizationRoles(organizationId: string | null) {
|
|
847
|
+
const service = useRBACService();
|
|
848
|
+
|
|
849
|
+
return useQuery({
|
|
850
|
+
queryKey: ['roles', organizationId],
|
|
851
|
+
queryFn: () => {
|
|
852
|
+
if (!organizationId || !service) return [];
|
|
853
|
+
return service.getOrganizationRoles(organizationId);
|
|
854
|
+
},
|
|
855
|
+
enabled: !!service && !!organizationId,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Create custom role
|
|
861
|
+
*/
|
|
862
|
+
export function useCreateRole() {
|
|
863
|
+
const service = useRBACService();
|
|
864
|
+
const queryClient = useQueryClient();
|
|
865
|
+
|
|
866
|
+
return useMutation({
|
|
867
|
+
mutationFn: (data: CreateRoleData) => {
|
|
868
|
+
if (!service) throw new Error('Service not available');
|
|
869
|
+
return service.createRole(data);
|
|
870
|
+
},
|
|
871
|
+
onSuccess: (_, { organizationId }) => {
|
|
872
|
+
queryClient.invalidateQueries({ queryKey: ['roles', organizationId] });
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Update role
|
|
879
|
+
*/
|
|
880
|
+
export function useUpdateRole() {
|
|
881
|
+
const service = useRBACService();
|
|
882
|
+
const queryClient = useQueryClient();
|
|
883
|
+
|
|
884
|
+
return useMutation({
|
|
885
|
+
mutationFn: ({ id, data }: { id: string; data: UpdateRoleData }) => {
|
|
886
|
+
if (!service) throw new Error('Service not available');
|
|
887
|
+
return service.updateRole(id, data);
|
|
888
|
+
},
|
|
889
|
+
onSuccess: () => {
|
|
890
|
+
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Delete role
|
|
897
|
+
*/
|
|
898
|
+
export function useDeleteRole() {
|
|
899
|
+
const service = useRBACService();
|
|
900
|
+
const queryClient = useQueryClient();
|
|
901
|
+
|
|
902
|
+
return useMutation({
|
|
903
|
+
mutationFn: (id: string) => {
|
|
904
|
+
if (!service) throw new Error('Service not available');
|
|
905
|
+
return service.deleteRole(id);
|
|
906
|
+
},
|
|
907
|
+
onSuccess: () => {
|
|
908
|
+
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Get all permissions
|
|
915
|
+
*/
|
|
916
|
+
export function usePermissions() {
|
|
917
|
+
const service = useRBACService();
|
|
918
|
+
|
|
919
|
+
return useQuery({
|
|
920
|
+
queryKey: ['permissions'],
|
|
921
|
+
queryFn: () => service?.getAllPermissions() || [],
|
|
922
|
+
enabled: !!service,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Get organization users
|
|
928
|
+
*/
|
|
929
|
+
export function useOrganizationUsers(organizationId: string | null) {
|
|
930
|
+
const service = useRBACService();
|
|
931
|
+
|
|
932
|
+
return useQuery({
|
|
933
|
+
queryKey: ['users', organizationId],
|
|
934
|
+
queryFn: () => {
|
|
935
|
+
if (!organizationId || !service) return [];
|
|
936
|
+
return service.getOrganizationUsers(organizationId);
|
|
937
|
+
},
|
|
938
|
+
enabled: !!service && !!organizationId,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Assign role to user
|
|
944
|
+
*/
|
|
945
|
+
export function useAssignRole() {
|
|
946
|
+
const service = useRBACService();
|
|
947
|
+
const queryClient = useQueryClient();
|
|
948
|
+
|
|
949
|
+
return useMutation({
|
|
950
|
+
mutationFn: (data: AssignRoleData) => {
|
|
951
|
+
if (!service) throw new Error('Service not available');
|
|
952
|
+
return service.assignRole(data);
|
|
953
|
+
},
|
|
954
|
+
onSuccess: (_, { organizationId }) => {
|
|
955
|
+
queryClient.invalidateQueries({ queryKey: ['users', organizationId] });
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Remove role from user
|
|
962
|
+
*/
|
|
963
|
+
export function useRemoveRole() {
|
|
964
|
+
const service = useRBACService();
|
|
965
|
+
const queryClient = useQueryClient();
|
|
966
|
+
|
|
967
|
+
return useMutation({
|
|
968
|
+
mutationFn: ({ userRoleId, organizationId }: { userRoleId: string; organizationId: string }) => {
|
|
969
|
+
if (!service) throw new Error('Service not available');
|
|
970
|
+
return service.removeRole(userRoleId);
|
|
971
|
+
},
|
|
972
|
+
onSuccess: (_, { organizationId }) => {
|
|
973
|
+
queryClient.invalidateQueries({ queryKey: ['users', organizationId] });
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Create user
|
|
980
|
+
*/
|
|
981
|
+
export function useCreateUser() {
|
|
982
|
+
const service = useRBACService();
|
|
983
|
+
const queryClient = useQueryClient();
|
|
984
|
+
|
|
985
|
+
return useMutation({
|
|
986
|
+
mutationFn: (data: CreateUserRequest) => {
|
|
987
|
+
if (!service) throw new Error('Service not available');
|
|
988
|
+
return service.createUser(data);
|
|
989
|
+
},
|
|
990
|
+
onSuccess: (_, { organizationId }) => {
|
|
991
|
+
queryClient.invalidateQueries({ queryKey: ['users', organizationId] });
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Update user
|
|
998
|
+
*/
|
|
999
|
+
export function useUpdateUser() {
|
|
1000
|
+
const service = useRBACService();
|
|
1001
|
+
const queryClient = useQueryClient();
|
|
1002
|
+
|
|
1003
|
+
return useMutation({
|
|
1004
|
+
mutationFn: ({ userId, data }: { userId: string; data: UpdateUserRequest }) => {
|
|
1005
|
+
if (!service) throw new Error('Service not available');
|
|
1006
|
+
return service.updateUser(userId, data);
|
|
1007
|
+
},
|
|
1008
|
+
onSuccess: (_, { userId }) => {
|
|
1009
|
+
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
1010
|
+
queryClient.invalidateQueries({ queryKey: ['auth', 'user'] });
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Delete user
|
|
1017
|
+
*/
|
|
1018
|
+
export function useDeleteUser() {
|
|
1019
|
+
const service = useRBACService();
|
|
1020
|
+
const queryClient = useQueryClient();
|
|
1021
|
+
|
|
1022
|
+
return useMutation({
|
|
1023
|
+
mutationFn: ({ userId, organizationId }: { userId: string; organizationId: string }) => {
|
|
1024
|
+
if (!service) throw new Error('Service not available');
|
|
1025
|
+
return service.deleteUser(userId);
|
|
1026
|
+
},
|
|
1027
|
+
onSuccess: (_, { organizationId }) => {
|
|
1028
|
+
queryClient.invalidateQueries({ queryKey: ['users', organizationId] });
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Check permissions
|
|
1035
|
+
*/
|
|
1036
|
+
export function useHasPermission() {
|
|
1037
|
+
const service = useRBACService();
|
|
1038
|
+
const { user, organization } = useGlobalKernelState();
|
|
1039
|
+
|
|
1040
|
+
return useCallback(
|
|
1041
|
+
async (resource: ResourceType, action: ActionType): Promise<boolean> => {
|
|
1042
|
+
if (!service || !user?.id || !organization?.id) return false;
|
|
1043
|
+
const result = await service.hasPermission(user.id, organization.id, resource, action);
|
|
1044
|
+
return result.allowed;
|
|
1045
|
+
},
|
|
1046
|
+
[service, user, organization]
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Get user permissions for current organization
|
|
1052
|
+
*/
|
|
1053
|
+
export function useUserPermissions() {
|
|
1054
|
+
const service = useRBACService();
|
|
1055
|
+
const { user, organization } = useGlobalKernelState();
|
|
1056
|
+
|
|
1057
|
+
return useQuery({
|
|
1058
|
+
queryKey: ['permissions', 'user', user?.id, organization?.id],
|
|
1059
|
+
queryFn: () => {
|
|
1060
|
+
if (!service || !user?.id || !organization?.id) return [];
|
|
1061
|
+
return service.getUserPermissions(user.id, organization.id);
|
|
1062
|
+
},
|
|
1063
|
+
enabled: !!service && !!user?.id && !!organization?.id,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Require permission - returns whether user has permission
|
|
1069
|
+
*/
|
|
1070
|
+
export function useRequirePermission(resource: ResourceType, action: ActionType) {
|
|
1071
|
+
const permissions = useUserPermissions();
|
|
1072
|
+
const [hasPermission, setHasPermission] = useState(false);
|
|
1073
|
+
|
|
1074
|
+
useEffect(() => {
|
|
1075
|
+
const perms = permissions.data || [];
|
|
1076
|
+
|
|
1077
|
+
const allowed =
|
|
1078
|
+
perms.some(p => p.resource === 'all' && p.action === 'all') ||
|
|
1079
|
+
perms.some(p => p.resource === resource && p.action === 'all') ||
|
|
1080
|
+
perms.some(p => p.resource === 'all' && p.action === action) ||
|
|
1081
|
+
perms.some(p => p.resource === resource && p.action === action);
|
|
1082
|
+
|
|
1083
|
+
setHasPermission(allowed);
|
|
1084
|
+
}, [permissions.data, resource, action]);
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
hasPermission,
|
|
1088
|
+
isLoading: permissions.isLoading,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Get audit logs
|
|
1094
|
+
*/
|
|
1095
|
+
export function useAuditLogs(organizationId: string | null, page = 1) {
|
|
1096
|
+
const service = useRBACService();
|
|
1097
|
+
|
|
1098
|
+
return useQuery({
|
|
1099
|
+
queryKey: ['audit-logs', organizationId, page],
|
|
1100
|
+
queryFn: () => {
|
|
1101
|
+
if (!organizationId || !service) return null;
|
|
1102
|
+
return service.getAuditLogs(organizationId, page);
|
|
1103
|
+
},
|
|
1104
|
+
enabled: !!service && !!organizationId,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
---
|
|
1110
|
+
|
|
1111
|
+
## Task 4: Create Organization Selector Component
|
|
1112
|
+
|
|
1113
|
+
**Files:**
|
|
1114
|
+
- Create: `host/src/kernel/rbac/components/OrganizationSelector.tsx`
|
|
1115
|
+
|
|
1116
|
+
### Step 1: Create organization selector
|
|
1117
|
+
|
|
1118
|
+
**File:** `host/src/kernel/rbac/components/OrganizationSelector.tsx`
|
|
1119
|
+
|
|
1120
|
+
```typescript
|
|
1121
|
+
import { useCurrentOrganization, useOrganizations } from '../hooks';
|
|
1122
|
+
import {
|
|
1123
|
+
DropdownMenu,
|
|
1124
|
+
DropdownMenuContent,
|
|
1125
|
+
DropdownMenuItem,
|
|
1126
|
+
DropdownMenuLabel,
|
|
1127
|
+
DropdownMenuSeparator,
|
|
1128
|
+
DropdownMenuTrigger,
|
|
1129
|
+
} from '../../components/ui/dropdown-menu';
|
|
1130
|
+
import { Button } from '../../components/ui/button';
|
|
1131
|
+
import { Building2, ChevronDown, Loader2, Plus } from 'lucide-react';
|
|
1132
|
+
import { useNavigate } from '@modern-js/runtime/router';
|
|
1133
|
+
|
|
1134
|
+
export function OrganizationSelector() {
|
|
1135
|
+
const { organization, setCurrentOrganization } = useCurrentOrganization();
|
|
1136
|
+
const { data: organizations, isLoading } = useOrganizations();
|
|
1137
|
+
const navigate = useNavigate();
|
|
1138
|
+
|
|
1139
|
+
const handleSwitch = (orgId: string) => {
|
|
1140
|
+
const org = organizations?.find(o => o.id === orgId);
|
|
1141
|
+
if (org) {
|
|
1142
|
+
setCurrentOrganization(org);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const handleCreate = () => {
|
|
1147
|
+
navigate('/settings/organizations/new');
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
return (
|
|
1151
|
+
<DropdownMenu>
|
|
1152
|
+
<DropdownMenuTrigger asChild>
|
|
1153
|
+
<Button variant="outline" className="gap-2">
|
|
1154
|
+
<Building2 className="h-4 w-4" />
|
|
1155
|
+
{isLoading ? (
|
|
1156
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
1157
|
+
) : organization ? (
|
|
1158
|
+
<span className="max-w-[150px] truncate">{organization.name}</span>
|
|
1159
|
+
) : (
|
|
1160
|
+
<span>Select Organization</span>
|
|
1161
|
+
)}
|
|
1162
|
+
<ChevronDown className="h-4 w-4" />
|
|
1163
|
+
</Button>
|
|
1164
|
+
</DropdownMenuTrigger>
|
|
1165
|
+
<DropdownMenuContent className="w-56" align="start">
|
|
1166
|
+
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
|
|
1167
|
+
<DropdownMenuSeparator />
|
|
1168
|
+
{organizations?.map((org) => (
|
|
1169
|
+
<DropdownMenuItem
|
|
1170
|
+
key={org.id}
|
|
1171
|
+
onClick={() => handleSwitch(org.id)}
|
|
1172
|
+
className={organization?.id === org.id ? 'bg-accent' : ''}
|
|
1173
|
+
>
|
|
1174
|
+
<Building2 className="mr-2 h-4 w-4" />
|
|
1175
|
+
<span className="flex-1 truncate">{org.name}</span>
|
|
1176
|
+
{organization?.id === org.id && (
|
|
1177
|
+
<span className="text-xs text-muted-foreground">Current</span>
|
|
1178
|
+
)}
|
|
1179
|
+
</DropdownMenuItem>
|
|
1180
|
+
))}
|
|
1181
|
+
<DropdownMenuSeparator />
|
|
1182
|
+
<DropdownMenuItem onClick={handleCreate}>
|
|
1183
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
1184
|
+
Create Organization
|
|
1185
|
+
</DropdownMenuItem>
|
|
1186
|
+
</DropdownMenuContent>
|
|
1187
|
+
</DropdownMenu>
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
---
|
|
1193
|
+
|
|
1194
|
+
## Task 5: Create Permission Gate Component
|
|
1195
|
+
|
|
1196
|
+
**Files:**
|
|
1197
|
+
- Create: `host/src/kernel/rbac/components/PermissionGate.tsx`
|
|
1198
|
+
|
|
1199
|
+
### Step 1: Create permission gate component
|
|
1200
|
+
|
|
1201
|
+
**File:** `host/src/kernel/rbac/components/PermissionGate.tsx`
|
|
1202
|
+
|
|
1203
|
+
```typescript
|
|
1204
|
+
import { useRequirePermission } from '../hooks';
|
|
1205
|
+
import type { ResourceType, ActionType } from '../types';
|
|
1206
|
+
import { Alert, AlertDescription } from '../../components/ui/alert';
|
|
1207
|
+
import { Lock } from 'lucide-react';
|
|
1208
|
+
|
|
1209
|
+
interface PermissionGateProps {
|
|
1210
|
+
resource: ResourceType;
|
|
1211
|
+
action: ActionType;
|
|
1212
|
+
children: React.ReactNode;
|
|
1213
|
+
fallback?: React.ReactNode;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function PermissionGate({
|
|
1217
|
+
resource,
|
|
1218
|
+
action,
|
|
1219
|
+
children,
|
|
1220
|
+
fallback,
|
|
1221
|
+
}: PermissionGateProps) {
|
|
1222
|
+
const { hasPermission, isLoading } = useRequirePermission(resource, action);
|
|
1223
|
+
|
|
1224
|
+
if (isLoading) {
|
|
1225
|
+
return (
|
|
1226
|
+
<div className="flex min-h-[200px] items-center justify-center">
|
|
1227
|
+
<div className="text-sm text-muted-foreground">Checking permissions...</div>
|
|
1228
|
+
</div>
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (!hasPermission) {
|
|
1233
|
+
return (
|
|
1234
|
+
fallback || (
|
|
1235
|
+
<Alert variant="destructive">
|
|
1236
|
+
<Lock className="h-4 w-4" />
|
|
1237
|
+
<AlertDescription>
|
|
1238
|
+
You don't have permission to {action} {resource}.
|
|
1239
|
+
</AlertDescription>
|
|
1240
|
+
</Alert>
|
|
1241
|
+
)
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return <>{children}</>;
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
---
|
|
1250
|
+
|
|
1251
|
+
## Task 6: Update Topbar with Organization Selector
|
|
1252
|
+
|
|
1253
|
+
**Files:**
|
|
1254
|
+
- Modify: `host/src/layout/Topbar.tsx`
|
|
1255
|
+
|
|
1256
|
+
### Step 1: Add organization selector to topbar
|
|
1257
|
+
|
|
1258
|
+
**File:** `host/src/layout/Topbar.tsx`
|
|
1259
|
+
|
|
1260
|
+
```typescript
|
|
1261
|
+
import { useGlobalKernelState } from '../kernel/shared-state';
|
|
1262
|
+
import { Menu, Bell } from 'lucide-react';
|
|
1263
|
+
import { LogoutButton } from '../kernel/auth/components/LogoutButton';
|
|
1264
|
+
import { useAuth } from '../kernel/auth/hooks';
|
|
1265
|
+
import { OrganizationSelector } from '../kernel/rbac/components/OrganizationSelector';
|
|
1266
|
+
|
|
1267
|
+
export function Topbar() {
|
|
1268
|
+
const { toggleMobileMenu } = useGlobalKernelState();
|
|
1269
|
+
const { isAuthenticated } = useAuth();
|
|
1270
|
+
|
|
1271
|
+
return (
|
|
1272
|
+
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-6">
|
|
1273
|
+
{/* Mobile menu button */}
|
|
1274
|
+
<button
|
|
1275
|
+
onClick={toggleMobileMenu}
|
|
1276
|
+
className="lg:hidden rounded-lg p-2 text-muted-foreground hover:bg-muted"
|
|
1277
|
+
>
|
|
1278
|
+
<Menu className="h-5 w-5" />
|
|
1279
|
+
</button>
|
|
1280
|
+
|
|
1281
|
+
{/* Organization selector */}
|
|
1282
|
+
{isAuthenticated && <OrganizationSelector />}
|
|
1283
|
+
|
|
1284
|
+
{/* Breadcrumb/spacer */}
|
|
1285
|
+
<div className="flex-1" />
|
|
1286
|
+
|
|
1287
|
+
{/* Actions */}
|
|
1288
|
+
<div className="flex items-center gap-2">
|
|
1289
|
+
{isAuthenticated && (
|
|
1290
|
+
<button className="rounded-lg p-2 text-muted-foreground hover:bg-muted">
|
|
1291
|
+
<Bell className="h-5 w-5" />
|
|
1292
|
+
</button>
|
|
1293
|
+
)}
|
|
1294
|
+
|
|
1295
|
+
{isAuthenticated && <LogoutButton />}
|
|
1296
|
+
</div>
|
|
1297
|
+
</header>
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
---
|
|
1303
|
+
|
|
1304
|
+
## Task 7: Create RBAC Context and Initial Organization Setup
|
|
1305
|
+
|
|
1306
|
+
**Files:**
|
|
1307
|
+
- Create: `host/src/kernel/rbac/utils.ts`
|
|
1308
|
+
- Modify: `host/src/kernel/providers/PocketBaseProvider.tsx`
|
|
1309
|
+
|
|
1310
|
+
### Step 1: Create RBAC utilities
|
|
1311
|
+
|
|
1312
|
+
**File:** `host/src/kernel/rbac/utils.ts`
|
|
1313
|
+
|
|
1314
|
+
```typescript
|
|
1315
|
+
import type { Organization } from './types';
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Initialize first organization for user if none exists
|
|
1319
|
+
*/
|
|
1320
|
+
export async function initializeFirstOrganization(
|
|
1321
|
+
pb: any,
|
|
1322
|
+
userId: string
|
|
1323
|
+
): Promise<Organization | null> {
|
|
1324
|
+
try {
|
|
1325
|
+
// Check if user has any organizations
|
|
1326
|
+
const userRecord = await pb.collection('users').getOne(userId, {
|
|
1327
|
+
expand: 'organizations',
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const organizations = userRecord.expand?.organizations || [];
|
|
1331
|
+
|
|
1332
|
+
if (organizations.length > 0) {
|
|
1333
|
+
return organizations[0] as Organization;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Create default organization
|
|
1337
|
+
const org = await pb.collection('organizations').create({
|
|
1338
|
+
name: `${userRecord.name || 'User'}'s Organization`,
|
|
1339
|
+
slug: `${userRecord.email?.split('@')[0] || 'user'}-${Date.now()}`,
|
|
1340
|
+
ownerId: userId,
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
return org as Organization;
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
console.error('Failed to initialize organization:', error);
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
### Step 2: Update PocketBaseProvider to initialize organization
|
|
1352
|
+
|
|
1353
|
+
**File:** `host/src/kernel/providers/PocketBaseProvider.tsx`
|
|
1354
|
+
|
|
1355
|
+
Add organization initialization after auth:
|
|
1356
|
+
|
|
1357
|
+
```typescript
|
|
1358
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
1359
|
+
import PocketBase from 'pocketbase';
|
|
1360
|
+
import { useGlobalKernelState } from '../shared-state';
|
|
1361
|
+
import { initializeFirstOrganization } from '../rbac/utils';
|
|
1362
|
+
|
|
1363
|
+
const PocketBaseContext = createContext<PocketBase | null>(null);
|
|
1364
|
+
|
|
1365
|
+
export function PocketBaseProvider({ children }: { children: React.ReactNode }) {
|
|
1366
|
+
const [pb, setPb] = useState<PocketBase | null>(null);
|
|
1367
|
+
const { token, clearAuth, setOrganization } = useGlobalKernelState();
|
|
1368
|
+
|
|
1369
|
+
useEffect(() => {
|
|
1370
|
+
// Initialize PocketBase client
|
|
1371
|
+
const client = new PocketBase(import.meta.env.VITE_POCKETBASE_URL || 'http://127.0.0.1:8090');
|
|
1372
|
+
|
|
1373
|
+
// Load stored token
|
|
1374
|
+
const storedToken = localStorage.getItem('pocketbase_auth');
|
|
1375
|
+
if (storedToken) {
|
|
1376
|
+
client.authStore.save(storedToken, null);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
setPb(client);
|
|
1380
|
+
|
|
1381
|
+
// Set up auth cleanup on token invalidation
|
|
1382
|
+
client.authStore.onChange(async (token, model) => {
|
|
1383
|
+
if (!token) {
|
|
1384
|
+
clearAuth();
|
|
1385
|
+
localStorage.removeItem('pocketbase_auth');
|
|
1386
|
+
setOrganization(null);
|
|
1387
|
+
} else {
|
|
1388
|
+
localStorage.setItem('pocketbase_auth', token);
|
|
1389
|
+
|
|
1390
|
+
// Initialize organization on login
|
|
1391
|
+
if (model && !pb.authStore.record?.organizationId) {
|
|
1392
|
+
const org = await initializeFirstOrganization(client, model.id);
|
|
1393
|
+
if (org) {
|
|
1394
|
+
setOrganization(org);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
return () => {
|
|
1401
|
+
// Cleanup on unmount
|
|
1402
|
+
};
|
|
1403
|
+
}, [clearAuth, setOrganization]);
|
|
1404
|
+
|
|
1405
|
+
// Update auth store when token changes in state
|
|
1406
|
+
useEffect(() => {
|
|
1407
|
+
if (pb && token && pb.authStore.token !== token) {
|
|
1408
|
+
pb.authStore.save(token, null);
|
|
1409
|
+
}
|
|
1410
|
+
}, [pb, token]);
|
|
1411
|
+
|
|
1412
|
+
if (!pb) {
|
|
1413
|
+
return null; // or a loading spinner
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
return (
|
|
1417
|
+
<PocketBaseContext.Provider value={pb}>
|
|
1418
|
+
{children}
|
|
1419
|
+
</PocketBaseContext.Provider>
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
export function usePocketBase() {
|
|
1424
|
+
const context = useContext(PocketBaseContext);
|
|
1425
|
+
if (!context) {
|
|
1426
|
+
throw new Error('usePocketBase must be used within PocketBaseProvider');
|
|
1427
|
+
}
|
|
1428
|
+
return context;
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
---
|
|
1433
|
+
|
|
1434
|
+
## Task 8: Create RBAC Module Barrel Export
|
|
1435
|
+
|
|
1436
|
+
**Files:**
|
|
1437
|
+
- Create: `host/src/kernel/rbac/index.ts`
|
|
1438
|
+
|
|
1439
|
+
### Step 1: Create barrel export
|
|
1440
|
+
|
|
1441
|
+
**File:** `host/src/kernel/rbac/index.ts`
|
|
1442
|
+
|
|
1443
|
+
```typescript
|
|
1444
|
+
export * from './types';
|
|
1445
|
+
export * from './service';
|
|
1446
|
+
export * from './hooks';
|
|
1447
|
+
export * from './components/PermissionGate';
|
|
1448
|
+
export * from './components/OrganizationSelector';
|
|
1449
|
+
export * from './utils';
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
---
|
|
1453
|
+
|
|
1454
|
+
## Verification
|
|
1455
|
+
|
|
1456
|
+
### Step 1: Build the host
|
|
1457
|
+
|
|
1458
|
+
**Run:**
|
|
1459
|
+
|
|
1460
|
+
```bash
|
|
1461
|
+
cd host
|
|
1462
|
+
pnpm run build
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
Expected: Build completes without errors.
|
|
1466
|
+
|
|
1467
|
+
### Step 2: Start development server
|
|
1468
|
+
|
|
1469
|
+
**Run:**
|
|
1470
|
+
|
|
1471
|
+
```bash
|
|
1472
|
+
cd host
|
|
1473
|
+
pnpm run dev
|
|
1474
|
+
```
|
|
1475
|
+
|
|
1476
|
+
Expected: Server starts on http://localhost:8080
|
|
1477
|
+
|
|
1478
|
+
### Step 3: Test organization initialization
|
|
1479
|
+
|
|
1480
|
+
1. Login with admin credentials
|
|
1481
|
+
2. After login, organization selector should appear in topbar
|
|
1482
|
+
3. First organization should be auto-created
|
|
1483
|
+
4. Should see organization name in dropdown
|
|
1484
|
+
|
|
1485
|
+
### Step 4: Test organization switching (after implementing settings pages)
|
|
1486
|
+
|
|
1487
|
+
1. Open organization selector dropdown
|
|
1488
|
+
2. Should list all user's organizations
|
|
1489
|
+
3. Switching should update current organization context
|
|
1490
|
+
|
|
1491
|
+
---
|
|
1492
|
+
|
|
1493
|
+
## Summary
|
|
1494
|
+
|
|
1495
|
+
After completing this document, you will have:
|
|
1496
|
+
|
|
1497
|
+
1. ✅ Complete RBAC service with organizations, roles, permissions, and users
|
|
1498
|
+
2. ✅ Organization selector component in topbar
|
|
1499
|
+
3. ✅ Permission gate component for protecting UI elements
|
|
1500
|
+
4. ✅ Hooks for managing organizations, roles, users, and permissions
|
|
1501
|
+
5. ✅ Auto-initialization of first organization for new users
|
|
1502
|
+
6. ✅ Organization context in global state
|
|
1503
|
+
7. ✅ System roles (Owner, Admin, Member, Guest) with default permissions
|
|
1504
|
+
8. ✅ Support for custom roles and permissions
|
|
1505
|
+
|
|
1506
|
+
**Next:** `06-ui-components.md` - Complete Shadcn UI component library with all components.
|
|
1507
|
+
|
|
1508
|
+
---
|
|
1509
|
+
|
|
1510
|
+
## Files Created
|
|
1511
|
+
|
|
1512
|
+
```
|
|
1513
|
+
host/
|
|
1514
|
+
└── src/
|
|
1515
|
+
└── kernel/
|
|
1516
|
+
├── rbac/
|
|
1517
|
+
│ ├── types.ts
|
|
1518
|
+
│ ├── service.ts
|
|
1519
|
+
│ ├── hooks.ts
|
|
1520
|
+
│ ├── utils.ts
|
|
1521
|
+
│ ├── components/
|
|
1522
|
+
│ │ ├── OrganizationSelector.tsx
|
|
1523
|
+
│ │ └── PermissionGate.tsx
|
|
1524
|
+
│ └── index.ts
|
|
1525
|
+
└── providers/
|
|
1526
|
+
└── PocketBaseProvider.tsx (modified)
|
|
1527
|
+
```
|