@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
package/src/api.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createApiRef } from "@checkstack/frontend-api";
|
|
2
|
+
import type { LucideIconName } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
// Types for better-auth entities
|
|
5
|
+
export interface AuthUser {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
image?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AuthSession {
|
|
13
|
+
session: {
|
|
14
|
+
id: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
token: string;
|
|
17
|
+
expiresAt: Date;
|
|
18
|
+
};
|
|
19
|
+
user: AuthUser;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Role {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description?: string | null;
|
|
26
|
+
isSystem?: boolean;
|
|
27
|
+
isAssignable?: boolean; // False for anonymous role - not assignable to users
|
|
28
|
+
permissions?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Permission {
|
|
32
|
+
id: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuthStrategy {
|
|
37
|
+
id: string;
|
|
38
|
+
displayName: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
icon?: LucideIconName;
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
configVersion: number;
|
|
43
|
+
configSchema: Record<string, unknown>; // JSON Schema
|
|
44
|
+
config?: Record<string, unknown>;
|
|
45
|
+
adminInstructions?: string; // Markdown instructions for admins
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EnabledAuthStrategy {
|
|
49
|
+
id: string;
|
|
50
|
+
displayName: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
type: "credential" | "social";
|
|
53
|
+
icon?: LucideIconName;
|
|
54
|
+
requiresManualRegistration: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* AuthApi provides better-auth client methods for authentication.
|
|
59
|
+
* For RPC calls (including getEnabledStrategies, user/role/strategy management), use:
|
|
60
|
+
* const authClient = rpcApiRef.forPlugin<AuthClient>("auth");
|
|
61
|
+
*/
|
|
62
|
+
export interface AuthApi {
|
|
63
|
+
// Better-auth methods (not RPC)
|
|
64
|
+
signIn(
|
|
65
|
+
email: string,
|
|
66
|
+
password: string
|
|
67
|
+
): Promise<{ data?: AuthSession; error?: Error }>;
|
|
68
|
+
signInWithSocial(provider: string): Promise<void>;
|
|
69
|
+
signOut(): Promise<void>;
|
|
70
|
+
getSession(): Promise<{ data?: AuthSession; error?: Error }>;
|
|
71
|
+
useSession(): {
|
|
72
|
+
data?: AuthSession;
|
|
73
|
+
isPending: boolean;
|
|
74
|
+
error?: Error;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const authApiRef = createApiRef<AuthApi>("auth.api");
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import React, { useState, useEffect } 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
|
+
LoadingSpinner,
|
|
16
|
+
Alert,
|
|
17
|
+
AlertDescription,
|
|
18
|
+
ConfirmationModal,
|
|
19
|
+
Dialog,
|
|
20
|
+
DialogContent,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
useToast,
|
|
25
|
+
} from "@checkstack/ui";
|
|
26
|
+
import { Plus, Trash2, RotateCcw, Copy } from "lucide-react";
|
|
27
|
+
import { useApi } from "@checkstack/frontend-api";
|
|
28
|
+
import { rpcApiRef } from "@checkstack/frontend-api";
|
|
29
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
30
|
+
import type { Role } from "../api";
|
|
31
|
+
|
|
32
|
+
export interface Application {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string | null;
|
|
36
|
+
roles: string[];
|
|
37
|
+
createdById: string;
|
|
38
|
+
createdAt: Date;
|
|
39
|
+
lastUsedAt?: Date | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ApplicationsTabProps {
|
|
43
|
+
roles: Role[];
|
|
44
|
+
canManageApplications: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const ApplicationsTab: React.FC<ApplicationsTabProps> = ({
|
|
48
|
+
roles,
|
|
49
|
+
canManageApplications,
|
|
50
|
+
}) => {
|
|
51
|
+
const rpcApi = useApi(rpcApiRef);
|
|
52
|
+
const authClient = rpcApi.forPlugin(AuthApi);
|
|
53
|
+
const toast = useToast();
|
|
54
|
+
|
|
55
|
+
const [applications, setApplications] = useState<Application[]>([]);
|
|
56
|
+
const [loading, setLoading] = useState(true);
|
|
57
|
+
const [applicationToDelete, setApplicationToDelete] = useState<string>();
|
|
58
|
+
const [applicationToRegenerateSecret, setApplicationToRegenerateSecret] =
|
|
59
|
+
useState<{ id: string; name: string }>();
|
|
60
|
+
const [newSecretDialog, setNewSecretDialog] = useState<{
|
|
61
|
+
open: boolean;
|
|
62
|
+
secret: string;
|
|
63
|
+
applicationName: string;
|
|
64
|
+
}>({ open: false, secret: "", applicationName: "" });
|
|
65
|
+
const [createAppDialogOpen, setCreateAppDialogOpen] = useState(false);
|
|
66
|
+
const [newAppName, setNewAppName] = useState("");
|
|
67
|
+
const [newAppDescription, setNewAppDescription] = useState("");
|
|
68
|
+
|
|
69
|
+
const fetchApplications = async () => {
|
|
70
|
+
if (!canManageApplications) {
|
|
71
|
+
setLoading(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setLoading(true);
|
|
75
|
+
try {
|
|
76
|
+
const data = await authClient.getApplications();
|
|
77
|
+
setApplications(data as Application[]);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error("Failed to fetch applications:", error);
|
|
80
|
+
} finally {
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
fetchApplications();
|
|
87
|
+
}, [canManageApplications]);
|
|
88
|
+
|
|
89
|
+
const handleCreateApplication = async () => {
|
|
90
|
+
if (!newAppName.trim()) {
|
|
91
|
+
toast.error("Application name is required");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const result = await authClient.createApplication({
|
|
96
|
+
name: newAppName.trim(),
|
|
97
|
+
description: newAppDescription.trim() || undefined,
|
|
98
|
+
});
|
|
99
|
+
setCreateAppDialogOpen(false);
|
|
100
|
+
setNewAppName("");
|
|
101
|
+
setNewAppDescription("");
|
|
102
|
+
setNewSecretDialog({
|
|
103
|
+
open: true,
|
|
104
|
+
secret: result.secret,
|
|
105
|
+
applicationName: result.application.name,
|
|
106
|
+
});
|
|
107
|
+
await fetchApplications();
|
|
108
|
+
} catch (error: unknown) {
|
|
109
|
+
toast.error(
|
|
110
|
+
error instanceof Error ? error.message : "Failed to create application"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleToggleApplicationRole = async (
|
|
116
|
+
appId: string,
|
|
117
|
+
roleId: string,
|
|
118
|
+
currentRoles: string[]
|
|
119
|
+
) => {
|
|
120
|
+
const newRoles = currentRoles.includes(roleId)
|
|
121
|
+
? currentRoles.filter((r) => r !== roleId)
|
|
122
|
+
: [...currentRoles, roleId];
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await authClient.updateApplication({
|
|
126
|
+
id: appId,
|
|
127
|
+
roles: newRoles,
|
|
128
|
+
});
|
|
129
|
+
setApplications(
|
|
130
|
+
applications.map((a) =>
|
|
131
|
+
a.id === appId ? { ...a, roles: newRoles } : a
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
toast.error(
|
|
136
|
+
error instanceof Error
|
|
137
|
+
? error.message
|
|
138
|
+
: "Failed to update application roles"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleDeleteApplication = async () => {
|
|
144
|
+
if (!applicationToDelete) return;
|
|
145
|
+
try {
|
|
146
|
+
await authClient.deleteApplication(applicationToDelete);
|
|
147
|
+
toast.success("Application deleted successfully");
|
|
148
|
+
setApplicationToDelete(undefined);
|
|
149
|
+
await fetchApplications();
|
|
150
|
+
} catch (error: unknown) {
|
|
151
|
+
toast.error(
|
|
152
|
+
error instanceof Error ? error.message : "Failed to delete application"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const handleRegenerateSecret = async () => {
|
|
158
|
+
if (!applicationToRegenerateSecret) return;
|
|
159
|
+
try {
|
|
160
|
+
const result = await authClient.regenerateApplicationSecret(
|
|
161
|
+
applicationToRegenerateSecret.id
|
|
162
|
+
);
|
|
163
|
+
setApplicationToRegenerateSecret(undefined);
|
|
164
|
+
setNewSecretDialog({
|
|
165
|
+
open: true,
|
|
166
|
+
secret: result.secret,
|
|
167
|
+
applicationName: applicationToRegenerateSecret.name,
|
|
168
|
+
});
|
|
169
|
+
} catch (error: unknown) {
|
|
170
|
+
toast.error(
|
|
171
|
+
error instanceof Error ? error.message : "Failed to regenerate secret"
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
<Card>
|
|
179
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
180
|
+
<CardTitle>External Applications</CardTitle>
|
|
181
|
+
{canManageApplications && (
|
|
182
|
+
<Button onClick={() => setCreateAppDialogOpen(true)} size="sm">
|
|
183
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
184
|
+
Create Application
|
|
185
|
+
</Button>
|
|
186
|
+
)}
|
|
187
|
+
</CardHeader>
|
|
188
|
+
<CardContent>
|
|
189
|
+
<Alert variant="info" className="mb-4">
|
|
190
|
+
<AlertDescription>
|
|
191
|
+
External applications use API keys to authenticate with the
|
|
192
|
+
Checkstack API. The secret is only shown once when created—store it
|
|
193
|
+
securely.
|
|
194
|
+
</AlertDescription>
|
|
195
|
+
</Alert>
|
|
196
|
+
|
|
197
|
+
{loading ? (
|
|
198
|
+
<div className="flex justify-center py-4">
|
|
199
|
+
<LoadingSpinner />
|
|
200
|
+
</div>
|
|
201
|
+
) : applications.length === 0 ? (
|
|
202
|
+
<p className="text-muted-foreground">
|
|
203
|
+
No external applications configured yet.
|
|
204
|
+
</p>
|
|
205
|
+
) : (
|
|
206
|
+
<Table>
|
|
207
|
+
<TableHeader>
|
|
208
|
+
<TableRow>
|
|
209
|
+
<TableHead>Application</TableHead>
|
|
210
|
+
<TableHead>Roles</TableHead>
|
|
211
|
+
<TableHead>Last Used</TableHead>
|
|
212
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
213
|
+
</TableRow>
|
|
214
|
+
</TableHeader>
|
|
215
|
+
<TableBody>
|
|
216
|
+
{applications.map((app) => (
|
|
217
|
+
<TableRow key={app.id}>
|
|
218
|
+
<TableCell>
|
|
219
|
+
<div className="flex flex-col">
|
|
220
|
+
<span className="font-medium">{app.name}</span>
|
|
221
|
+
{app.description && (
|
|
222
|
+
<span className="text-xs text-muted-foreground">
|
|
223
|
+
{app.description}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
226
|
+
<span className="text-xs text-muted-foreground font-mono">
|
|
227
|
+
ID: {app.id}
|
|
228
|
+
</span>
|
|
229
|
+
</div>
|
|
230
|
+
</TableCell>
|
|
231
|
+
<TableCell>
|
|
232
|
+
<div className="flex flex-wrap flex-col gap-2">
|
|
233
|
+
{roles
|
|
234
|
+
.filter((role) => role.isAssignable !== false)
|
|
235
|
+
.map((role) => (
|
|
236
|
+
<div
|
|
237
|
+
key={role.id}
|
|
238
|
+
className="flex items-center space-x-2"
|
|
239
|
+
>
|
|
240
|
+
<Checkbox
|
|
241
|
+
id={`app-role-${app.id}-${role.id}`}
|
|
242
|
+
checked={app.roles.includes(role.id)}
|
|
243
|
+
disabled={!canManageApplications}
|
|
244
|
+
onCheckedChange={() =>
|
|
245
|
+
handleToggleApplicationRole(
|
|
246
|
+
app.id,
|
|
247
|
+
role.id,
|
|
248
|
+
app.roles
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
/>
|
|
252
|
+
<label
|
|
253
|
+
htmlFor={`app-role-${app.id}-${role.id}`}
|
|
254
|
+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
255
|
+
>
|
|
256
|
+
{role.name}
|
|
257
|
+
</label>
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
</TableCell>
|
|
262
|
+
<TableCell>
|
|
263
|
+
{app.lastUsedAt ? (
|
|
264
|
+
<span className="text-sm">
|
|
265
|
+
{new Date(app.lastUsedAt).toLocaleDateString()}
|
|
266
|
+
</span>
|
|
267
|
+
) : (
|
|
268
|
+
<span className="text-xs text-muted-foreground">
|
|
269
|
+
Never
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
</TableCell>
|
|
273
|
+
<TableCell className="text-right">
|
|
274
|
+
<div className="flex justify-end gap-2">
|
|
275
|
+
<Button
|
|
276
|
+
variant="ghost"
|
|
277
|
+
size="sm"
|
|
278
|
+
onClick={() =>
|
|
279
|
+
setApplicationToRegenerateSecret({
|
|
280
|
+
id: app.id,
|
|
281
|
+
name: app.name,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
title="Regenerate Secret"
|
|
285
|
+
>
|
|
286
|
+
<RotateCcw className="h-4 w-4" />
|
|
287
|
+
</Button>
|
|
288
|
+
<Button
|
|
289
|
+
variant="ghost"
|
|
290
|
+
size="sm"
|
|
291
|
+
onClick={() => setApplicationToDelete(app.id)}
|
|
292
|
+
>
|
|
293
|
+
<Trash2 className="h-4 w-4" />
|
|
294
|
+
</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</TableCell>
|
|
297
|
+
</TableRow>
|
|
298
|
+
))}
|
|
299
|
+
</TableBody>
|
|
300
|
+
</Table>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{!canManageApplications && (
|
|
304
|
+
<p className="text-xs text-muted-foreground mt-4">
|
|
305
|
+
You don't have permission to manage applications.
|
|
306
|
+
</p>
|
|
307
|
+
)}
|
|
308
|
+
</CardContent>
|
|
309
|
+
</Card>
|
|
310
|
+
|
|
311
|
+
{/* Delete Application Confirmation */}
|
|
312
|
+
<ConfirmationModal
|
|
313
|
+
isOpen={!!applicationToDelete}
|
|
314
|
+
onClose={() => setApplicationToDelete(undefined)}
|
|
315
|
+
onConfirm={handleDeleteApplication}
|
|
316
|
+
title="Delete Application"
|
|
317
|
+
message="Are you sure you want to delete this application? Its API key will stop working immediately."
|
|
318
|
+
/>
|
|
319
|
+
|
|
320
|
+
{/* Regenerate Secret Confirmation */}
|
|
321
|
+
<ConfirmationModal
|
|
322
|
+
isOpen={!!applicationToRegenerateSecret}
|
|
323
|
+
onClose={() => setApplicationToRegenerateSecret(undefined)}
|
|
324
|
+
onConfirm={() => void handleRegenerateSecret()}
|
|
325
|
+
title="Regenerate Application Secret"
|
|
326
|
+
message={`Are you sure you want to regenerate the secret for "${
|
|
327
|
+
applicationToRegenerateSecret?.name ?? ""
|
|
328
|
+
}"? The current secret will stop working immediately and all calling applications will break until updated.`}
|
|
329
|
+
/>
|
|
330
|
+
|
|
331
|
+
{/* New Secret Display Dialog */}
|
|
332
|
+
<Dialog
|
|
333
|
+
open={newSecretDialog.open}
|
|
334
|
+
onOpenChange={(open) => {
|
|
335
|
+
if (!open) {
|
|
336
|
+
setNewSecretDialog({
|
|
337
|
+
open: false,
|
|
338
|
+
secret: "",
|
|
339
|
+
applicationName: "",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<DialogContent>
|
|
345
|
+
<DialogHeader>
|
|
346
|
+
<DialogTitle>
|
|
347
|
+
Application Secret: {newSecretDialog.applicationName}
|
|
348
|
+
</DialogTitle>
|
|
349
|
+
</DialogHeader>
|
|
350
|
+
<div className="space-y-4">
|
|
351
|
+
<Alert variant="warning">
|
|
352
|
+
<AlertDescription>
|
|
353
|
+
Copy this secret now—it will never be shown again!
|
|
354
|
+
</AlertDescription>
|
|
355
|
+
</Alert>
|
|
356
|
+
<div className="flex items-center gap-2">
|
|
357
|
+
<code className="flex-1 bg-muted p-2 rounded font-mono text-sm break-all">
|
|
358
|
+
{newSecretDialog.secret}
|
|
359
|
+
</code>
|
|
360
|
+
<Button
|
|
361
|
+
variant="outline"
|
|
362
|
+
size="sm"
|
|
363
|
+
onClick={() => {
|
|
364
|
+
navigator.clipboard.writeText(newSecretDialog.secret);
|
|
365
|
+
toast.success("Secret copied to clipboard");
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
<Copy className="h-4 w-4" />
|
|
369
|
+
</Button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
<DialogFooter>
|
|
373
|
+
<Button
|
|
374
|
+
onClick={() =>
|
|
375
|
+
setNewSecretDialog({
|
|
376
|
+
open: false,
|
|
377
|
+
secret: "",
|
|
378
|
+
applicationName: "",
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
>
|
|
382
|
+
Done
|
|
383
|
+
</Button>
|
|
384
|
+
</DialogFooter>
|
|
385
|
+
</DialogContent>
|
|
386
|
+
</Dialog>
|
|
387
|
+
|
|
388
|
+
{/* Create Application Dialog */}
|
|
389
|
+
<Dialog
|
|
390
|
+
open={createAppDialogOpen}
|
|
391
|
+
onOpenChange={(open) => {
|
|
392
|
+
if (!open) {
|
|
393
|
+
setCreateAppDialogOpen(false);
|
|
394
|
+
setNewAppName("");
|
|
395
|
+
setNewAppDescription("");
|
|
396
|
+
}
|
|
397
|
+
}}
|
|
398
|
+
>
|
|
399
|
+
<DialogContent>
|
|
400
|
+
<DialogHeader>
|
|
401
|
+
<DialogTitle>Create Application</DialogTitle>
|
|
402
|
+
</DialogHeader>
|
|
403
|
+
<div className="space-y-4">
|
|
404
|
+
<div>
|
|
405
|
+
<label className="block text-sm font-medium mb-1">Name</label>
|
|
406
|
+
<input
|
|
407
|
+
type="text"
|
|
408
|
+
value={newAppName}
|
|
409
|
+
onChange={(e) => setNewAppName(e.target.value)}
|
|
410
|
+
className="w-full px-3 py-2 border rounded-md bg-background"
|
|
411
|
+
placeholder="My Application"
|
|
412
|
+
/>
|
|
413
|
+
</div>
|
|
414
|
+
<div>
|
|
415
|
+
<label className="block text-sm font-medium mb-1">
|
|
416
|
+
Description (optional)
|
|
417
|
+
</label>
|
|
418
|
+
<input
|
|
419
|
+
type="text"
|
|
420
|
+
value={newAppDescription}
|
|
421
|
+
onChange={(e) => setNewAppDescription(e.target.value)}
|
|
422
|
+
className="w-full px-3 py-2 border rounded-md bg-background"
|
|
423
|
+
placeholder="What does this application do?"
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
<Alert variant="info">
|
|
427
|
+
<AlertDescription>
|
|
428
|
+
New applications are assigned the "Applications" role by
|
|
429
|
+
default. You can manage roles after creation.
|
|
430
|
+
</AlertDescription>
|
|
431
|
+
</Alert>
|
|
432
|
+
</div>
|
|
433
|
+
<DialogFooter>
|
|
434
|
+
<Button
|
|
435
|
+
variant="ghost"
|
|
436
|
+
onClick={() => {
|
|
437
|
+
setCreateAppDialogOpen(false);
|
|
438
|
+
setNewAppName("");
|
|
439
|
+
setNewAppDescription("");
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
Cancel
|
|
443
|
+
</Button>
|
|
444
|
+
<Button onClick={() => void handleCreateApplication()}>
|
|
445
|
+
Create
|
|
446
|
+
</Button>
|
|
447
|
+
</DialogFooter>
|
|
448
|
+
</DialogContent>
|
|
449
|
+
</Dialog>
|
|
450
|
+
</>
|
|
451
|
+
);
|
|
452
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Link, useSearchParams } from "react-router-dom";
|
|
2
|
+
import { AlertCircle, Home, LogIn } from "lucide-react";
|
|
3
|
+
import { authRoutes } from "@checkstack/auth-common";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
CardDescription,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardFooter,
|
|
13
|
+
Alert,
|
|
14
|
+
AlertIcon,
|
|
15
|
+
AlertContent,
|
|
16
|
+
AlertTitle,
|
|
17
|
+
AlertDescription,
|
|
18
|
+
} from "@checkstack/ui";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Map technical error messages to user-friendly ones
|
|
22
|
+
*/
|
|
23
|
+
const getErrorMessage = (error: string | undefined): string => {
|
|
24
|
+
if (!error) {
|
|
25
|
+
return "An unexpected error occurred during authentication.";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Registration disabled error
|
|
29
|
+
if (error.includes("Registration is currently disabled")) {
|
|
30
|
+
return "Registration is currently disabled. Please contact an administrator if you need access.";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// User denied authorization
|
|
34
|
+
if (error.includes("access_denied") || error.includes("user_denied")) {
|
|
35
|
+
return "Authorization was cancelled. Please try again if you wish to sign in.";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Generic OAuth errors
|
|
39
|
+
if (error.includes("UNKNOWN") || error.includes("unknown")) {
|
|
40
|
+
return "An unexpected error occurred. Please try again or contact support if the problem persists.";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Return the error as-is if it seems user-friendly
|
|
44
|
+
return error;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const AuthErrorPage = () => {
|
|
48
|
+
const [searchParams] = useSearchParams();
|
|
49
|
+
const errorParam = searchParams.get("error");
|
|
50
|
+
|
|
51
|
+
// better-auth encodes error messages using underscores for spaces
|
|
52
|
+
// Decode by replacing underscores with spaces
|
|
53
|
+
const decodedError = errorParam?.replaceAll("_", " ") ?? undefined;
|
|
54
|
+
|
|
55
|
+
const errorMessage = getErrorMessage(decodedError);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="min-h-[80vh] flex items-center justify-center">
|
|
59
|
+
<Card className="w-full max-w-md">
|
|
60
|
+
<CardHeader className="flex flex-col space-y-1 items-center">
|
|
61
|
+
<CardTitle>Authentication Error</CardTitle>
|
|
62
|
+
<CardDescription>
|
|
63
|
+
We encountered a problem during sign-in
|
|
64
|
+
</CardDescription>
|
|
65
|
+
</CardHeader>
|
|
66
|
+
<CardContent>
|
|
67
|
+
<Alert variant="error">
|
|
68
|
+
<AlertIcon>
|
|
69
|
+
<AlertCircle className="h-4 w-4" />
|
|
70
|
+
</AlertIcon>
|
|
71
|
+
<AlertContent>
|
|
72
|
+
<AlertTitle>Sign-in Failed</AlertTitle>
|
|
73
|
+
<AlertDescription>{errorMessage}</AlertDescription>
|
|
74
|
+
</AlertContent>
|
|
75
|
+
</Alert>
|
|
76
|
+
</CardContent>
|
|
77
|
+
<CardFooter className="flex gap-2 justify-center">
|
|
78
|
+
<Link to={resolveRoute(authRoutes.routes.login)}>
|
|
79
|
+
<Button variant="primary">
|
|
80
|
+
<LogIn className="mr-2 h-4 w-4" />
|
|
81
|
+
Try Again
|
|
82
|
+
</Button>
|
|
83
|
+
</Link>
|
|
84
|
+
<Link to="/">
|
|
85
|
+
<Button variant="outline">
|
|
86
|
+
<Home className="mr-2 h-4 w-4" />
|
|
87
|
+
Go Home
|
|
88
|
+
</Button>
|
|
89
|
+
</Link>
|
|
90
|
+
</CardFooter>
|
|
91
|
+
</Card>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
};
|