@checkstack/auth-frontend 0.3.1 → 0.4.0

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # @checkstack/auth-frontend
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - df6ac7b: Added onboarding flow and user profile
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [df6ac7b]
12
+ - @checkstack/auth-common@0.4.0
13
+
3
14
  ## 0.3.1
4
15
 
5
16
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-frontend",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "exports": {
@@ -0,0 +1,33 @@
1
+ import { AuthApi } from "@checkstack/auth-common";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { useEffect } from "react";
4
+ import { useLocation, useNavigate } from "react-router-dom";
5
+
6
+ /**
7
+ * Onboarding guard that redirects to onboarding page if no users exist.
8
+ * Skips check if already on onboarding page.
9
+ */
10
+ export function OnboardingCheck() {
11
+ const navigate = useNavigate();
12
+ const location = useLocation();
13
+ const authApi = usePluginClient(AuthApi);
14
+
15
+ const { data, isLoading } = authApi.getOnboardingStatus.useQuery();
16
+
17
+ useEffect(() => {
18
+ if (isLoading) {
19
+ return;
20
+ }
21
+
22
+ // Skip check if already on onboarding page
23
+ if (location.pathname === "/auth/onboarding") {
24
+ return;
25
+ }
26
+
27
+ if (data?.needsOnboarding) {
28
+ navigate("/auth/onboarding", { replace: true });
29
+ }
30
+ }, [isLoading, data, location.pathname, navigate]);
31
+
32
+ return <></>;
33
+ }
@@ -0,0 +1,291 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { User, Lock, Mail, CheckCircle, AlertCircle } from "lucide-react";
4
+ import { usePluginClient } from "@checkstack/frontend-api";
5
+ import { AuthApi, authRoutes, passwordSchema } from "@checkstack/auth-common";
6
+ import { resolveRoute } from "@checkstack/common";
7
+ import {
8
+ Button,
9
+ Input,
10
+ Label,
11
+ Card,
12
+ CardHeader,
13
+ CardTitle,
14
+ CardDescription,
15
+ CardContent,
16
+ CardFooter,
17
+ Alert,
18
+ AlertIcon,
19
+ AlertContent,
20
+ AlertTitle,
21
+ AlertDescription,
22
+ } from "@checkstack/ui";
23
+ import { useAuthClient } from "../lib/auth-client";
24
+
25
+ export const OnboardingPage = () => {
26
+ const navigate = useNavigate();
27
+ const [name, setName] = useState("");
28
+ const [email, setEmail] = useState("");
29
+ const [password, setPassword] = useState("");
30
+ const [confirmPassword, setConfirmPassword] = useState("");
31
+ const [loading, setLoading] = useState(false);
32
+ const [error, setError] = useState<string>();
33
+ const [success, setSuccess] = useState(false);
34
+ const [validationErrors, setValidationErrors] = useState<string[]>([]);
35
+
36
+ const authClient = usePluginClient(AuthApi);
37
+ const completeOnboardingMutation =
38
+ authClient.completeOnboarding.useMutation();
39
+ const betterAuthClient = useAuthClient();
40
+
41
+ // Check if onboarding is needed
42
+ const { data: onboardingStatus, isLoading: checkingStatus } =
43
+ authClient.getOnboardingStatus.useQuery({});
44
+
45
+ // Redirect if onboarding not needed
46
+ useEffect(() => {
47
+ if (
48
+ !checkingStatus &&
49
+ onboardingStatus &&
50
+ !onboardingStatus.needsOnboarding
51
+ ) {
52
+ navigate(resolveRoute(authRoutes.routes.login));
53
+ }
54
+ }, [checkingStatus, onboardingStatus, navigate]);
55
+
56
+ // Validate password on change
57
+ useEffect(() => {
58
+ if (password) {
59
+ const result = passwordSchema.safeParse(password);
60
+ if (result.success) {
61
+ setValidationErrors([]);
62
+ } else {
63
+ setValidationErrors(result.error.issues.map((issue) => issue.message));
64
+ }
65
+ } else {
66
+ setValidationErrors([]);
67
+ }
68
+ }, [password]);
69
+
70
+ const handleSubmit = async (e: React.FormEvent) => {
71
+ e.preventDefault();
72
+ setError(undefined);
73
+
74
+ // Validate password match
75
+ if (password !== confirmPassword) {
76
+ setError("Passwords do not match");
77
+ return;
78
+ }
79
+
80
+ // Validate password strength
81
+ const result = passwordSchema.safeParse(password);
82
+ if (!result.success) {
83
+ setError(result.error.issues[0].message);
84
+ return;
85
+ }
86
+
87
+ setLoading(true);
88
+ try {
89
+ const response = await completeOnboardingMutation.mutateAsync({
90
+ name,
91
+ email,
92
+ password,
93
+ });
94
+
95
+ if (response.success) {
96
+ // Auto-login the user
97
+ const loginRes = await betterAuthClient.signIn.email({
98
+ email,
99
+ password,
100
+ });
101
+
102
+ if (loginRes.error) {
103
+ setError("Account created but login failed. Please login manually.");
104
+ } else {
105
+ setSuccess(true);
106
+ // Redirect to dashboard
107
+ setTimeout(() => {
108
+ globalThis.location.href = "/";
109
+ }, 1500);
110
+ }
111
+ }
112
+ } catch (error_) {
113
+ const message =
114
+ error_ instanceof Error ? error_.message : "Failed to complete setup";
115
+ setError(message);
116
+ } finally {
117
+ setLoading(false);
118
+ }
119
+ };
120
+
121
+ // Loading state
122
+ if (checkingStatus) {
123
+ return (
124
+ <div className="min-h-[80vh] flex items-center justify-center">
125
+ <Card className="w-full max-w-md">
126
+ <CardContent className="pt-6">
127
+ <div className="space-y-4">
128
+ <div className="h-4 bg-muted animate-pulse rounded" />
129
+ <div className="h-10 bg-muted animate-pulse rounded" />
130
+ <div className="h-10 bg-muted animate-pulse rounded" />
131
+ </div>
132
+ </CardContent>
133
+ </Card>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ // Success state
139
+ if (success) {
140
+ return (
141
+ <div className="min-h-[80vh] flex items-center justify-center">
142
+ <Card className="w-full max-w-md">
143
+ <CardHeader className="space-y-1 text-center">
144
+ <div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-2">
145
+ <CheckCircle className="h-6 w-6 text-primary" />
146
+ </div>
147
+ <CardTitle className="text-2xl font-bold">
148
+ Setup Complete!
149
+ </CardTitle>
150
+ <CardDescription>
151
+ Your admin account has been created. Redirecting to dashboard...
152
+ </CardDescription>
153
+ </CardHeader>
154
+ </Card>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <div className="min-h-[80vh] flex items-center justify-center">
161
+ <Card className="w-full max-w-md">
162
+ <CardHeader className="space-y-1 text-center">
163
+ <CardTitle className="text-2xl font-bold">
164
+ Welcome to Checkstack
165
+ </CardTitle>
166
+ <CardDescription>
167
+ Create your administrator account to get started
168
+ </CardDescription>
169
+ </CardHeader>
170
+ <form onSubmit={handleSubmit}>
171
+ <CardContent className="space-y-4">
172
+ {error && (
173
+ <Alert variant="error">
174
+ <AlertIcon>
175
+ <AlertCircle className="h-4 w-4" />
176
+ </AlertIcon>
177
+ <AlertContent>
178
+ <AlertTitle>Error</AlertTitle>
179
+ <AlertDescription>{error}</AlertDescription>
180
+ </AlertContent>
181
+ </Alert>
182
+ )}
183
+
184
+ <div className="space-y-2">
185
+ <Label htmlFor="name">Name</Label>
186
+ <div className="relative">
187
+ <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
188
+ <Input
189
+ id="name"
190
+ type="text"
191
+ placeholder="Your name"
192
+ value={name}
193
+ onChange={(e) => setName(e.target.value)}
194
+ className="pl-10"
195
+ required
196
+ autoFocus
197
+ />
198
+ </div>
199
+ </div>
200
+
201
+ <div className="space-y-2">
202
+ <Label htmlFor="email">Email</Label>
203
+ <div className="relative">
204
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
205
+ <Input
206
+ id="email"
207
+ type="email"
208
+ placeholder="admin@example.com"
209
+ value={email}
210
+ onChange={(e) => setEmail(e.target.value)}
211
+ className="pl-10"
212
+ required
213
+ />
214
+ </div>
215
+ </div>
216
+
217
+ <div className="space-y-2">
218
+ <Label htmlFor="password">Password</Label>
219
+ <div className="relative">
220
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
221
+ <Input
222
+ id="password"
223
+ type="password"
224
+ placeholder="Create a strong password"
225
+ value={password}
226
+ onChange={(e) => setPassword(e.target.value)}
227
+ className="pl-10"
228
+ required
229
+ />
230
+ </div>
231
+ {validationErrors.length > 0 && (
232
+ <ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
233
+ {validationErrors.map((validationError, i) => (
234
+ <li key={i} className="text-destructive">
235
+ {validationError}
236
+ </li>
237
+ ))}
238
+ </ul>
239
+ )}
240
+ </div>
241
+
242
+ <div className="space-y-2">
243
+ <Label htmlFor="confirmPassword">Confirm Password</Label>
244
+ <div className="relative">
245
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
246
+ <Input
247
+ id="confirmPassword"
248
+ type="password"
249
+ placeholder="Confirm your password"
250
+ value={confirmPassword}
251
+ onChange={(e) => setConfirmPassword(e.target.value)}
252
+ className="pl-10"
253
+ required
254
+ />
255
+ </div>
256
+ {confirmPassword && password !== confirmPassword && (
257
+ <p className="text-sm text-destructive">
258
+ Passwords do not match
259
+ </p>
260
+ )}
261
+ </div>
262
+
263
+ <div className="text-xs text-muted-foreground">
264
+ Password must be at least 8 characters and contain:
265
+ <ul className="list-disc pl-5 mt-1">
266
+ <li>At least one uppercase letter</li>
267
+ <li>At least one lowercase letter</li>
268
+ <li>At least one number</li>
269
+ </ul>
270
+ </div>
271
+ </CardContent>
272
+ <CardFooter>
273
+ <Button
274
+ type="submit"
275
+ className="w-full"
276
+ disabled={
277
+ loading ||
278
+ validationErrors.length > 0 ||
279
+ password !== confirmPassword ||
280
+ !name ||
281
+ !email
282
+ }
283
+ >
284
+ {loading ? "Creating Account..." : "Complete Setup"}
285
+ </Button>
286
+ </CardFooter>
287
+ </form>
288
+ </Card>
289
+ </div>
290
+ );
291
+ };
@@ -0,0 +1,223 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useNavigate, Link } from "react-router-dom";
3
+ import {
4
+ User,
5
+ Mail,
6
+ Key,
7
+ ArrowLeft,
8
+ CheckCircle,
9
+ AlertCircle,
10
+ } from "lucide-react";
11
+ import { usePluginClient } from "@checkstack/frontend-api";
12
+ import { AuthApi, authRoutes } from "@checkstack/auth-common";
13
+ import { resolveRoute } from "@checkstack/common";
14
+ import {
15
+ Button,
16
+ Input,
17
+ Label,
18
+ Card,
19
+ CardHeader,
20
+ CardTitle,
21
+ CardDescription,
22
+ CardContent,
23
+ CardFooter,
24
+ Alert,
25
+ AlertIcon,
26
+ AlertContent,
27
+ AlertTitle,
28
+ AlertDescription,
29
+ } from "@checkstack/ui";
30
+
31
+ export const ProfilePage = () => {
32
+ const navigate = useNavigate();
33
+ const [name, setName] = useState("");
34
+ const [email, setEmail] = useState("");
35
+ const [originalName, setOriginalName] = useState("");
36
+ const [originalEmail, setOriginalEmail] = useState("");
37
+ const [loading, setLoading] = useState(false);
38
+ const [error, setError] = useState<string>();
39
+ const [success, setSuccess] = useState(false);
40
+ const [hasCredentialAccount, setHasCredentialAccount] = useState(false);
41
+
42
+ const authClient = usePluginClient(AuthApi);
43
+
44
+ // Fetch current user profile
45
+ const { data: profile, isLoading: loadingProfile } =
46
+ authClient.getCurrentUserProfile.useQuery({});
47
+
48
+ // Update mutation
49
+ const updateMutation = authClient.updateCurrentUser.useMutation({
50
+ onSuccess: () => {
51
+ setSuccess(true);
52
+ setOriginalName(name);
53
+ setOriginalEmail(email);
54
+ setTimeout(() => setSuccess(false), 3000);
55
+ },
56
+ onError: (err) => {
57
+ setError(err.message);
58
+ },
59
+ });
60
+
61
+ // Populate form when profile loads
62
+ useEffect(() => {
63
+ if (profile) {
64
+ setName(profile.name);
65
+ setEmail(profile.email);
66
+ setOriginalName(profile.name);
67
+ setOriginalEmail(profile.email);
68
+ setHasCredentialAccount(profile.hasCredentialAccount);
69
+ }
70
+ }, [profile]);
71
+
72
+ const handleSubmit = async (e: React.FormEvent) => {
73
+ e.preventDefault();
74
+ setError(undefined);
75
+ setLoading(true);
76
+
77
+ try {
78
+ const updates: { name?: string; email?: string } = {};
79
+ if (name !== originalName) updates.name = name;
80
+ if (email !== originalEmail && hasCredentialAccount)
81
+ updates.email = email;
82
+
83
+ // Only call if there are changes
84
+ if (Object.keys(updates).length > 0) {
85
+ await updateMutation.mutateAsync(updates);
86
+ } else {
87
+ setSuccess(true);
88
+ setTimeout(() => setSuccess(false), 3000);
89
+ }
90
+ } catch {
91
+ // Error handled by mutation
92
+ } finally {
93
+ setLoading(false);
94
+ }
95
+ };
96
+
97
+ const hasChanges =
98
+ name !== originalName || (hasCredentialAccount && email !== originalEmail);
99
+
100
+ // Loading state
101
+ if (loadingProfile) {
102
+ return (
103
+ <div className="min-h-[80vh] flex items-center justify-center">
104
+ <Card className="w-full max-w-md">
105
+ <CardContent className="pt-6">
106
+ <div className="space-y-4">
107
+ <div className="h-4 bg-muted animate-pulse rounded" />
108
+ <div className="h-10 bg-muted animate-pulse rounded" />
109
+ <div className="h-10 bg-muted animate-pulse rounded" />
110
+ </div>
111
+ </CardContent>
112
+ </Card>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div className="min-h-[80vh] flex items-center justify-center">
119
+ <Card className="w-full max-w-md">
120
+ <CardHeader className="space-y-1">
121
+ <CardTitle className="text-2xl font-bold">Profile</CardTitle>
122
+ <CardDescription>Manage your account settings</CardDescription>
123
+ </CardHeader>
124
+ <form onSubmit={handleSubmit}>
125
+ <CardContent className="space-y-4">
126
+ {error && (
127
+ <Alert variant="error">
128
+ <AlertIcon>
129
+ <AlertCircle className="h-4 w-4" />
130
+ </AlertIcon>
131
+ <AlertContent>
132
+ <AlertTitle>Error</AlertTitle>
133
+ <AlertDescription>{error}</AlertDescription>
134
+ </AlertContent>
135
+ </Alert>
136
+ )}
137
+
138
+ {success && (
139
+ <Alert variant="success">
140
+ <AlertIcon>
141
+ <CheckCircle className="h-4 w-4" />
142
+ </AlertIcon>
143
+ <AlertContent>
144
+ <AlertTitle>Success</AlertTitle>
145
+ <AlertDescription>
146
+ Profile updated successfully
147
+ </AlertDescription>
148
+ </AlertContent>
149
+ </Alert>
150
+ )}
151
+
152
+ <div className="space-y-2">
153
+ <Label htmlFor="name">Name</Label>
154
+ <div className="relative">
155
+ <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
156
+ <Input
157
+ id="name"
158
+ type="text"
159
+ placeholder="Your name"
160
+ value={name}
161
+ onChange={(e) => setName(e.target.value)}
162
+ className="pl-10"
163
+ required
164
+ />
165
+ </div>
166
+ </div>
167
+
168
+ <div className="space-y-2">
169
+ <Label htmlFor="email">Email</Label>
170
+ <div className="relative">
171
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
172
+ <Input
173
+ id="email"
174
+ type="email"
175
+ placeholder="your@email.com"
176
+ value={email}
177
+ onChange={(e) => setEmail(e.target.value)}
178
+ className="pl-10"
179
+ disabled={!hasCredentialAccount}
180
+ required
181
+ />
182
+ </div>
183
+ {!hasCredentialAccount && (
184
+ <p className="text-xs text-muted-foreground">
185
+ Email is managed by your social login provider
186
+ </p>
187
+ )}
188
+ </div>
189
+
190
+ {hasCredentialAccount && (
191
+ <div className="pt-2">
192
+ <Link
193
+ to={resolveRoute(authRoutes.routes.changePassword)}
194
+ className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
195
+ >
196
+ <Key className="h-4 w-4" />
197
+ Change Password
198
+ </Link>
199
+ </div>
200
+ )}
201
+ </CardContent>
202
+ <CardFooter className="flex flex-col gap-4">
203
+ <Button
204
+ type="submit"
205
+ className="w-full"
206
+ disabled={loading || !hasChanges}
207
+ >
208
+ {loading ? "Saving..." : "Save Changes"}
209
+ </Button>
210
+ <button
211
+ type="button"
212
+ onClick={() => navigate(-1)}
213
+ className="text-sm text-primary hover:underline flex items-center justify-center gap-1"
214
+ >
215
+ <ArrowLeft className="h-4 w-4" />
216
+ Go Back
217
+ </button>
218
+ </CardFooter>
219
+ </form>
220
+ </Card>
221
+ </div>
222
+ );
223
+ };
package/src/index.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  NavbarRightSlot,
9
9
  UserMenuItemsSlot,
10
10
  UserMenuItemsBottomSlot,
11
+ NavbarLeftSlot,
11
12
  } from "@checkstack/frontend-api";
12
13
  import {
13
14
  LoginPage,
@@ -19,6 +20,8 @@ import { AuthErrorPage } from "./components/AuthErrorPage";
19
20
  import { ForgotPasswordPage } from "./components/ForgotPasswordPage";
20
21
  import { ResetPasswordPage } from "./components/ResetPasswordPage";
21
22
  import { ChangePasswordPage } from "./components/ChangePasswordPage";
23
+ import { OnboardingPage } from "./components/OnboardingPage";
24
+ import { ProfilePage } from "./components/ProfilePage";
22
25
  import { authApiRef, AuthApi, AuthSession } from "./api";
23
26
  import { getAuthClientLazy } from "./lib/auth-client";
24
27
 
@@ -26,7 +29,7 @@ import { useAccessRules } from "./hooks/useAccessRules";
26
29
 
27
30
  import type { AccessRule } from "@checkstack/common";
28
31
  import { useNavigate } from "react-router-dom";
29
- import { Settings2, Key } from "lucide-react";
32
+ import { Settings2, User } from "lucide-react";
30
33
  import { DropdownMenuItem } from "@checkstack/ui";
31
34
  import { UserMenuItemsContext } from "@checkstack/frontend-api";
32
35
  import { AuthSettingsPage } from "./components/AuthSettingsPage";
@@ -36,6 +39,7 @@ import {
36
39
  pluginMetadata,
37
40
  } from "@checkstack/auth-common";
38
41
  import { resolveRoute } from "@checkstack/common";
42
+ import { OnboardingCheck } from "./components/OnboardingCheck";
39
43
 
40
44
  /**
41
45
  * Unified access API implementation.
@@ -108,7 +112,7 @@ class BetterAuthApi implements AuthApi {
108
112
  provider,
109
113
  callbackURL: frontendUrl,
110
114
  errorCallbackURL: `${frontendUrl}${resolveRoute(
111
- authRoutes.routes.error
115
+ authRoutes.routes.error,
112
116
  )}`,
113
117
  });
114
118
  }
@@ -194,6 +198,14 @@ export const authPlugin = createFrontendPlugin({
194
198
  route: authRoutes.routes.changePassword,
195
199
  element: <ChangePasswordPage />,
196
200
  },
201
+ {
202
+ route: authRoutes.routes.profile,
203
+ element: <ProfilePage />,
204
+ },
205
+ {
206
+ route: authRoutes.routes.onboarding,
207
+ element: <OnboardingPage />,
208
+ },
197
209
  ],
198
210
  extensions: [
199
211
  {
@@ -222,22 +234,16 @@ export const authPlugin = createFrontendPlugin({
222
234
  },
223
235
  }),
224
236
  createSlotExtension(UserMenuItemsSlot, {
225
- id: "auth.user-menu.change-password",
226
- component: ({ hasCredentialAccount }: UserMenuItemsContext) => {
237
+ id: "auth.user-menu.profile",
238
+ component: () => {
227
239
  const navigate = useNavigate();
228
240
 
229
- // Only show for credential-authenticated users
230
- // The changePassword API requires current password, so only credential users can use it
231
- if (!hasCredentialAccount) return <React.Fragment />;
232
-
233
241
  return (
234
242
  <DropdownMenuItem
235
- onClick={() =>
236
- navigate(resolveRoute(authRoutes.routes.changePassword))
237
- }
238
- icon={<Key className="h-4 w-4" />}
243
+ onClick={() => navigate(resolveRoute(authRoutes.routes.profile))}
244
+ icon={<User className="h-4 w-4" />}
239
245
  >
240
- Change Password
246
+ Profile
241
247
  </DropdownMenuItem>
242
248
  );
243
249
  },
@@ -246,5 +252,9 @@ export const authPlugin = createFrontendPlugin({
246
252
  id: "auth.user-menu.logout",
247
253
  component: LogoutMenuItem,
248
254
  }),
255
+ createSlotExtension(NavbarLeftSlot, {
256
+ id: "auth.onboarding-guard",
257
+ component: OnboardingCheck,
258
+ }),
249
259
  ],
250
260
  });