@checkstack/auth-frontend 0.0.2

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.
@@ -0,0 +1,276 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardContent,
7
+ Button,
8
+ LoadingSpinner,
9
+ Alert,
10
+ AlertIcon,
11
+ AlertContent,
12
+ AlertTitle,
13
+ AlertDescription,
14
+ DynamicForm,
15
+ useToast,
16
+ } from "@checkstack/ui";
17
+ import { Shield, RefreshCw } from "lucide-react";
18
+ import { useApi } from "@checkstack/frontend-api";
19
+ import { rpcApiRef } from "@checkstack/frontend-api";
20
+ import { AuthApi } from "@checkstack/auth-common";
21
+ import type { AuthStrategy } from "../api";
22
+ import { AuthStrategyCard } from "./AuthStrategyCard";
23
+
24
+ export interface StrategiesTabProps {
25
+ strategies: AuthStrategy[];
26
+ canManageStrategies: boolean;
27
+ canManageRegistration: boolean;
28
+ onDataChange: () => Promise<void>;
29
+ }
30
+
31
+ export const StrategiesTab: React.FC<StrategiesTabProps> = ({
32
+ strategies,
33
+ canManageStrategies,
34
+ canManageRegistration,
35
+ onDataChange,
36
+ }) => {
37
+ const rpcApi = useApi(rpcApiRef);
38
+ const authClient = rpcApi.forPlugin(AuthApi);
39
+ const toast = useToast();
40
+
41
+ const [reloading, setReloading] = useState(false);
42
+ const [expandedStrategy, setExpandedStrategy] = useState<string>();
43
+ const [strategyConfigs, setStrategyConfigs] = useState<
44
+ Record<string, Record<string, unknown>>
45
+ >({});
46
+
47
+ // Registration state
48
+ const [registrationSchema, setRegistrationSchema] = useState<
49
+ Record<string, unknown> | undefined
50
+ >();
51
+ const [registrationSettings, setRegistrationSettings] = useState<{
52
+ allowRegistration: boolean;
53
+ }>({ allowRegistration: true });
54
+ const [loadingRegistration, setLoadingRegistration] = useState(true);
55
+ const [savingRegistration, setSavingRegistration] = useState(false);
56
+ const [registrationValid, setRegistrationValid] = useState(true);
57
+
58
+ // Initialize strategy configs when strategies change
59
+ useEffect(() => {
60
+ const configs: Record<string, Record<string, unknown>> = {};
61
+ for (const strategy of strategies) {
62
+ configs[strategy.id] = strategy.config || {};
63
+ }
64
+ setStrategyConfigs(configs);
65
+ }, [strategies]);
66
+
67
+ // Fetch registration data when we have permission
68
+ useEffect(() => {
69
+ if (!canManageRegistration) {
70
+ setLoadingRegistration(false);
71
+ return;
72
+ }
73
+
74
+ const fetchRegistrationData = async () => {
75
+ setLoadingRegistration(true);
76
+ try {
77
+ const [schema, status] = await Promise.all([
78
+ authClient.getRegistrationSchema(),
79
+ authClient.getRegistrationStatus(),
80
+ ]);
81
+ setRegistrationSchema(schema);
82
+ setRegistrationSettings(status);
83
+ } catch (error) {
84
+ console.error("Failed to fetch registration data:", error);
85
+ } finally {
86
+ setLoadingRegistration(false);
87
+ }
88
+ };
89
+ fetchRegistrationData();
90
+ }, [canManageRegistration, authClient]);
91
+
92
+ const handleToggleStrategy = async (strategyId: string, enabled: boolean) => {
93
+ try {
94
+ await authClient.updateStrategy({ id: strategyId, enabled });
95
+ await onDataChange();
96
+ } catch (error: unknown) {
97
+ const message =
98
+ error instanceof Error ? error.message : "Failed to toggle strategy";
99
+ toast.error(message);
100
+ }
101
+ };
102
+
103
+ const handleSaveStrategyConfig = async (
104
+ strategyId: string,
105
+ config: Record<string, unknown>
106
+ ) => {
107
+ try {
108
+ const strategy = strategies.find((s) => s.id === strategyId);
109
+ if (!strategy) {
110
+ toast.error("Strategy not found");
111
+ return;
112
+ }
113
+ setStrategyConfigs({
114
+ ...strategyConfigs,
115
+ [strategyId]: config,
116
+ });
117
+ await authClient.updateStrategy({
118
+ id: strategyId,
119
+ enabled: strategy.enabled,
120
+ config,
121
+ });
122
+ toast.success(
123
+ "Configuration saved successfully! Click 'Reload Authentication' to apply changes."
124
+ );
125
+ } catch (error: unknown) {
126
+ const message =
127
+ error instanceof Error
128
+ ? error.message
129
+ : "Failed to save strategy configuration";
130
+ toast.error(message);
131
+ }
132
+ };
133
+
134
+ const handleSaveRegistration = async () => {
135
+ setSavingRegistration(true);
136
+ try {
137
+ await authClient.setRegistrationStatus(registrationSettings);
138
+ toast.success("Registration settings saved successfully");
139
+ } catch (error: unknown) {
140
+ toast.error(
141
+ error instanceof Error
142
+ ? error.message
143
+ : "Failed to save registration settings"
144
+ );
145
+ } finally {
146
+ setSavingRegistration(false);
147
+ }
148
+ };
149
+
150
+ const handleReloadAuth = async () => {
151
+ setReloading(true);
152
+ try {
153
+ await authClient.reloadAuth();
154
+ toast.success("Authentication system reloaded successfully");
155
+ await onDataChange();
156
+ } catch (error: unknown) {
157
+ toast.error(
158
+ error instanceof Error
159
+ ? error.message
160
+ : "Failed to reload authentication"
161
+ );
162
+ } finally {
163
+ setReloading(false);
164
+ }
165
+ };
166
+
167
+ const enabledStrategies = strategies.filter((s) => s.enabled);
168
+ const hasNoEnabled = enabledStrategies.length === 0;
169
+
170
+ return (
171
+ <div className="space-y-4">
172
+ {/* Platform Settings */}
173
+ <Card>
174
+ <CardHeader>
175
+ <CardTitle>Platform Settings</CardTitle>
176
+ </CardHeader>
177
+ <CardContent>
178
+ {canManageRegistration ? (
179
+ loadingRegistration ? (
180
+ <div className="flex justify-center py-4">
181
+ <LoadingSpinner />
182
+ </div>
183
+ ) : registrationSchema ? (
184
+ <div className="space-y-4">
185
+ <DynamicForm
186
+ schema={registrationSchema}
187
+ value={registrationSettings}
188
+ onChange={(value) =>
189
+ setRegistrationSettings(
190
+ value as { allowRegistration: boolean }
191
+ )
192
+ }
193
+ onValidChange={setRegistrationValid}
194
+ />
195
+ <Button
196
+ onClick={() => void handleSaveRegistration()}
197
+ disabled={savingRegistration || !registrationValid}
198
+ >
199
+ {savingRegistration ? "Saving..." : "Save Settings"}
200
+ </Button>
201
+ </div>
202
+ ) : (
203
+ <p className="text-muted-foreground">
204
+ Failed to load registration settings
205
+ </p>
206
+ )
207
+ ) : (
208
+ <p className="text-sm text-muted-foreground">
209
+ You don't have permission to manage registration settings.
210
+ </p>
211
+ )}
212
+ </CardContent>
213
+ </Card>
214
+
215
+ <div className="flex justify-end">
216
+ <Button
217
+ onClick={handleReloadAuth}
218
+ disabled={!canManageStrategies || reloading}
219
+ className="gap-2"
220
+ >
221
+ <RefreshCw className={`h-4 w-4 ${reloading ? "animate-spin" : ""}`} />
222
+ {reloading ? "Reloading..." : "Reload Authentication"}
223
+ </Button>
224
+ </div>
225
+
226
+ {hasNoEnabled && (
227
+ <Alert variant="warning">
228
+ <AlertIcon>
229
+ <Shield className="h-4 w-4" />
230
+ </AlertIcon>
231
+ <AlertContent>
232
+ <AlertTitle>No authentication strategies enabled</AlertTitle>
233
+ <AlertDescription>
234
+ You won't be able to log in! Please enable at least one
235
+ authentication strategy and reload authentication.
236
+ </AlertDescription>
237
+ </AlertContent>
238
+ </Alert>
239
+ )}
240
+
241
+ <Alert className="mt-6">
242
+ <AlertIcon>
243
+ <Shield className="h-4 w-4" />
244
+ </AlertIcon>
245
+ <AlertContent>
246
+ <AlertDescription>
247
+ Changes to authentication strategies require clicking the "Reload
248
+ Authentication" button to take effect. This reloads the auth system
249
+ without requiring a full restart.
250
+ </AlertDescription>
251
+ </AlertContent>
252
+ </Alert>
253
+
254
+ {strategies.map((strategy) => (
255
+ <AuthStrategyCard
256
+ key={strategy.id}
257
+ strategy={strategy}
258
+ onToggle={handleToggleStrategy}
259
+ onSaveConfig={handleSaveStrategyConfig}
260
+ disabled={!canManageStrategies}
261
+ expanded={expandedStrategy === strategy.id}
262
+ onExpandedChange={(isExpanded) => {
263
+ setExpandedStrategy(isExpanded ? strategy.id : undefined);
264
+ }}
265
+ config={strategyConfigs[strategy.id]}
266
+ />
267
+ ))}
268
+
269
+ {!canManageStrategies && (
270
+ <p className="text-xs text-muted-foreground mt-4">
271
+ You don't have permission to manage strategies.
272
+ </p>
273
+ )}
274
+ </div>
275
+ );
276
+ };
@@ -0,0 +1,234 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardContent,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ Button,
14
+ Checkbox,
15
+ Alert,
16
+ AlertDescription,
17
+ ConfirmationModal,
18
+ useToast,
19
+ } from "@checkstack/ui";
20
+ import { Plus, Trash2 } from "lucide-react";
21
+ import { useApi } from "@checkstack/frontend-api";
22
+ import { rpcApiRef } from "@checkstack/frontend-api";
23
+ import { AuthApi } from "@checkstack/auth-common";
24
+ import type { AuthUser, Role, AuthStrategy } from "../api";
25
+ import { CreateUserDialog } from "./CreateUserDialog";
26
+
27
+ export interface UsersTabProps {
28
+ users: (AuthUser & { roles: string[] })[];
29
+ roles: Role[];
30
+ strategies: AuthStrategy[];
31
+ currentUserId?: string;
32
+ canReadUsers: boolean;
33
+ canCreateUsers: boolean;
34
+ canManageUsers: boolean;
35
+ canManageRoles: boolean;
36
+ onDataChange: () => Promise<void>;
37
+ }
38
+
39
+ export const UsersTab: React.FC<UsersTabProps> = ({
40
+ users,
41
+ roles,
42
+ strategies,
43
+ currentUserId,
44
+ canReadUsers,
45
+ canCreateUsers,
46
+ canManageUsers,
47
+ canManageRoles,
48
+ onDataChange,
49
+ }) => {
50
+ const rpcApi = useApi(rpcApiRef);
51
+ const authClient = rpcApi.forPlugin(AuthApi);
52
+ const toast = useToast();
53
+
54
+ const [userToDelete, setUserToDelete] = useState<string>();
55
+ const [createUserDialogOpen, setCreateUserDialogOpen] = useState(false);
56
+
57
+ const hasCredentialStrategy = strategies.some(
58
+ (s) => s.id === "credential" && s.enabled
59
+ );
60
+
61
+ const handleDeleteUser = async () => {
62
+ if (!userToDelete) return;
63
+ try {
64
+ await authClient.deleteUser(userToDelete);
65
+ toast.success("User deleted successfully");
66
+ setUserToDelete(undefined);
67
+ await onDataChange();
68
+ } catch (error: unknown) {
69
+ toast.error(
70
+ error instanceof Error ? error.message : "Failed to delete user"
71
+ );
72
+ }
73
+ };
74
+
75
+ const handleToggleRole = async (
76
+ userId: string,
77
+ roleId: string,
78
+ currentRoles: string[]
79
+ ) => {
80
+ if (currentUserId === userId) {
81
+ toast.error("You cannot update your own roles");
82
+ return;
83
+ }
84
+
85
+ const newRoles = currentRoles.includes(roleId)
86
+ ? currentRoles.filter((r) => r !== roleId)
87
+ : [...currentRoles, roleId];
88
+
89
+ try {
90
+ await authClient.updateUserRoles({ userId, roles: newRoles });
91
+ await onDataChange();
92
+ } catch (error: unknown) {
93
+ const message =
94
+ error instanceof Error ? error.message : "Failed to update roles";
95
+ toast.error(message);
96
+ }
97
+ };
98
+
99
+ const handleCreateUser = async (data: {
100
+ name: string;
101
+ email: string;
102
+ password: string;
103
+ }) => {
104
+ try {
105
+ await authClient.createCredentialUser(data);
106
+ toast.success("User created successfully");
107
+ await onDataChange();
108
+ } catch (error: unknown) {
109
+ toast.error(
110
+ error instanceof Error ? error.message : "Failed to create user"
111
+ );
112
+ throw error;
113
+ }
114
+ };
115
+
116
+ return (
117
+ <>
118
+ <Card>
119
+ <CardHeader className="flex flex-row items-center justify-between">
120
+ <CardTitle>User Management</CardTitle>
121
+ {canCreateUsers && hasCredentialStrategy && (
122
+ <Button onClick={() => setCreateUserDialogOpen(true)} size="sm">
123
+ <Plus className="h-4 w-4 mr-2" />
124
+ Create User
125
+ </Button>
126
+ )}
127
+ </CardHeader>
128
+ <CardContent>
129
+ <Alert variant="info" className="mb-4">
130
+ <AlertDescription>
131
+ You cannot modify roles for your own account. This security
132
+ measure prevents accidental self-lockout from the system and
133
+ permission elevation.
134
+ </AlertDescription>
135
+ </Alert>
136
+ {canReadUsers ? (
137
+ users.length === 0 ? (
138
+ <p className="text-muted-foreground">No users found.</p>
139
+ ) : (
140
+ <Table>
141
+ <TableHeader>
142
+ <TableRow>
143
+ <TableHead>User</TableHead>
144
+ <TableHead>Roles</TableHead>
145
+ <TableHead className="text-right">Actions</TableHead>
146
+ </TableRow>
147
+ </TableHeader>
148
+ <TableBody>
149
+ {users.map((user) => (
150
+ <TableRow key={user.id}>
151
+ <TableCell>
152
+ <div className="flex flex-col">
153
+ <span className="font-medium">
154
+ {user.name || "N/A"}
155
+ </span>
156
+ <span className="text-xs text-muted-foreground">
157
+ {user.email}
158
+ </span>
159
+ </div>
160
+ </TableCell>
161
+ <TableCell>
162
+ <div className="flex flex-wrap flex-col gap-2">
163
+ {roles
164
+ .filter((role) => role.isAssignable !== false)
165
+ .map((role) => (
166
+ <div
167
+ key={role.id}
168
+ className="flex items-center space-x-2"
169
+ >
170
+ <Checkbox
171
+ id={`role-${user.id}-${role.id}`}
172
+ checked={user.roles.includes(role.id)}
173
+ disabled={
174
+ !canManageRoles || currentUserId === user.id
175
+ }
176
+ onCheckedChange={() =>
177
+ handleToggleRole(
178
+ user.id,
179
+ role.id,
180
+ user.roles
181
+ )
182
+ }
183
+ />
184
+ <label
185
+ htmlFor={`role-${user.id}-${role.id}`}
186
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
187
+ >
188
+ {role.name}
189
+ </label>
190
+ </div>
191
+ ))}
192
+ </div>
193
+ </TableCell>
194
+ <TableCell className="text-right">
195
+ {canManageUsers &&
196
+ user.email !== "admin@checkstack.com" && (
197
+ <Button
198
+ variant="destructive"
199
+ size="icon"
200
+ onClick={() => setUserToDelete(user.id)}
201
+ >
202
+ <Trash2 size={16} />
203
+ </Button>
204
+ )}
205
+ </TableCell>
206
+ </TableRow>
207
+ ))}
208
+ </TableBody>
209
+ </Table>
210
+ )
211
+ ) : (
212
+ <p className="text-muted-foreground">
213
+ You don't have permission to list users.
214
+ </p>
215
+ )}
216
+ </CardContent>
217
+ </Card>
218
+
219
+ <ConfirmationModal
220
+ isOpen={!!userToDelete}
221
+ onClose={() => setUserToDelete(undefined)}
222
+ onConfirm={handleDeleteUser}
223
+ title="Delete User"
224
+ message="Are you sure you want to delete this user? This action cannot be undone."
225
+ />
226
+
227
+ <CreateUserDialog
228
+ open={createUserDialogOpen}
229
+ onOpenChange={setCreateUserDialogOpen}
230
+ onSubmit={handleCreateUser}
231
+ />
232
+ </>
233
+ );
234
+ };
@@ -0,0 +1,54 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useApi, rpcApiRef } from "@checkstack/frontend-api";
3
+ import { AuthApi } from "@checkstack/auth-common";
4
+ import type { EnabledAuthStrategy } from "../api";
5
+
6
+ export interface UseEnabledStrategiesResult {
7
+ strategies: EnabledAuthStrategy[];
8
+ loading: boolean;
9
+ error?: Error;
10
+ }
11
+
12
+ export const useEnabledStrategies = (): UseEnabledStrategiesResult => {
13
+ const rpcApi = useApi(rpcApiRef);
14
+ const authClient = rpcApi.forPlugin(AuthApi);
15
+
16
+ const [strategies, setStrategies] = useState<EnabledAuthStrategy[]>([]);
17
+ const [loading, setLoading] = useState(true);
18
+ const [error, setError] = useState<Error>();
19
+
20
+ useEffect(() => {
21
+ let mounted = true;
22
+
23
+ const fetchStrategies = async () => {
24
+ try {
25
+ setLoading(true);
26
+ const result = await authClient.getEnabledStrategies();
27
+ if (mounted) {
28
+ setStrategies(result);
29
+ setError(undefined);
30
+ }
31
+ } catch (error_) {
32
+ if (mounted) {
33
+ setError(
34
+ error_ instanceof Error
35
+ ? error_
36
+ : new Error("Failed to fetch strategies")
37
+ );
38
+ }
39
+ } finally {
40
+ if (mounted) {
41
+ setLoading(false);
42
+ }
43
+ }
44
+ };
45
+
46
+ fetchStrategies();
47
+
48
+ return () => {
49
+ mounted = false;
50
+ };
51
+ }, [authClient]);
52
+
53
+ return { strategies, loading, error };
54
+ };
@@ -0,0 +1,43 @@
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
+ };
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { authPlugin } from "./index";
3
+ import { permissionApiRef } from "@checkstack/frontend-api";
4
+ import { usePermissions } from "./hooks/usePermissions";
5
+
6
+ // Mock the usePermissions hook
7
+ mock.module("./hooks/usePermissions", () => ({
8
+ usePermissions: mock(),
9
+ }));
10
+
11
+ describe("AuthPermissionApi", () => {
12
+ let permissionApi: {
13
+ usePermission: (p: string) => { loading: boolean; allowed: boolean };
14
+ };
15
+
16
+ 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;
22
+ });
23
+
24
+ it("should return true if user has the permission", () => {
25
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
26
+ permissions: ["test.permission"],
27
+ loading: false,
28
+ });
29
+
30
+ expect(permissionApi.usePermission("test.permission")).toEqual({
31
+ loading: false,
32
+ allowed: true,
33
+ });
34
+ });
35
+
36
+ it("should return false if user is missing the permission", () => {
37
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
38
+ permissions: ["other.permission"],
39
+ loading: false,
40
+ });
41
+
42
+ expect(permissionApi.usePermission("test.permission")).toEqual({
43
+ loading: false,
44
+ allowed: false,
45
+ });
46
+ });
47
+
48
+ it("should return false if no session data (no permissions)", () => {
49
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
50
+ permissions: [],
51
+ loading: false,
52
+ });
53
+
54
+ expect(permissionApi.usePermission("test.permission")).toEqual({
55
+ loading: false,
56
+ allowed: false,
57
+ });
58
+ });
59
+
60
+ it("should return false if no user permissions (empty array)", () => {
61
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
62
+ permissions: [],
63
+ loading: false,
64
+ });
65
+
66
+ expect(permissionApi.usePermission("test.permission")).toEqual({
67
+ loading: false,
68
+ allowed: false,
69
+ });
70
+ });
71
+
72
+ it("should return true if user has the wildcard permission", () => {
73
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
74
+ permissions: ["*"],
75
+ loading: false,
76
+ });
77
+
78
+ expect(permissionApi.usePermission("any.permission")).toEqual({
79
+ loading: false,
80
+ allowed: true,
81
+ });
82
+ });
83
+
84
+ it("should return loading state if permissions are loading", () => {
85
+ (usePermissions as ReturnType<typeof mock>).mockReturnValue({
86
+ permissions: [],
87
+ loading: true,
88
+ });
89
+
90
+ expect(permissionApi.usePermission("test.permission")).toEqual({
91
+ loading: true,
92
+ allowed: false,
93
+ });
94
+ });
95
+ });