@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,156 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ Button,
10
+ Input,
11
+ Label,
12
+ } from "@checkstack/ui";
13
+ import { passwordSchema } from "@checkstack/auth-common";
14
+
15
+ interface CreateUserDialogProps {
16
+ open: boolean;
17
+ onOpenChange: (open: boolean) => void;
18
+ onSubmit: (data: {
19
+ name: string;
20
+ email: string;
21
+ password: string;
22
+ }) => Promise<void>;
23
+ }
24
+
25
+ export const CreateUserDialog: React.FC<CreateUserDialogProps> = ({
26
+ open,
27
+ onOpenChange,
28
+ onSubmit,
29
+ }) => {
30
+ const [name, setName] = useState("");
31
+ const [email, setEmail] = useState("");
32
+ const [password, setPassword] = useState("");
33
+ const [loading, setLoading] = useState(false);
34
+ const [validationErrors, setValidationErrors] = useState<string[]>([]);
35
+
36
+ // Reset form when dialog opens/closes
37
+ useEffect(() => {
38
+ if (!open) {
39
+ setName("");
40
+ setEmail("");
41
+ setPassword("");
42
+ setValidationErrors([]);
43
+ }
44
+ }, [open]);
45
+
46
+ // Validate password on change
47
+ useEffect(() => {
48
+ if (password) {
49
+ const result = passwordSchema.safeParse(password);
50
+ if (result.success) {
51
+ setValidationErrors([]);
52
+ } else {
53
+ setValidationErrors(result.error.issues.map((issue) => issue.message));
54
+ }
55
+ } else {
56
+ setValidationErrors([]);
57
+ }
58
+ }, [password]);
59
+
60
+ const handleSubmit = async (e: React.FormEvent) => {
61
+ e.preventDefault();
62
+
63
+ // Validate password before submitting
64
+ const result = passwordSchema.safeParse(password);
65
+ if (!result.success) {
66
+ return;
67
+ }
68
+
69
+ setLoading(true);
70
+ try {
71
+ await onSubmit({ name, email, password });
72
+ onOpenChange(false);
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ };
77
+
78
+ const isValid =
79
+ name.trim().length > 0 &&
80
+ email.trim().length > 0 &&
81
+ password.length > 0 &&
82
+ validationErrors.length === 0;
83
+
84
+ return (
85
+ <Dialog open={open} onOpenChange={onOpenChange}>
86
+ <DialogContent className="sm:max-w-[425px]">
87
+ <DialogHeader>
88
+ <DialogTitle>Create User</DialogTitle>
89
+ <DialogDescription>
90
+ Create a new user account with email and password credentials.
91
+ </DialogDescription>
92
+ </DialogHeader>
93
+ <form onSubmit={handleSubmit}>
94
+ <div className="grid gap-4 py-4">
95
+ <div className="grid gap-2">
96
+ <Label htmlFor="create-name">Name</Label>
97
+ <Input
98
+ id="create-name"
99
+ value={name}
100
+ onChange={(e) => setName(e.target.value)}
101
+ placeholder="John Doe"
102
+ required
103
+ />
104
+ </div>
105
+ <div className="grid gap-2">
106
+ <Label htmlFor="create-email">Email</Label>
107
+ <Input
108
+ id="create-email"
109
+ type="email"
110
+ value={email}
111
+ onChange={(e) => setEmail(e.target.value)}
112
+ placeholder="john@example.com"
113
+ required
114
+ />
115
+ </div>
116
+ <div className="grid gap-2">
117
+ <Label htmlFor="create-password">Password</Label>
118
+ <Input
119
+ id="create-password"
120
+ type="password"
121
+ value={password}
122
+ onChange={(e) => setPassword(e.target.value)}
123
+ required
124
+ />
125
+ {validationErrors.length > 0 && (
126
+ <ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
127
+ {validationErrors.map((error, i) => (
128
+ <li key={i} className="text-destructive">
129
+ {error}
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )}
134
+ <p className="text-xs text-muted-foreground">
135
+ At least 8 characters with uppercase, lowercase, and number
136
+ </p>
137
+ </div>
138
+ </div>
139
+ <DialogFooter>
140
+ <Button
141
+ type="button"
142
+ variant="outline"
143
+ onClick={() => onOpenChange(false)}
144
+ disabled={loading}
145
+ >
146
+ Cancel
147
+ </Button>
148
+ <Button type="submit" disabled={!isValid || loading}>
149
+ {loading ? "Creating..." : "Create User"}
150
+ </Button>
151
+ </DialogFooter>
152
+ </form>
153
+ </DialogContent>
154
+ </Dialog>
155
+ );
156
+ };
@@ -0,0 +1,131 @@
1
+ import React, { useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Mail, ArrowLeft, CheckCircle } from "lucide-react";
4
+ import { authRoutes } from "@checkstack/auth-common";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import {
7
+ Button,
8
+ Input,
9
+ Label,
10
+ Card,
11
+ CardHeader,
12
+ CardTitle,
13
+ CardDescription,
14
+ CardContent,
15
+ CardFooter,
16
+ } from "@checkstack/ui";
17
+ import { useAuthClient } from "../lib/auth-client";
18
+
19
+ export const ForgotPasswordPage = () => {
20
+ const [email, setEmail] = useState("");
21
+ const [loading, setLoading] = useState(false);
22
+ const [submitted, setSubmitted] = useState(false);
23
+ const authClient = useAuthClient();
24
+
25
+ const handleSubmit = async (e: React.FormEvent) => {
26
+ e.preventDefault();
27
+ setLoading(true);
28
+ try {
29
+ // Always show success message to prevent user enumeration
30
+ await authClient.requestPasswordReset({
31
+ email,
32
+ redirectTo: "/auth/reset-password",
33
+ });
34
+ } catch (error) {
35
+ // Silently handle errors to prevent timing attacks
36
+ console.error("Password reset request failed:", error);
37
+ } finally {
38
+ setLoading(false);
39
+ setSubmitted(true);
40
+ }
41
+ };
42
+
43
+ if (submitted) {
44
+ return (
45
+ <div className="min-h-[80vh] flex items-center justify-center">
46
+ <Card className="w-full max-w-md">
47
+ <CardHeader className="space-y-1 text-center">
48
+ <div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-2">
49
+ <CheckCircle className="h-6 w-6 text-primary" />
50
+ </div>
51
+ <CardTitle className="text-2xl font-bold">
52
+ Check Your Email
53
+ </CardTitle>
54
+ <CardDescription>
55
+ If an account exists for <strong>{email}</strong>, you will
56
+ receive a password reset link shortly.
57
+ </CardDescription>
58
+ </CardHeader>
59
+ <CardContent className="text-center text-sm text-muted-foreground">
60
+ <p>
61
+ Didn't receive an email? Check your spam folder or make sure you
62
+ entered the correct email address.
63
+ </p>
64
+ </CardContent>
65
+ <CardFooter className="flex flex-col gap-4">
66
+ <Button
67
+ variant="outline"
68
+ className="w-full"
69
+ onClick={() => setSubmitted(false)}
70
+ >
71
+ Try Another Email
72
+ </Button>
73
+ <Link
74
+ to={resolveRoute(authRoutes.routes.login)}
75
+ className="text-sm text-primary hover:underline flex items-center justify-center gap-1"
76
+ >
77
+ <ArrowLeft className="h-4 w-4" />
78
+ Back to Login
79
+ </Link>
80
+ </CardFooter>
81
+ </Card>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <div className="min-h-[80vh] flex items-center justify-center">
88
+ <Card className="w-full max-w-md">
89
+ <CardHeader className="space-y-1">
90
+ <CardTitle className="text-2xl font-bold">Forgot Password</CardTitle>
91
+ <CardDescription>
92
+ Enter your email address and we'll send you a link to reset your
93
+ password.
94
+ </CardDescription>
95
+ </CardHeader>
96
+ <form onSubmit={handleSubmit}>
97
+ <CardContent className="space-y-4">
98
+ <div className="space-y-2">
99
+ <Label htmlFor="email">Email</Label>
100
+ <div className="relative">
101
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
102
+ <Input
103
+ id="email"
104
+ type="email"
105
+ placeholder="Enter your email"
106
+ value={email}
107
+ onChange={(e) => setEmail(e.target.value)}
108
+ className="pl-10"
109
+ required
110
+ autoFocus
111
+ />
112
+ </div>
113
+ </div>
114
+ </CardContent>
115
+ <CardFooter className="flex flex-col gap-4">
116
+ <Button type="submit" className="w-full" disabled={loading}>
117
+ {loading ? "Sending..." : "Send Reset Link"}
118
+ </Button>
119
+ <Link
120
+ to={resolveRoute(authRoutes.routes.login)}
121
+ className="text-sm text-primary hover:underline flex items-center justify-center gap-1"
122
+ >
123
+ <ArrowLeft className="h-4 w-4" />
124
+ Back to Login
125
+ </Link>
126
+ </CardFooter>
127
+ </form>
128
+ </Card>
129
+ </div>
130
+ );
131
+ };
@@ -0,0 +1,330 @@
1
+ import React, { useState } from "react";
2
+ import { Link, useNavigate } from "react-router-dom";
3
+ import { LogIn, LogOut, AlertCircle } from "lucide-react";
4
+ import {
5
+ useApi,
6
+ ExtensionSlot,
7
+ pluginRegistry,
8
+ rpcApiRef,
9
+ UserMenuItemsSlot,
10
+ UserMenuItemsBottomSlot,
11
+ UserMenuItemsContext,
12
+ } from "@checkstack/frontend-api";
13
+ import { AuthApi, authRoutes } from "@checkstack/auth-common";
14
+ import { resolveRoute } from "@checkstack/common";
15
+ import {
16
+ Button,
17
+ Input,
18
+ Label,
19
+ Card,
20
+ CardHeader,
21
+ CardTitle,
22
+ CardDescription,
23
+ CardContent,
24
+ CardFooter,
25
+ UserMenu,
26
+ DropdownMenuItem,
27
+ DropdownMenuSeparator,
28
+ Alert,
29
+ AlertIcon,
30
+ AlertContent,
31
+ AlertTitle,
32
+ AlertDescription,
33
+ InfoBanner,
34
+ InfoBannerIcon,
35
+ InfoBannerContent,
36
+ InfoBannerTitle,
37
+ InfoBannerDescription,
38
+ } from "@checkstack/ui";
39
+ import { authApiRef } from "../api";
40
+ import { useEnabledStrategies } from "../hooks/useEnabledStrategies";
41
+ import { usePermissions } from "../hooks/usePermissions";
42
+ import { useAuthClient } from "../lib/auth-client";
43
+ import { SocialProviderButton } from "./SocialProviderButton";
44
+ import { useEffect } from "react";
45
+
46
+ export const LoginPage = () => {
47
+ const [email, setEmail] = useState("");
48
+ const [password, setPassword] = useState("");
49
+ const [loading, setLoading] = useState(false);
50
+ const navigate = useNavigate();
51
+ const authApi = useApi(authApiRef);
52
+ const rpcApi = useApi(rpcApiRef);
53
+ const authRpcClient = rpcApi.forPlugin(AuthApi);
54
+ const { strategies, loading: strategiesLoading } = useEnabledStrategies();
55
+ const [registrationAllowed, setRegistrationAllowed] = useState<boolean>(true);
56
+
57
+ useEffect(() => {
58
+ authRpcClient
59
+ .getRegistrationStatus()
60
+ .then(({ allowRegistration }) => {
61
+ setRegistrationAllowed(allowRegistration);
62
+ })
63
+ .catch((error: Error) => {
64
+ console.error("Failed to check registration status:", error);
65
+ setRegistrationAllowed(true);
66
+ });
67
+ }, [authRpcClient]);
68
+
69
+ const handleCredentialLogin = async (e: React.FormEvent) => {
70
+ e.preventDefault();
71
+ setLoading(true);
72
+ try {
73
+ const { error } = await authApi.signIn(email, password);
74
+ if (error) {
75
+ console.error("Login failed:", error);
76
+ } else {
77
+ navigate("/");
78
+ }
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ };
83
+
84
+ const handleSocialLogin = async (provider: string) => {
85
+ try {
86
+ await authApi.signInWithSocial(provider);
87
+ // Navigation will happen automatically after OAuth redirect
88
+ } catch (error) {
89
+ console.error("Social login failed:", error);
90
+ }
91
+ };
92
+
93
+ const credentialStrategy = strategies.find((s) => s.type === "credential");
94
+ const socialStrategies = strategies.filter((s) => s.type === "social");
95
+ const hasCredential = !!credentialStrategy;
96
+ const hasSocial = socialStrategies.length > 0;
97
+
98
+ // Loading state
99
+ if (strategiesLoading) {
100
+ return (
101
+ <div className="min-h-[80vh] flex items-center justify-center">
102
+ <Card className="w-full max-w-md">
103
+ <CardContent className="pt-6">
104
+ <div className="space-y-4">
105
+ <div className="h-4 bg-muted animate-pulse rounded" />
106
+ <div className="h-10 bg-muted animate-pulse rounded" />
107
+ <div className="h-10 bg-muted animate-pulse rounded" />
108
+ <div className="h-10 bg-muted animate-pulse rounded" />
109
+ </div>
110
+ </CardContent>
111
+ </Card>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // No strategies enabled
117
+ if (strategies.length === 0) {
118
+ return (
119
+ <div className="min-h-[80vh] flex items-center justify-center">
120
+ <Card className="w-full max-w-md">
121
+ <CardHeader className="flex flex-col space-y-1 items-center">
122
+ <CardTitle>Authentication Unavailable</CardTitle>
123
+ </CardHeader>
124
+ <CardContent>
125
+ <Alert variant="warning">
126
+ <AlertIcon>
127
+ <AlertCircle className="h-4 w-4" />
128
+ </AlertIcon>
129
+ <AlertContent>
130
+ <AlertTitle>No authentication methods enabled</AlertTitle>
131
+ <AlertDescription>
132
+ Please contact your system administrator to enable
133
+ authentication methods.
134
+ </AlertDescription>
135
+ </AlertContent>
136
+ </Alert>
137
+ </CardContent>
138
+ </Card>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <div className="min-h-[80vh] flex items-center justify-center">
145
+ <Card className="w-full max-w-md">
146
+ <CardHeader className="flex flex-col space-y-1 items-center">
147
+ <CardTitle>Sign in to your account</CardTitle>
148
+ <CardDescription>
149
+ {hasCredential && hasSocial
150
+ ? "Choose your preferred sign-in method"
151
+ : hasCredential
152
+ ? "Enter your credentials to access the dashboard"
153
+ : "Continue with your account"}
154
+ </CardDescription>
155
+ </CardHeader>
156
+ <CardContent>
157
+ <div className="space-y-4">
158
+ {/* Registration Disabled Banner */}
159
+ {!registrationAllowed && (
160
+ <InfoBanner variant="warning">
161
+ <InfoBannerIcon>
162
+ <AlertCircle className="h-4 w-4" />
163
+ </InfoBannerIcon>
164
+ <InfoBannerContent>
165
+ <InfoBannerTitle>Registration Disabled</InfoBannerTitle>
166
+ <InfoBannerDescription>
167
+ New user registration is currently disabled. Please contact
168
+ an administrator if you need access.
169
+ </InfoBannerDescription>
170
+ </InfoBannerContent>
171
+ </InfoBanner>
172
+ )}
173
+
174
+ {/* Credential Form */}
175
+ {hasCredential && (
176
+ <form className="space-y-4" onSubmit={handleCredentialLogin}>
177
+ <div className="space-y-2">
178
+ <Label htmlFor="email">Email</Label>
179
+ <Input
180
+ id="email"
181
+ placeholder="name@example.com"
182
+ type="email"
183
+ required
184
+ value={email}
185
+ onChange={(e) => setEmail(e.target.value)}
186
+ />
187
+ </div>
188
+ <div className="space-y-2">
189
+ <Label htmlFor="password">Password</Label>
190
+ <Input
191
+ id="password"
192
+ required
193
+ type="password"
194
+ value={password}
195
+ onChange={(e) => setPassword(e.target.value)}
196
+ />
197
+ <div className="text-right">
198
+ <Link
199
+ to={resolveRoute(authRoutes.routes.forgotPassword)}
200
+ className="text-sm text-primary hover:underline"
201
+ >
202
+ Forgot password?
203
+ </Link>
204
+ </div>
205
+ </div>
206
+ <Button type="submit" className="w-full" disabled={loading}>
207
+ {loading ? "Signing In..." : "Sign In"}
208
+ </Button>
209
+ </form>
210
+ )}
211
+
212
+ {/* Separator */}
213
+ {hasCredential && hasSocial && (
214
+ <div className="relative">
215
+ <div className="absolute inset-0 flex items-center">
216
+ <span className="w-full border-t border-border" />
217
+ </div>
218
+ <div className="relative flex justify-center text-xs uppercase">
219
+ <span className="bg-card px-2 text-muted-foreground">
220
+ Or continue with
221
+ </span>
222
+ </div>
223
+ </div>
224
+ )}
225
+
226
+ {/* Social Provider Buttons */}
227
+ {hasSocial && (
228
+ <div className="space-y-2">
229
+ {socialStrategies.map((strategy) => (
230
+ <SocialProviderButton
231
+ key={strategy.id}
232
+ displayName={strategy.displayName}
233
+ icon={strategy.icon}
234
+ onClick={() => handleSocialLogin(strategy.id)}
235
+ />
236
+ ))}
237
+ </div>
238
+ )}
239
+ </div>
240
+ </CardContent>
241
+ {registrationAllowed &&
242
+ strategies.some((s) => s.requiresManualRegistration) && (
243
+ <CardFooter className="flex justify-center border-t border-border mt-4 pt-4">
244
+ <div className="text-sm">
245
+ Don't have an account?{" "}
246
+ <Link
247
+ to={resolveRoute(authRoutes.routes.register)}
248
+ className="underline text-primary hover:text-primary/90 font-medium"
249
+ >
250
+ Sign up
251
+ </Link>
252
+ </div>
253
+ </CardFooter>
254
+ )}
255
+ </Card>
256
+ </div>
257
+ );
258
+ };
259
+
260
+ export const LogoutMenuItem = (_props: UserMenuItemsContext) => {
261
+ const authApi = useApi(authApiRef);
262
+
263
+ return (
264
+ <DropdownMenuItem
265
+ onClick={() => authApi.signOut()}
266
+ icon={<LogOut className="h-4 w-4" />}
267
+ >
268
+ Logout
269
+ </DropdownMenuItem>
270
+ );
271
+ };
272
+
273
+ export const LoginNavbarAction = () => {
274
+ const authApi = useApi(authApiRef);
275
+ const { data: session, isPending } = authApi.useSession();
276
+ const { permissions, loading: permissionsLoading } = usePermissions();
277
+ const authClient = useAuthClient();
278
+ const [hasCredentialAccount, setHasCredentialAccount] =
279
+ useState<boolean>(false);
280
+ const [credentialLoading, setCredentialLoading] = useState(true);
281
+
282
+ useEffect(() => {
283
+ if (!session?.user) {
284
+ setCredentialLoading(false);
285
+ return;
286
+ }
287
+ authClient.listAccounts().then((result) => {
288
+ if (result.data) {
289
+ const hasCredential = result.data.some(
290
+ (account) => account.providerId === "credential"
291
+ );
292
+ setHasCredentialAccount(hasCredential);
293
+ }
294
+ setCredentialLoading(false);
295
+ });
296
+ }, [session?.user, authClient]);
297
+
298
+ if (isPending || permissionsLoading || credentialLoading) {
299
+ return <div className="w-20 h-9 bg-muted animate-pulse rounded-full" />;
300
+ }
301
+
302
+ if (session?.user) {
303
+ // Check if we have any bottom items to decide if we need a separator
304
+ const bottomExtensions = pluginRegistry.getExtensions(
305
+ UserMenuItemsBottomSlot.id
306
+ );
307
+ const hasBottomItems = bottomExtensions.length > 0;
308
+ const menuContext: UserMenuItemsContext = {
309
+ permissions,
310
+ hasCredentialAccount,
311
+ };
312
+
313
+ return (
314
+ <UserMenu user={session.user}>
315
+ <ExtensionSlot slot={UserMenuItemsSlot} context={menuContext} />
316
+ {hasBottomItems && <DropdownMenuSeparator />}
317
+ <ExtensionSlot slot={UserMenuItemsBottomSlot} context={menuContext} />
318
+ </UserMenu>
319
+ );
320
+ }
321
+
322
+ return (
323
+ <Link to={resolveRoute(authRoutes.routes.login)}>
324
+ <Button variant="outline" className="flex items-center rounded-full px-5">
325
+ <LogIn className="mr-2 h-4 w-4" />
326
+ Login
327
+ </Button>
328
+ </Link>
329
+ );
330
+ };