@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.
- package/CHANGELOG.md +155 -0
- package/package.json +1 -1
- package/src/api.ts +2 -2
- package/src/components/ApplicationsTab.tsx +89 -90
- package/src/components/AuthSettingsPage.tsx +87 -100
- package/src/components/LoginPage.tsx +15 -22
- package/src/components/RegisterPage.tsx +17 -28
- package/src/components/RoleDialog.tsx +49 -53
- package/src/components/RolesTab.tsx +60 -47
- package/src/components/StrategiesTab.tsx +90 -96
- package/src/components/TeamAccessEditor.tsx +131 -123
- package/src/components/TeamsTab.tsx +180 -162
- package/src/components/UsersTab.tsx +43 -32
- package/src/hooks/useAccessRules.ts +32 -0
- package/src/hooks/useEnabledStrategies.ts +10 -41
- package/src/index.test.tsx +83 -37
- package/src/index.tsx +29 -51
- package/src/lib/auth-client.ts +17 -22
- package/src/hooks/usePermissions.ts +0 -43
package/src/index.test.tsx
CHANGED
|
@@ -1,95 +1,141 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
2
|
import { authPlugin } from "./index";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
7
|
-
mock.module("./hooks/
|
|
8
|
-
|
|
7
|
+
// Mock the useAccessRules hook
|
|
8
|
+
mock.module("./hooks/useAccessRules", () => ({
|
|
9
|
+
useAccessRules: mock(),
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
(
|
|
26
|
-
|
|
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(
|
|
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
|
|
37
|
-
(
|
|
38
|
-
|
|
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(
|
|
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
|
|
49
|
-
(
|
|
50
|
-
|
|
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(
|
|
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
|
|
61
|
-
(
|
|
62
|
-
|
|
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(
|
|
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
|
|
73
|
-
(
|
|
74
|
-
|
|
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(
|
|
99
|
+
expect(accessApi.useAccess(otherAccess)).toEqual({
|
|
79
100
|
loading: false,
|
|
80
101
|
allowed: true,
|
|
81
102
|
});
|
|
82
103
|
});
|
|
83
104
|
|
|
84
|
-
it("should return
|
|
85
|
-
(
|
|
86
|
-
|
|
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(
|
|
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
|
-
|
|
5
|
-
|
|
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 {
|
|
25
|
+
import { useAccessRules } from "./hooks/useAccessRules";
|
|
26
26
|
|
|
27
|
-
import {
|
|
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
|
-
|
|
34
|
+
authAccess,
|
|
35
35
|
authRoutes,
|
|
36
36
|
pluginMetadata,
|
|
37
37
|
} from "@checkstack/auth-common";
|
|
38
38
|
import { resolveRoute } from "@checkstack/common";
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
loading:
|
|
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:
|
|
184
|
-
factory: () => new
|
|
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: ({
|
|
206
|
+
component: ({ accessRules: userPerms }: UserMenuItemsContext) => {
|
|
226
207
|
const navigate = useNavigate();
|
|
227
|
-
const qualifiedId =
|
|
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
|
|
package/src/lib/auth-client.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { createAuthClient } from "better-auth/react";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
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:
|
|
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
|
-
};
|