@checkstack/auth-frontend 0.1.0 → 0.3.0

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.
@@ -1,95 +1,141 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import { authPlugin } from "./index";
3
- import { permissionApiRef } from "@checkstack/frontend-api";
4
- import { usePermissions } from "./hooks/usePermissions";
3
+ import { accessApiRef } from "@checkstack/frontend-api";
4
+ import type { AccessRule } from "@checkstack/common";
5
+ import { useAccessRules } from "./hooks/useAccessRules";
5
6
 
6
- // Mock the usePermissions hook
7
- mock.module("./hooks/usePermissions", () => ({
8
- usePermissions: mock(),
7
+ // Mock the useAccessRules hook
8
+ mock.module("./hooks/useAccessRules", () => ({
9
+ useAccessRules: mock(),
9
10
  }));
10
11
 
11
- describe("AuthPermissionApi", () => {
12
- let permissionApi: {
13
- usePermission: (p: string) => { loading: boolean; allowed: boolean };
12
+ // Test access rule objects
13
+ const testReadAccess: AccessRule = {
14
+ id: "test.read",
15
+ resource: "test",
16
+ level: "read",
17
+ description: "Test read access",
18
+ };
19
+
20
+ const testManageAccess: AccessRule = {
21
+ id: "test.manage",
22
+ resource: "test",
23
+ level: "manage",
24
+ description: "Test manage access",
25
+ };
26
+
27
+ const otherAccess: AccessRule = {
28
+ id: "other.read",
29
+ resource: "other",
30
+ level: "read",
31
+ description: "Other read access",
32
+ };
33
+
34
+ describe("AuthAccessApi", () => {
35
+ let accessApi: {
36
+ useAccess: (p: AccessRule) => { loading: boolean; allowed: boolean };
14
37
  };
15
38
 
16
39
  beforeEach(() => {
17
- const apiDef = authPlugin.apis?.find(
18
- (a) => a.ref.id === permissionApiRef.id
19
- );
20
- if (!apiDef) throw new Error("Permission API not found in plugin");
21
- permissionApi = apiDef.factory({ get: () => ({}) } as any) as any;
40
+ const apiDef = authPlugin.apis?.find((a) => a.ref.id === accessApiRef.id);
41
+ if (!apiDef) throw new Error("Access API not found in plugin");
42
+ accessApi = apiDef.factory({ get: () => ({}) } as any) as any;
22
43
  });
23
44
 
24
- it("should return true if user has the permission", () => {
25
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
26
- permissions: ["test.permission"],
45
+ it("should return true if user has the access rule", () => {
46
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
47
+ accessRules: ["test.read"],
27
48
  loading: false,
28
49
  });
29
50
 
30
- expect(permissionApi.usePermission("test.permission")).toEqual({
51
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
31
52
  loading: false,
32
53
  allowed: true,
33
54
  });
34
55
  });
35
56
 
36
- it("should return false if user is missing the permission", () => {
37
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
38
- permissions: ["other.permission"],
57
+ it("should return false if user is missing the access rule", () => {
58
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
59
+ accessRules: ["other.read"],
39
60
  loading: false,
40
61
  });
41
62
 
42
- expect(permissionApi.usePermission("test.permission")).toEqual({
63
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
43
64
  loading: false,
44
65
  allowed: false,
45
66
  });
46
67
  });
47
68
 
48
- it("should return false if no session data (no permissions)", () => {
49
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
50
- permissions: [],
69
+ it("should return false if no session data (no access rules)", () => {
70
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
71
+ accessRules: [],
51
72
  loading: false,
52
73
  });
53
74
 
54
- expect(permissionApi.usePermission("test.permission")).toEqual({
75
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
55
76
  loading: false,
56
77
  allowed: false,
57
78
  });
58
79
  });
59
80
 
60
- it("should return false if no user permissions (empty array)", () => {
61
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
62
- permissions: [],
81
+ it("should return false if no user access rules (empty array)", () => {
82
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
83
+ accessRules: [],
63
84
  loading: false,
64
85
  });
65
86
 
66
- expect(permissionApi.usePermission("test.permission")).toEqual({
87
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
67
88
  loading: false,
68
89
  allowed: false,
69
90
  });
70
91
  });
71
92
 
72
- it("should return true if user has the wildcard permission", () => {
73
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
74
- permissions: ["*"],
93
+ it("should return true if user has the wildcard access rule", () => {
94
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
95
+ accessRules: ["*"],
75
96
  loading: false,
76
97
  });
77
98
 
78
- expect(permissionApi.usePermission("any.permission")).toEqual({
99
+ expect(accessApi.useAccess(otherAccess)).toEqual({
79
100
  loading: false,
80
101
  allowed: true,
81
102
  });
82
103
  });
83
104
 
84
- it("should return loading state if permissions are loading", () => {
85
- (usePermissions as ReturnType<typeof mock>).mockReturnValue({
86
- permissions: [],
105
+ it("should return true if user has manage access for a manage check", () => {
106
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
107
+ accessRules: ["test.manage"],
108
+ loading: false,
109
+ });
110
+
111
+ expect(accessApi.useAccess(testManageAccess)).toEqual({
112
+ loading: false,
113
+ allowed: true,
114
+ });
115
+ });
116
+
117
+ it("should return loading state if access rules are loading", () => {
118
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
119
+ accessRules: [],
87
120
  loading: true,
88
121
  });
89
122
 
90
- expect(permissionApi.usePermission("test.permission")).toEqual({
123
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
91
124
  loading: true,
92
125
  allowed: false,
93
126
  });
94
127
  });
128
+
129
+ it("should return true if user has manage access for a read check", () => {
130
+ (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
131
+ accessRules: ["test.manage"],
132
+ loading: false,
133
+ });
134
+
135
+ // User has test.manage, which implies test.read
136
+ expect(accessApi.useAccess(testReadAccess)).toEqual({
137
+ loading: false,
138
+ allowed: true,
139
+ });
140
+ });
95
141
  });
package/src/index.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
2
  import {
3
3
  ApiRef,
4
- permissionApiRef,
5
- PermissionApi,
4
+ accessApiRef,
5
+ AccessApi,
6
6
  createFrontendPlugin,
7
7
  createSlotExtension,
8
8
  NavbarRightSlot,
@@ -22,71 +22,52 @@ import { ChangePasswordPage } from "./components/ChangePasswordPage";
22
22
  import { authApiRef, AuthApi, AuthSession } from "./api";
23
23
  import { getAuthClientLazy } from "./lib/auth-client";
24
24
 
25
- import { usePermissions } from "./hooks/usePermissions";
25
+ import { useAccessRules } from "./hooks/useAccessRules";
26
26
 
27
- import { PermissionAction, qualifyPermissionId } from "@checkstack/common";
27
+ import type { AccessRule } from "@checkstack/common";
28
28
  import { useNavigate } from "react-router-dom";
29
29
  import { Settings2, Key } from "lucide-react";
30
30
  import { DropdownMenuItem } from "@checkstack/ui";
31
31
  import { UserMenuItemsContext } from "@checkstack/frontend-api";
32
32
  import { AuthSettingsPage } from "./components/AuthSettingsPage";
33
33
  import {
34
- permissions as authPermissions,
34
+ authAccess,
35
35
  authRoutes,
36
36
  pluginMetadata,
37
37
  } from "@checkstack/auth-common";
38
38
  import { resolveRoute } from "@checkstack/common";
39
39
 
40
- class AuthPermissionApi implements PermissionApi {
41
- usePermission(permission: string): { loading: boolean; allowed: boolean } {
42
- const { permissions, loading } = usePermissions();
43
-
44
- if (loading) {
45
- return { loading: true, allowed: false };
46
- }
47
-
48
- // If no user, or user has no permissions, return false
49
- if (!permissions || permissions.length === 0) {
50
- return { loading: false, allowed: false };
51
- }
52
- const allowed =
53
- permissions.includes("*") || permissions.includes(permission);
54
- return { loading: false, allowed };
55
- }
56
-
57
- useResourcePermission(
58
- resource: string,
59
- action: PermissionAction
60
- ): { loading: boolean; allowed: boolean } {
61
- const { permissions, loading } = usePermissions();
40
+ /**
41
+ * Unified access API implementation.
42
+ * Uses AccessRule objects for access checks.
43
+ */
44
+ class AuthAccessApi implements AccessApi {
45
+ useAccess(accessRule: AccessRule): { loading: boolean; allowed: boolean } {
46
+ const { accessRules, loading } = useAccessRules();
62
47
 
63
48
  if (loading) {
64
49
  return { loading: true, allowed: false };
65
50
  }
66
51
 
67
- if (!permissions || permissions.length === 0) {
52
+ // If no user, or user has no access rules, return false
53
+ if (!accessRules || accessRules.length === 0) {
68
54
  return { loading: false, allowed: false };
69
55
  }
70
56
 
71
- const isWildcard = permissions.includes("*");
72
- const hasResourceManage = permissions.includes(`${resource}.manage`);
73
- const hasSpecificPermission = permissions.includes(`${resource}.${action}`);
57
+ const accessRuleId = accessRule.id;
74
58
 
75
- // manage implies read
76
- const isAllowed =
77
- isWildcard ||
78
- hasResourceManage ||
79
- (action === "read" && hasResourceManage) ||
80
- hasSpecificPermission;
59
+ // Check wildcard, exact match, or manage implies read
60
+ const isWildcard = accessRules.includes("*");
61
+ const hasExact = accessRules.includes(accessRuleId);
81
62
 
82
- return { loading: false, allowed: isAllowed };
83
- }
63
+ // For read actions, also check if user has manage access for the same resource
64
+ const hasManage =
65
+ accessRule.level === "read"
66
+ ? accessRules.includes(`${accessRule.resource}.manage`)
67
+ : false;
84
68
 
85
- useManagePermission(resource: string): {
86
- loading: boolean;
87
- allowed: boolean;
88
- } {
89
- return this.useResourcePermission(resource, "manage");
69
+ const allowed = isWildcard || hasExact || hasManage;
70
+ return { loading: false, allowed };
90
71
  }
91
72
  }
92
73
 
@@ -180,8 +161,8 @@ export const authPlugin = createFrontendPlugin({
180
161
  factory: () => new BetterAuthApi(),
181
162
  },
182
163
  {
183
- ref: permissionApiRef as ApiRef<unknown>,
184
- factory: () => new AuthPermissionApi(),
164
+ ref: accessApiRef as ApiRef<unknown>,
165
+ factory: () => new AuthAccessApi(),
185
166
  },
186
167
  ],
187
168
  routes: [
@@ -222,12 +203,9 @@ export const authPlugin = createFrontendPlugin({
222
203
  },
223
204
  createSlotExtension(UserMenuItemsSlot, {
224
205
  id: "auth.user-menu.settings",
225
- component: ({ permissions: userPerms }: UserMenuItemsContext) => {
206
+ component: ({ accessRules: userPerms }: UserMenuItemsContext) => {
226
207
  const navigate = useNavigate();
227
- const qualifiedId = qualifyPermissionId(
228
- pluginMetadata,
229
- authPermissions.strategiesManage
230
- );
208
+ const qualifiedId = `${pluginMetadata.pluginId}.${authAccess.strategies.id}`;
231
209
  const canManage =
232
210
  userPerms.includes("*") || userPerms.includes(qualifiedId);
233
211
 
@@ -1,10 +1,13 @@
1
1
  import { useMemo } from "react";
2
2
  import { createAuthClient } from "better-auth/react";
3
- import { useRuntimeConfig } from "@checkstack/frontend-api";
3
+ import {
4
+ useRuntimeConfig,
5
+ getCachedRuntimeConfig,
6
+ } from "@checkstack/frontend-api";
4
7
 
5
8
  // Cache for lazy-initialized client
6
9
  let cachedClient: ReturnType<typeof createAuthClient> | undefined;
7
- let configPromise: Promise<string> | undefined;
10
+ let cachedBaseUrl: string | undefined;
8
11
 
9
12
  /**
10
13
  * React hook to get the auth client with proper runtime config.
@@ -25,31 +28,23 @@ export function useAuthClient() {
25
28
 
26
29
  /**
27
30
  * Lazy-initialized auth client for class-based APIs.
28
- * Fetches config from /api/config if not already cached.
29
- * Use useAuthClient hook in React components instead.
31
+ * Uses the cached runtime config from RuntimeConfigProvider.
32
+ *
33
+ * Note: This should only be called AFTER RuntimeConfigProvider has loaded.
34
+ * Components rendered inside the provider tree are guaranteed to have config available.
30
35
  */
31
36
  export function getAuthClientLazy(): ReturnType<typeof createAuthClient> {
32
- if (!cachedClient) {
33
- // Create with default URL initially
37
+ const config = getCachedRuntimeConfig();
38
+ const baseUrl = config?.baseUrl ?? "http://localhost:3000";
39
+
40
+ // Recreate client if baseUrl changed or not yet created
41
+ if (!cachedClient || cachedBaseUrl !== baseUrl) {
42
+ cachedBaseUrl = baseUrl;
34
43
  cachedClient = createAuthClient({
35
- baseURL: "http://localhost:3000",
44
+ baseURL: baseUrl,
36
45
  basePath: "/api/auth",
37
46
  });
38
-
39
- // Fetch real config and update
40
- if (!configPromise) {
41
- configPromise = fetch("/api/config")
42
- .then((res) => res.json())
43
- .then((data: { baseUrl: string }) => data.baseUrl)
44
- .catch(() => "http://localhost:3000");
45
- }
46
-
47
- configPromise.then((baseUrl) => {
48
- cachedClient = createAuthClient({
49
- baseURL: baseUrl,
50
- basePath: "/api/auth",
51
- });
52
- });
53
47
  }
48
+
54
49
  return cachedClient;
55
50
  }
@@ -1,43 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { useAuthClient } from "../lib/auth-client";
3
- import { rpcApiRef, useApi } from "@checkstack/frontend-api";
4
- import { AuthApi } from "@checkstack/auth-common";
5
-
6
- export const usePermissions = () => {
7
- const authClient = useAuthClient();
8
- const { data: session, isPending: sessionPending } = authClient.useSession();
9
- const [permissions, setPermissions] = useState<string[]>([]);
10
- const [loading, setLoading] = useState(true);
11
- const rpcApi = useApi(rpcApiRef);
12
-
13
- useEffect(() => {
14
- // Don't set loading=false while session is still pending
15
- // This prevents "Access Denied" flash during initial page load
16
- if (sessionPending) {
17
- return;
18
- }
19
-
20
- if (!session?.user) {
21
- setPermissions([]);
22
- setLoading(false);
23
- return;
24
- }
25
-
26
- const fetchPermissions = async () => {
27
- try {
28
- const authRpc = rpcApi.forPlugin(AuthApi);
29
- const data = await authRpc.permissions();
30
- if (Array.isArray(data.permissions)) {
31
- setPermissions(data.permissions);
32
- }
33
- } catch (error) {
34
- console.error("Failed to fetch permissions", error);
35
- } finally {
36
- setLoading(false);
37
- }
38
- };
39
- fetchPermissions();
40
- }, [session?.user?.id, sessionPending, rpcApi]);
41
-
42
- return { permissions, loading };
43
- };