@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.
- package/CHANGELOG.md +207 -0
- package/e2e/login.e2e.ts +63 -0
- package/package.json +34 -0
- package/playwright-report/data/774b616fd991c36e57f6aa95d67906b877dff5d1.md +20 -0
- package/playwright-report/data/d37ef869a8ef03c489f7ca3b80d67da69614c383.png +3 -0
- package/playwright-report/index.html +85 -0
- package/playwright.config.ts +5 -0
- package/src/api.ts +78 -0
- package/src/components/ApplicationsTab.tsx +452 -0
- package/src/components/AuthErrorPage.tsx +94 -0
- package/src/components/AuthSettingsPage.tsx +249 -0
- package/src/components/AuthStrategyCard.tsx +77 -0
- package/src/components/ChangePasswordPage.tsx +259 -0
- package/src/components/CreateUserDialog.tsx +156 -0
- package/src/components/ForgotPasswordPage.tsx +131 -0
- package/src/components/LoginPage.tsx +330 -0
- package/src/components/RegisterPage.tsx +350 -0
- package/src/components/ResetPasswordPage.tsx +262 -0
- package/src/components/RoleDialog.tsx +284 -0
- package/src/components/RolesTab.tsx +219 -0
- package/src/components/SocialProviderButton.tsx +30 -0
- package/src/components/StrategiesTab.tsx +276 -0
- package/src/components/UsersTab.tsx +234 -0
- package/src/hooks/useEnabledStrategies.ts +54 -0
- package/src/hooks/usePermissions.ts +43 -0
- package/src/index.test.tsx +95 -0
- package/src/index.tsx +271 -0
- package/src/lib/auth-client.ts +55 -0
- package/test-results/login-Login-Page-should-show-login-form-elements-chromium/test-failed-1.png +3 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
});
|