@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,5 @@
1
+ import { createPlaywrightConfig } from "@checkstack/test-utils-frontend/playwright";
2
+
3
+ export default createPlaywrightConfig({
4
+ baseURL: "http://localhost:5173",
5
+ });
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
+ };