@lego-box/shell 1.0.5 → 1.0.6

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.
Files changed (45) hide show
  1. package/dist/emulator/lego-box-shell-1.0.6.tgz +0 -0
  2. package/package.json +2 -3
  3. package/src/auth/auth-store.ts +33 -0
  4. package/src/auth/auth.ts +176 -0
  5. package/src/components/ProtectedPage.tsx +48 -0
  6. package/src/config/env.node.ts +38 -0
  7. package/src/config/env.ts +103 -0
  8. package/src/context/AbilityContext.tsx +213 -0
  9. package/src/context/PiralInstanceContext.tsx +17 -0
  10. package/src/hooks/index.ts +11 -0
  11. package/src/hooks/useAuditLogs.ts +190 -0
  12. package/src/hooks/useDebounce.ts +34 -0
  13. package/src/hooks/usePermissionGuard.tsx +39 -0
  14. package/src/hooks/usePermissions.ts +190 -0
  15. package/src/hooks/useRoles.ts +233 -0
  16. package/src/hooks/useTickets.ts +214 -0
  17. package/src/hooks/useUserLogins.ts +39 -0
  18. package/src/hooks/useUsers.ts +252 -0
  19. package/src/index.html +16 -0
  20. package/src/index.tsx +296 -0
  21. package/src/layout.tsx +246 -0
  22. package/src/migrations/config.ts +62 -0
  23. package/src/migrations/dev-migrations.ts +75 -0
  24. package/src/migrations/index.ts +13 -0
  25. package/src/migrations/run-migrations.ts +187 -0
  26. package/src/migrations/runner.ts +925 -0
  27. package/src/migrations/types.ts +207 -0
  28. package/src/migrations/utils.ts +264 -0
  29. package/src/pages/AuditLogsPage.tsx +378 -0
  30. package/src/pages/ContactSupportPage.tsx +610 -0
  31. package/src/pages/LandingPage.tsx +221 -0
  32. package/src/pages/LoginPage.tsx +217 -0
  33. package/src/pages/MigrationsPage.tsx +1364 -0
  34. package/src/pages/ProfilePage.tsx +335 -0
  35. package/src/pages/SettingsPage.tsx +101 -0
  36. package/src/pages/SystemHealthCheckPage.tsx +144 -0
  37. package/src/pages/UserManagementPage.tsx +1010 -0
  38. package/src/piral/api.ts +39 -0
  39. package/src/piral/auth-casl.ts +56 -0
  40. package/src/piral/menu.ts +102 -0
  41. package/src/piral/piral.json +4 -0
  42. package/src/services/telemetry.ts +37 -0
  43. package/src/styles/globals.css +1351 -0
  44. package/src/utils/auditLogger.ts +68 -0
  45. package/dist/emulator/lego-box-shell-1.0.5.tgz +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lego-box/shell",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Piral microfrontend shell for Lego Box - provides authentication, PocketBase integration, RBAC, and shared UI components",
5
5
  "keywords": [
6
6
  "piral",
@@ -25,8 +25,7 @@
25
25
  "files": [
26
26
  "dist/emulator",
27
27
  "pilet.ts",
28
- "src/types.ts",
29
- "src/auth/ability.ts",
28
+ "src",
30
29
  "README.md"
31
30
  ],
32
31
  "main": "dist/emulator/index.js",
@@ -0,0 +1,33 @@
1
+ import type { AppAbility } from './ability';
2
+ import type { PermissionString } from '../types';
3
+
4
+ /**
5
+ * Module-level store for auth/CASL data.
6
+ * AbilityProvider populates this; plugins and pilets read from it.
7
+ */
8
+ let abilityRef: AppAbility | null = null;
9
+ let roleNameRef: string | null = null;
10
+ let permissionsRef: PermissionString[] = [];
11
+
12
+ export function setAuthCaslStore(ability: AppAbility, roleName: string | null, permissions: PermissionString[]) {
13
+ abilityRef = ability;
14
+ roleNameRef = roleName;
15
+ permissionsRef = permissions;
16
+ }
17
+
18
+ export function getAbility(): AppAbility | null {
19
+ return abilityRef;
20
+ }
21
+
22
+ export function getRoleName(): string | null {
23
+ return roleNameRef;
24
+ }
25
+
26
+ export function getPermissions(): PermissionString[] {
27
+ return permissionsRef;
28
+ }
29
+
30
+ export function can(action: string, subject: string): boolean {
31
+ if (!abilityRef) return false;
32
+ return abilityRef.can(action, subject);
33
+ }
@@ -0,0 +1,176 @@
1
+ import type { PiralPlugin } from 'piral';
2
+ import type PocketBase from 'pocketbase';
3
+ import type { AuthUser, UserType } from '../types';
4
+ import { trackUserLogin } from '../services/telemetry';
5
+
6
+ /** piral-auth UserInfo shape (id, firstName, lastName, mail, language, permissions, features). */
7
+ interface UserInfo {
8
+ id: string;
9
+ firstName: string;
10
+ lastName: string;
11
+ mail: string;
12
+ language: string;
13
+ permissions: Record<string, unknown>;
14
+ features: Record<string, boolean>;
15
+ }
16
+
17
+ function authUserToUserInfo(user: AuthUser, userType: UserType): UserInfo {
18
+ const parts = user.name.trim().split(/\s+/);
19
+ const firstName = parts[0] ?? user.name;
20
+ const lastName = parts.slice(1).join(' ') ?? '';
21
+ return {
22
+ id: user.id,
23
+ firstName,
24
+ lastName,
25
+ mail: user.email,
26
+ language: '',
27
+ permissions: { userType: userType ?? 'user' },
28
+ features: userType === 'admin' ? { admin: true } : {},
29
+ };
30
+ }
31
+
32
+ function userInfoToAuthUser(info: UserInfo | undefined): AuthUser | undefined {
33
+ if (!info) return undefined;
34
+ const name = [info.firstName, info.lastName].filter(Boolean).join(' ').trim() || info.mail;
35
+ return {
36
+ id: info.id,
37
+ email: info.mail,
38
+ name,
39
+ };
40
+ }
41
+
42
+ /** Minimal shape needed to derive UserType (permissions.userType or features.admin). */
43
+ export type UserInfoLike = { permissions?: Record<string, unknown>; features?: Record<string, boolean> };
44
+
45
+ /** Derives UserType from piral-auth UserInfo (permissions.userType or features.admin). */
46
+ export function getUserTypeFromUserInfo(info: UserInfo | UserInfoLike | undefined): UserType {
47
+ if (!info) return null;
48
+ const t = info.permissions?.userType;
49
+ if (t === 'admin' || t === 'user') return t;
50
+ return info.features?.admin ? 'admin' : 'user';
51
+ }
52
+
53
+ /** piral-auth UserInfo for createAuthApi({ user }) initial state. */
54
+ export interface UserInfoForPiralAuth {
55
+ id: string;
56
+ firstName: string;
57
+ lastName: string;
58
+ mail: string;
59
+ language: string;
60
+ permissions: Record<string, unknown>;
61
+ features: Record<string, boolean>;
62
+ }
63
+
64
+ /**
65
+ * Builds initial user for piral-auth from PocketBase authStore (localStorage by default).
66
+ * Call before createInstance and pass to createAuthApi({ user }) so auth persists on refresh.
67
+ */
68
+ export function getInitialUserFromPb(
69
+ pb: PocketBase
70
+ ): UserInfoForPiralAuth | undefined {
71
+ if (typeof document === 'undefined' || !pb.authStore.isValid || !pb.authStore.model) {
72
+ return undefined;
73
+ }
74
+ const model = pb.authStore.model as Record<string, unknown>;
75
+ const email = String(model.email ?? '');
76
+ const name = String(model.name ?? model.email ?? email);
77
+ const id = String(model.id ?? 'admin');
78
+ // PocketBase: users have collectionId; admins do not (see docs)
79
+ const isAdmin = !('collectionId' in model && model.collectionId);
80
+ const userType: UserType = isAdmin ? 'admin' : 'user';
81
+ const authUser: AuthUser = { id, email, name };
82
+ return authUserToUserInfo(authUser, userType);
83
+ }
84
+
85
+ /**
86
+ * Creates a Piral plugin that adds PocketBase-backed auth: login (user then admin),
87
+ * logout, isAuthenticated, getUser, getUserId, getUserType. Syncs with piral-auth state via setUser.
88
+ */
89
+ export function createPocketBaseAuthPlugin(
90
+ pb: PocketBase
91
+ ): PiralPlugin<Record<string, unknown>> {
92
+ return (context) => {
93
+ const setUser = (context as unknown as { setUser?: (u: UserInfo | undefined) => void }).setUser;
94
+
95
+ // Keep Piral auth state in sync with PocketBase authStore (e.g. restore from localStorage, or clear)
96
+ if (typeof setUser === 'function' && typeof pb.authStore.onChange === 'function') {
97
+ pb.authStore.onChange(() => {
98
+ const next = getInitialUserFromPb(pb);
99
+ setUser(next as UserInfo | undefined);
100
+ });
101
+ }
102
+
103
+ return {
104
+ isAuthenticated() {
105
+ return pb.authStore.isValid;
106
+ },
107
+ getUser(): AuthUser | undefined {
108
+ const state = context.readState((s: unknown) => (s as { user?: UserInfo }).user);
109
+ return userInfoToAuthUser(state);
110
+ },
111
+ getUserId(): string | null {
112
+ const user = context.readState((s: unknown) => (s as { user?: UserInfo }).user);
113
+ return user?.id ?? null;
114
+ },
115
+ getUserType(): UserType {
116
+ const user = context.readState((s: unknown) => (s as { user?: UserInfo }).user);
117
+ return getUserTypeFromUserInfo(user);
118
+ },
119
+ async login(email: string, password: string) {
120
+ try {
121
+ const authData = await pb.collection('users').authWithPassword(email, password);
122
+ const record = authData.record as Record<string, unknown>;
123
+
124
+ // Check if user is active
125
+ const isActive = record.isActive !== false; // Default to true if not set
126
+ if (!isActive) {
127
+ // Clear the auth store since we don't want inactive users to be logged in
128
+ pb.authStore.clear();
129
+
130
+ // Track failed login due to inactive account
131
+ await trackUserLogin(pb, {
132
+ userId: String(record.id),
133
+ emailAttempted: email,
134
+ success: false,
135
+ failureReason: 'Account is deactivated',
136
+ });
137
+
138
+ throw new Error('Your account has been deactivated. Please contact an administrator.');
139
+ }
140
+
141
+ const authUser: AuthUser = {
142
+ id: String(record.id),
143
+ email: String(record.email ?? email),
144
+ name: String(record.name ?? record.email ?? email),
145
+ avatar: record.avatar as string | undefined,
146
+ };
147
+ const userInfo = authUserToUserInfo(authUser, 'user');
148
+ (context as unknown as { setUser?: (u: UserInfo) => void }).setUser?.(userInfo);
149
+
150
+ // Track successful login
151
+ await trackUserLogin(pb, {
152
+ userId: authUser.id,
153
+ emailAttempted: email,
154
+ success: true,
155
+ });
156
+ } catch (userErr) {
157
+ // Re-throw our deactivation error as-is
158
+ if (userErr instanceof Error && userErr.message.includes('deactivated')) {
159
+ throw userErr;
160
+ }
161
+ // Track failed login (invalid credentials)
162
+ await trackUserLogin(pb, {
163
+ emailAttempted: email,
164
+ success: false,
165
+ failureReason: 'Invalid email or password',
166
+ });
167
+ throw new Error('Invalid email or password');
168
+ }
169
+ },
170
+ logout() {
171
+ pb.authStore.clear();
172
+ (context as unknown as { setUser?: (u: UserInfo | undefined) => void }).setUser?.(undefined as unknown as UserInfo);
173
+ },
174
+ };
175
+ };
176
+ }
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import { useGlobalStateContext } from 'piral';
3
+ import { AccessDenied, Loading } from '@lego-box/ui-kit';
4
+ import { useCan, useAbilityLoading } from '../context/AbilityContext';
5
+
6
+ export interface ProtectedPageProps {
7
+ /** CASL action (e.g. "read") */
8
+ action: string;
9
+ /** CASL subject (e.g. "migrations") */
10
+ subject: string;
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ /**
15
+ * ProtectedPage - Wraps a page and enforces permission. If the user lacks the required
16
+ * permission, shows a static Access Denied page. Use for route-level protection.
17
+ */
18
+ export function ProtectedPage({
19
+ action,
20
+ subject,
21
+ children,
22
+ }: ProtectedPageProps) {
23
+ const can = useCan();
24
+ const loading = useAbilityLoading();
25
+ const ctx = useGlobalStateContext();
26
+ const allowed = can(action, subject);
27
+
28
+ const handleGoHome = () => {
29
+ ctx?.navigation?.replace?.('/');
30
+ };
31
+
32
+ if (loading) {
33
+ return <Loading message="Checking permissions..." fullPage={true} />;
34
+ }
35
+
36
+ if (!allowed) {
37
+ return (
38
+ <AccessDenied
39
+ title="Access Denied"
40
+ description="You don't have permission to view this page."
41
+ onNavigateHome={handleGoHome}
42
+ homeButtonText="Go Back Home"
43
+ />
44
+ );
45
+ }
46
+
47
+ return <>{children}</>;
48
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Environment config for Node scripts (migrations, CLI).
3
+ * Uses only process.env and require('dotenv') so it works under ts-node (CommonJS).
4
+ * Same env var names and defaults as env.ts so .env works for both app and migrations.
5
+ */
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ require('dotenv').config();
8
+
9
+ export interface EnvConfig {
10
+ pocketbaseUrl: string;
11
+ adminEmail: string;
12
+ adminPassword: string;
13
+ appName: string;
14
+ appIdentifier: string;
15
+ }
16
+
17
+ export const env: EnvConfig = {
18
+ pocketbaseUrl:
19
+ process.env.VITE_POCKETBASE_URL ||
20
+ process.env.POCKETBASE_URL ||
21
+ 'http://localhost:8090',
22
+ adminEmail:
23
+ process.env.POCKETBASE_ADMIN_EMAIL ||
24
+ process.env.VITE_POCKETBASE_ADMIN_EMAIL ||
25
+ 'superuser@legobox.local',
26
+ adminPassword:
27
+ process.env.POCKETBASE_ADMIN_PASSWORD ||
28
+ process.env.VITE_POCKETBASE_ADMIN_PASSWORD ||
29
+ 'SuperUser123!',
30
+ appName:
31
+ process.env.VITE_APP_NAME ||
32
+ process.env.APP_NAME ||
33
+ 'Lego Box',
34
+ appIdentifier:
35
+ process.env.VITE_APP_IDENTIFIER ||
36
+ process.env.APP_IDENTIFIER ||
37
+ 'lego-box-default',
38
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Environment configuration for the browser app.
3
+ * Uses process.env (injected by Webpack DefinePlugin) for build-time env vars.
4
+ *
5
+ * Feed URL format for local pilets: `http://localhost:{PORT}/$pilet-api`
6
+ * Feed URL format for remote feeds: `https://feed.example.com/api/v1/pilet`
7
+ *
8
+ * To configure multiple feeds via environment variable, set VITE_FEED_URLS to a JSON array:
9
+ * VITE_FEED_URLS=["http://localhost:9000/$pilet-api", "http://localhost:9001/$pilet-api"]
10
+ */
11
+
12
+ /**
13
+ * Environment configuration consumed by the browser app.
14
+ *
15
+ * @property pocketbaseUrl - PocketBase instance URL for API requests
16
+ * @property adminEmail - Admin email for PocketBase authentication
17
+ * @property adminPassword - Admin password for PocketBase authentication
18
+ * @property appName - Human-readable application display name
19
+ * @property appIdentifier - Unique identifier for this app instance
20
+ * @property feedUrls - Array of feed URLs to aggregate pilets from (local or remote)
21
+ */
22
+ export interface EnvConfig {
23
+ pocketbaseUrl: string;
24
+ adminEmail: string;
25
+ adminPassword: string;
26
+ appName: string;
27
+ appIdentifier: string;
28
+ feedUrls: string[];
29
+ }
30
+
31
+ /** Simple URL validation (handles http, https, localhost) */
32
+ function isValidUrl(s: string): boolean {
33
+ try {
34
+ const u = new URL(s);
35
+ return u.protocol === 'http:' || u.protocol === 'https:';
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** Validates raw env config; throws with message on failure */
42
+ function validateEnvConfig(raw: EnvConfig): EnvConfig {
43
+ const errors: string[] = [];
44
+ if (!raw.pocketbaseUrl || typeof raw.pocketbaseUrl !== 'string' || !raw.pocketbaseUrl.trim()) {
45
+ errors.push('pocketbaseUrl: required non-empty string');
46
+ }
47
+ if (!raw.adminEmail || typeof raw.adminEmail !== 'string' || !raw.adminEmail.trim()) {
48
+ errors.push('adminEmail: required non-empty string');
49
+ }
50
+ if (typeof raw.adminPassword !== 'string') {
51
+ errors.push('adminPassword: required string');
52
+ }
53
+ if (!raw.appName || typeof raw.appName !== 'string' || !raw.appName.trim()) {
54
+ errors.push('appName: required non-empty string');
55
+ }
56
+ if (!raw.appIdentifier || typeof raw.appIdentifier !== 'string' || !raw.appIdentifier.trim()) {
57
+ errors.push('appIdentifier: required non-empty string');
58
+ }
59
+ if (!Array.isArray(raw.feedUrls)) {
60
+ errors.push('feedUrls: required array');
61
+ } else if (!raw.feedUrls.every((u) => typeof u === 'string' && isValidUrl(u))) {
62
+ errors.push('feedUrls: all elements must be valid URLs');
63
+ }
64
+ if (errors.length > 0) {
65
+ throw new Error(`Env config validation failed: ${errors.join('; ')}`);
66
+ }
67
+ return raw;
68
+ }
69
+
70
+ /**
71
+ * Build-time env vars (injected by Webpack DefinePlugin).
72
+ * MUST use static references only - e.g. process.env.VITE_FEED_URLS, not process.env[name].
73
+ * Dynamic access leaves process in the bundle and throws "process is not defined" in browser.
74
+ */
75
+ function parseFeedUrls(): string[] {
76
+ const viteRaw = process.env.VITE_FEED_URLS;
77
+ if (viteRaw && typeof viteRaw === 'string') {
78
+ try {
79
+ const parsed = JSON.parse(viteRaw) as unknown;
80
+ if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) {
81
+ return parsed as string[];
82
+ }
83
+ } catch {
84
+ /* fall through */
85
+ }
86
+ }
87
+ const feedUrl = process.env.FEED_URL;
88
+ if (feedUrl && typeof feedUrl === 'string' && feedUrl.trim()) {
89
+ return [feedUrl.trim()];
90
+ }
91
+ return ['http://localhost:9000/$pilet-api'];
92
+ }
93
+
94
+ const rawEnv: EnvConfig = {
95
+ pocketbaseUrl: process.env.VITE_POCKETBASE_URL || 'http://localhost:8090',
96
+ adminEmail: process.env.VITE_POCKETBASE_ADMIN_EMAIL || 'superuser@legobox.local',
97
+ adminPassword: process.env.VITE_POCKETBASE_ADMIN_PASSWORD || 'SuperUser123!',
98
+ appName: process.env.VITE_APP_NAME || 'Lego Box',
99
+ appIdentifier: process.env.VITE_APP_IDENTIFIER || 'lego-box-default',
100
+ feedUrls: parseFeedUrls(),
101
+ };
102
+
103
+ export const env: EnvConfig = validateEnvConfig(rawEnv);
@@ -0,0 +1,213 @@
1
+ import * as React from 'react';
2
+ import type PocketBase from 'pocketbase';
3
+ import {
4
+ defineAbilityFor,
5
+ parsePermissionString,
6
+ type AppAbility,
7
+ } from '../auth/ability';
8
+ import { setAuthCaslStore } from '../auth/auth-store';
9
+ import { usePiralInstance } from './PiralInstanceContext';
10
+
11
+ interface AbilityContextValue {
12
+ ability: AppAbility;
13
+ loading: boolean;
14
+ }
15
+
16
+ const AbilityContext = React.createContext<AbilityContextValue | null>(null);
17
+
18
+ /**
19
+ * AbilityProvider - Provides CASL ability instance throughout the React tree.
20
+ * Fetches current user and role permissions from PocketBase, builds ability,
21
+ * and subscribes to auth/role changes to rebuild when needed.
22
+ */
23
+ export function AbilityProvider({ children }: { children: React.ReactNode }) {
24
+ const instance = usePiralInstance();
25
+ const pb = React.useMemo(
26
+ () => (instance as unknown as { root?: { pocketbase?: PocketBase } })?.root?.pocketbase,
27
+ [instance]
28
+ );
29
+
30
+ const [ability, setAbility] = React.useState<AppAbility>(() =>
31
+ defineAbilityFor(null, [])
32
+ );
33
+ const [loading, setLoading] = React.useState(true);
34
+ const unsubRef = React.useRef<{ roles?: () => void; users?: () => void }>({});
35
+
36
+ const buildAbility = React.useCallback(async () => {
37
+ if (!pb) {
38
+ const empty = defineAbilityFor(null, []);
39
+ setAbility(empty);
40
+ setAuthCaslStore(empty, null, []);
41
+ setLoading(false);
42
+ return;
43
+ }
44
+
45
+ const model = pb.authStore.model as Record<string, unknown> | null;
46
+ if (!model) {
47
+ const empty = defineAbilityFor(null, []);
48
+ setAbility(empty);
49
+ setAuthCaslStore(empty, null, []);
50
+ setLoading(false);
51
+ return;
52
+ }
53
+
54
+ const isSuperuser = model.is_superuser === true;
55
+ const user = {
56
+ id: String(model.id ?? ''),
57
+ is_superuser: isSuperuser,
58
+ role: Array.isArray(model.role) ? model.role[0] : model.role,
59
+ } as { id: string; is_superuser?: boolean; role?: string };
60
+
61
+ if (isSuperuser) {
62
+ const ab = defineAbilityFor(user, []);
63
+ setAbility(ab);
64
+ setAuthCaslStore(ab, 'Superuser', ['manage:all']);
65
+ setLoading(false);
66
+ return;
67
+ }
68
+
69
+ const roleId =
70
+ typeof user.role === 'string'
71
+ ? user.role
72
+ : Array.isArray(user.role)
73
+ ? user.role[0]
74
+ : undefined;
75
+
76
+ if (!roleId) {
77
+ const ab = defineAbilityFor(user, []);
78
+ setAbility(ab);
79
+ setAuthCaslStore(ab, null, []);
80
+ setLoading(false);
81
+ return;
82
+ }
83
+
84
+ try {
85
+ const roleRecord = await pb.collection('roles').getOne(roleId, {
86
+ fields: 'permissions,name',
87
+ $autoCancel: false,
88
+ });
89
+ const permissionValues = Array.isArray(roleRecord.permissions)
90
+ ? (roleRecord.permissions as string[]).filter(
91
+ (p): p is string => typeof p === 'string' && p.length > 0
92
+ )
93
+ : [];
94
+
95
+ const permissionDefs: Array<{ action: string; subject: string }> = [];
96
+
97
+ // Split into permission names (e.g. "read:users") vs IDs (PocketBase record IDs)
98
+ const permissionNames = permissionValues.filter((v) => v.includes(':'));
99
+ const permissionIds = permissionValues.filter((v) => !v.includes(':'));
100
+
101
+ // Permission names: parse directly
102
+ for (const name of permissionNames) {
103
+ const parsed = parsePermissionString(name);
104
+ if (parsed) permissionDefs.push(parsed);
105
+ }
106
+
107
+ // Permission IDs: fetch records and map to action/subject
108
+ if (permissionIds.length > 0) {
109
+ const filter =
110
+ permissionIds.length === 1
111
+ ? `id = "${permissionIds[0]}"`
112
+ : `(${permissionIds.map((id) => `id = "${id}"`).join(' || ')})`;
113
+ const permRecords = await pb.collection('permissions').getFullList({
114
+ filter,
115
+ fields: 'action,collection,name',
116
+ $autoCancel: false,
117
+ });
118
+ for (const r of permRecords as Array<{ action?: string; collection?: string; name?: string }>) {
119
+ const action = r.action ?? parsePermissionString(r.name ?? '')?.action;
120
+ const subject = r.collection ?? parsePermissionString(r.name ?? '')?.subject;
121
+ if (action && subject) permissionDefs.push({ action, subject });
122
+ }
123
+ }
124
+
125
+ const roleName = (roleRecord as { name?: string }).name ?? null;
126
+ const permissionStrings: string[] = permissionDefs.map((p) => `${p.action}:${p.subject}`);
127
+ const ab = defineAbilityFor(user, permissionDefs);
128
+ setAbility(ab);
129
+ setAuthCaslStore(ab, roleName, permissionStrings);
130
+ } catch (err) {
131
+ console.error('[AbilityProvider] Error fetching role:', err);
132
+ const ab = defineAbilityFor(user, []);
133
+ setAbility(ab);
134
+ setAuthCaslStore(ab, null, []);
135
+ } finally {
136
+ setLoading(false);
137
+ }
138
+ }, [pb]);
139
+
140
+ React.useEffect(() => {
141
+ setLoading(true);
142
+ buildAbility();
143
+ }, [buildAbility]);
144
+
145
+ React.useEffect(() => {
146
+ if (!pb) return;
147
+
148
+ const onAuthChange = () => {
149
+ setLoading(true);
150
+ buildAbility();
151
+ };
152
+
153
+ const unsubscribe = pb.authStore.onChange(onAuthChange);
154
+
155
+ const ref = unsubRef.current;
156
+
157
+ pb.collection('roles')
158
+ .subscribe('*', onAuthChange)
159
+ .then((unsub) => {
160
+ ref.roles = unsub;
161
+ })
162
+ .catch(() => {});
163
+ pb.collection('users')
164
+ .subscribe('*', onAuthChange)
165
+ .then((unsub) => {
166
+ ref.users = unsub;
167
+ })
168
+ .catch(() => {});
169
+
170
+ return () => {
171
+ unsubscribe();
172
+ ref.roles?.();
173
+ ref.users?.();
174
+ ref.roles = undefined;
175
+ ref.users = undefined;
176
+ };
177
+ }, [pb, buildAbility]);
178
+
179
+ const value = React.useMemo(() => ({ ability, loading }), [ability, loading]);
180
+
181
+ return (
182
+ <AbilityContext.Provider value={value}>
183
+ {children}
184
+ </AbilityContext.Provider>
185
+ );
186
+ }
187
+
188
+ /** Returns the current ability instance from context. */
189
+ export function useAbility(): AppAbility {
190
+ const ctx = React.useContext(AbilityContext);
191
+ if (!ctx) {
192
+ throw new Error('useAbility must be used within AbilityProvider');
193
+ }
194
+ return ctx.ability;
195
+ }
196
+
197
+ /** Returns whether the ability is still loading (e.g. fetching permissions). */
198
+ export function useAbilityLoading(): boolean {
199
+ const ctx = React.useContext(AbilityContext);
200
+ if (!ctx) {
201
+ throw new Error('useAbilityLoading must be used within AbilityProvider');
202
+ }
203
+ return ctx.loading;
204
+ }
205
+
206
+ /** Convenience hook: returns (action, subject) => ability.can(action, subject). */
207
+ export function useCan(): (action: string, subject: string) => boolean {
208
+ const ability = useAbility();
209
+ return React.useCallback(
210
+ (action: string, subject: string) => ability.can(action, subject),
211
+ [ability]
212
+ );
213
+ }
@@ -0,0 +1,17 @@
1
+ import * as React from 'react';
2
+
3
+ /** Piral instance type (has context and root API). */
4
+ export interface PiralInstanceLike {
5
+ context: unknown;
6
+ root: unknown;
7
+ }
8
+
9
+ const PiralInstanceContext = React.createContext<PiralInstanceLike | null>(
10
+ null
11
+ );
12
+
13
+ export const PiralInstanceProvider = PiralInstanceContext.Provider;
14
+
15
+ export function usePiralInstance(): PiralInstanceLike | null {
16
+ return React.useContext(PiralInstanceContext);
17
+ }
@@ -0,0 +1,11 @@
1
+ export { useUsers, useUserCounts } from './useUsers';
2
+ export { useRoles } from './useRoles';
3
+ export { usePermissions } from './usePermissions';
4
+
5
+ export { useAbility, useCan, useAbilityLoading } from '../context/AbilityContext';
6
+ export { usePermissionGuard, PermissionGuard } from './usePermissionGuard';
7
+
8
+ export { useAuditLogs } from './useAuditLogs';
9
+ export { useDebounce } from './useDebounce';
10
+ export { useTickets } from './useTickets';
11
+ export { useUserLogins } from './useUserLogins';