@checkmate-monitor/auth-frontend 0.1.0 → 0.2.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,38 @@
1
1
  # @checkmate-monitor/auth-frontend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e26c08e: Add password change functionality for credential-authenticated users
8
+
9
+ - Add `changePassword` route to auth-common
10
+ - Create `ChangePasswordPage.tsx` component with password validation, current password verification, and session revocation option
11
+ - Add "Change Password" menu item in User Menu
12
+ - Reuses patterns from existing password reset flow for consistency
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [e26c08e]
17
+ - @checkmate-monitor/auth-common@0.2.0
18
+
19
+ ## 0.1.1
20
+
21
+ ### Patch Changes
22
+
23
+ - 0f8cc7d: Add runtime configuration API for Docker deployments
24
+
25
+ - Backend: Add `/api/config` endpoint serving `BASE_URL` at runtime
26
+ - Backend: Update CORS to use `BASE_URL` and auto-allow Vite dev server
27
+ - Backend: `INTERNAL_URL` now defaults to `localhost:3000` (no BASE_URL fallback)
28
+ - Frontend API: Add `RuntimeConfigProvider` context for runtime config
29
+ - Frontend: Use `RuntimeConfigProvider` from `frontend-api`
30
+ - Auth Frontend: Add `useAuthClient()` hook using runtime config
31
+
32
+ - Updated dependencies [0f8cc7d]
33
+ - @checkmate-monitor/frontend-api@0.0.3
34
+ - @checkmate-monitor/ui@0.1.1
35
+
3
36
  ## 0.1.0
4
37
 
5
38
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkmate-monitor/auth-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "exports": {
@@ -0,0 +1,259 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Lock, ArrowLeft, CheckCircle, AlertCircle, Key } from "lucide-react";
4
+ import { passwordSchema } from "@checkmate-monitor/auth-common";
5
+ import {
6
+ Button,
7
+ Input,
8
+ Label,
9
+ Card,
10
+ CardHeader,
11
+ CardTitle,
12
+ CardDescription,
13
+ CardContent,
14
+ CardFooter,
15
+ Alert,
16
+ AlertIcon,
17
+ AlertContent,
18
+ AlertTitle,
19
+ AlertDescription,
20
+ Checkbox,
21
+ } from "@checkmate-monitor/ui";
22
+ import { useAuthClient } from "../lib/auth-client";
23
+
24
+ export const ChangePasswordPage = () => {
25
+ const navigate = useNavigate();
26
+
27
+ const [currentPassword, setCurrentPassword] = useState("");
28
+ const [newPassword, setNewPassword] = useState("");
29
+ const [confirmPassword, setConfirmPassword] = useState("");
30
+ const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
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
+ const authClient = useAuthClient();
36
+
37
+ // Validate new password on change
38
+ useEffect(() => {
39
+ if (newPassword) {
40
+ const result = passwordSchema.safeParse(newPassword);
41
+ if (result.success) {
42
+ setValidationErrors([]);
43
+ } else {
44
+ setValidationErrors(result.error.issues.map((issue) => issue.message));
45
+ }
46
+ } else {
47
+ setValidationErrors([]);
48
+ }
49
+ }, [newPassword]);
50
+
51
+ const handleSubmit = async (e: React.FormEvent) => {
52
+ e.preventDefault();
53
+ setError(undefined);
54
+
55
+ // Frontend validation
56
+ if (newPassword !== confirmPassword) {
57
+ setError("New passwords do not match");
58
+ return;
59
+ }
60
+
61
+ const result = passwordSchema.safeParse(newPassword);
62
+ if (!result.success) {
63
+ setError(result.error.issues[0].message);
64
+ return;
65
+ }
66
+
67
+ if (!currentPassword) {
68
+ setError("Current password is required");
69
+ return;
70
+ }
71
+
72
+ setLoading(true);
73
+ try {
74
+ const response = await authClient.changePassword({
75
+ currentPassword,
76
+ newPassword,
77
+ revokeOtherSessions,
78
+ });
79
+
80
+ if (response.error) {
81
+ setError(response.error.message ?? "Failed to change password");
82
+ } else {
83
+ setSuccess(true);
84
+ }
85
+ } catch (error_) {
86
+ setError(
87
+ error_ instanceof Error ? error_.message : "Failed to change password"
88
+ );
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ };
93
+
94
+ // Success state
95
+ if (success) {
96
+ return (
97
+ <div className="min-h-[80vh] flex items-center justify-center">
98
+ <Card className="w-full max-w-md">
99
+ <CardHeader className="space-y-1 text-center">
100
+ <div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-2">
101
+ <CheckCircle className="h-6 w-6 text-primary" />
102
+ </div>
103
+ <CardTitle className="text-2xl font-bold">
104
+ Password Changed Successfully
105
+ </CardTitle>
106
+ <CardDescription>
107
+ Your password has been updated.
108
+ {revokeOtherSessions &&
109
+ " All other sessions have been signed out."}
110
+ </CardDescription>
111
+ </CardHeader>
112
+ <CardFooter>
113
+ <Button className="w-full" onClick={() => navigate("/")}>
114
+ Go to Dashboard
115
+ </Button>
116
+ </CardFooter>
117
+ </Card>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div className="min-h-[80vh] flex items-center justify-center">
124
+ <Card className="w-full max-w-md">
125
+ <CardHeader className="space-y-1">
126
+ <CardTitle className="text-2xl font-bold">Change Password</CardTitle>
127
+ <CardDescription>
128
+ Enter your current password and choose a new password.
129
+ </CardDescription>
130
+ </CardHeader>
131
+ <form onSubmit={handleSubmit}>
132
+ <CardContent className="space-y-4">
133
+ {error && (
134
+ <Alert variant="error">
135
+ <AlertIcon>
136
+ <AlertCircle className="h-4 w-4" />
137
+ </AlertIcon>
138
+ <AlertContent>
139
+ <AlertTitle>Error</AlertTitle>
140
+ <AlertDescription>{error}</AlertDescription>
141
+ </AlertContent>
142
+ </Alert>
143
+ )}
144
+
145
+ <div className="space-y-2">
146
+ <Label htmlFor="currentPassword">Current Password</Label>
147
+ <div className="relative">
148
+ <Key className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
149
+ <Input
150
+ id="currentPassword"
151
+ type="password"
152
+ placeholder="Enter current password"
153
+ value={currentPassword}
154
+ onChange={(e) => setCurrentPassword(e.target.value)}
155
+ className="pl-10"
156
+ required
157
+ autoFocus
158
+ />
159
+ </div>
160
+ </div>
161
+
162
+ <div className="space-y-2">
163
+ <Label htmlFor="newPassword">New Password</Label>
164
+ <div className="relative">
165
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
166
+ <Input
167
+ id="newPassword"
168
+ type="password"
169
+ placeholder="Enter new password"
170
+ value={newPassword}
171
+ onChange={(e) => setNewPassword(e.target.value)}
172
+ className="pl-10"
173
+ required
174
+ />
175
+ </div>
176
+ {validationErrors.length > 0 && (
177
+ <ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
178
+ {validationErrors.map((validationError, i) => (
179
+ <li key={i} className="text-destructive">
180
+ {validationError}
181
+ </li>
182
+ ))}
183
+ </ul>
184
+ )}
185
+ </div>
186
+
187
+ <div className="space-y-2">
188
+ <Label htmlFor="confirmPassword">Confirm New Password</Label>
189
+ <div className="relative">
190
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
191
+ <Input
192
+ id="confirmPassword"
193
+ type="password"
194
+ placeholder="Confirm new password"
195
+ value={confirmPassword}
196
+ onChange={(e) => setConfirmPassword(e.target.value)}
197
+ className="pl-10"
198
+ required
199
+ />
200
+ </div>
201
+ {confirmPassword && newPassword !== confirmPassword && (
202
+ <p className="text-sm text-destructive">
203
+ Passwords do not match
204
+ </p>
205
+ )}
206
+ </div>
207
+
208
+ <div className="text-xs text-muted-foreground">
209
+ Password must be at least 8 characters and contain:
210
+ <ul className="list-disc pl-5 mt-1">
211
+ <li>At least one uppercase letter</li>
212
+ <li>At least one lowercase letter</li>
213
+ <li>At least one number</li>
214
+ </ul>
215
+ </div>
216
+
217
+ <div className="flex items-center space-x-2">
218
+ <Checkbox
219
+ id="revokeOtherSessions"
220
+ checked={revokeOtherSessions}
221
+ onCheckedChange={(checked) =>
222
+ setRevokeOtherSessions(checked === true)
223
+ }
224
+ />
225
+ <Label
226
+ htmlFor="revokeOtherSessions"
227
+ className="text-sm font-normal"
228
+ >
229
+ Sign out of all other sessions
230
+ </Label>
231
+ </div>
232
+ </CardContent>
233
+ <CardFooter className="flex flex-col gap-4">
234
+ <Button
235
+ type="submit"
236
+ className="w-full"
237
+ disabled={
238
+ loading ||
239
+ validationErrors.length > 0 ||
240
+ newPassword !== confirmPassword ||
241
+ !currentPassword
242
+ }
243
+ >
244
+ {loading ? "Changing..." : "Change Password"}
245
+ </Button>
246
+ <button
247
+ type="button"
248
+ onClick={() => navigate(-1)}
249
+ className="text-sm text-primary hover:underline flex items-center justify-center gap-1"
250
+ >
251
+ <ArrowLeft className="h-4 w-4" />
252
+ Go Back
253
+ </button>
254
+ </CardFooter>
255
+ </form>
256
+ </Card>
257
+ </div>
258
+ );
259
+ };
@@ -14,12 +14,13 @@ import {
14
14
  CardContent,
15
15
  CardFooter,
16
16
  } from "@checkmate-monitor/ui";
17
- import { authClient } from "../lib/auth-client";
17
+ import { useAuthClient } from "../lib/auth-client";
18
18
 
19
19
  export const ForgotPasswordPage = () => {
20
20
  const [email, setEmail] = useState("");
21
21
  const [loading, setLoading] = useState(false);
22
22
  const [submitted, setSubmitted] = useState(false);
23
+ const authClient = useAuthClient();
23
24
 
24
25
  const handleSubmit = async (e: React.FormEvent) => {
25
26
  e.preventDefault();
@@ -27,7 +27,7 @@ import {
27
27
  } from "@checkmate-monitor/ui";
28
28
  import { useEnabledStrategies } from "../hooks/useEnabledStrategies";
29
29
  import { SocialProviderButton } from "./SocialProviderButton";
30
- import { authClient } from "../lib/auth-client";
30
+ import { useAuthClient } from "../lib/auth-client";
31
31
 
32
32
  export const RegisterPage = () => {
33
33
  const [name, setName] = useState("");
@@ -42,6 +42,7 @@ export const RegisterPage = () => {
42
42
  const { strategies, loading: strategiesLoading } = useEnabledStrategies();
43
43
  const [registrationAllowed, setRegistrationAllowed] = useState<boolean>(true);
44
44
  const [checkingRegistration, setCheckingRegistration] = useState(true);
45
+ const authClient = useAuthClient();
45
46
 
46
47
  // Validate password on change
47
48
  useEffect(() => {
@@ -19,7 +19,7 @@ import {
19
19
  AlertTitle,
20
20
  AlertDescription,
21
21
  } from "@checkmate-monitor/ui";
22
- import { authClient } from "../lib/auth-client";
22
+ import { useAuthClient } from "../lib/auth-client";
23
23
 
24
24
  export const ResetPasswordPage = () => {
25
25
  const [searchParams] = useSearchParams();
@@ -32,6 +32,7 @@ export const ResetPasswordPage = () => {
32
32
  const [error, setError] = useState<string>();
33
33
  const [success, setSuccess] = useState(false);
34
34
  const [validationErrors, setValidationErrors] = useState<string[]>([]);
35
+ const authClient = useAuthClient();
35
36
 
36
37
  // Validate password on change
37
38
  useEffect(() => {
@@ -1,9 +1,10 @@
1
1
  import { useEffect, useState } from "react";
2
- import { authClient } from "../lib/auth-client";
2
+ import { useAuthClient } from "../lib/auth-client";
3
3
  import { rpcApiRef, useApi } from "@checkmate-monitor/frontend-api";
4
4
  import { AuthApi } from "@checkmate-monitor/auth-common";
5
5
 
6
6
  export const usePermissions = () => {
7
+ const authClient = useAuthClient();
7
8
  const { data: session } = authClient.useSession();
8
9
  const [permissions, setPermissions] = useState<string[]>([]);
9
10
  const [loading, setLoading] = useState(true);
package/src/index.tsx CHANGED
@@ -16,14 +16,15 @@ import { RegisterPage } from "./components/RegisterPage";
16
16
  import { AuthErrorPage } from "./components/AuthErrorPage";
17
17
  import { ForgotPasswordPage } from "./components/ForgotPasswordPage";
18
18
  import { ResetPasswordPage } from "./components/ResetPasswordPage";
19
+ import { ChangePasswordPage } from "./components/ChangePasswordPage";
19
20
  import { authApiRef, AuthApi, AuthSession } from "./api";
20
- import { authClient } from "./lib/auth-client";
21
+ import { getAuthClientLazy } from "./lib/auth-client";
21
22
 
22
23
  import { usePermissions } from "./hooks/usePermissions";
23
24
 
24
25
  import { PermissionAction } from "@checkmate-monitor/common";
25
26
  import { useNavigate } from "react-router-dom";
26
- import { Settings2 } from "lucide-react";
27
+ import { Settings2, Key } from "lucide-react";
27
28
  import { DropdownMenuItem } from "@checkmate-monitor/ui";
28
29
  import { useApi } from "@checkmate-monitor/frontend-api";
29
30
  import { AuthSettingsPage } from "./components/AuthSettingsPage";
@@ -93,7 +94,7 @@ class AuthPermissionApi implements PermissionApi {
93
94
  */
94
95
  class BetterAuthApi implements AuthApi {
95
96
  async signIn(email: string, password: string) {
96
- const res = await authClient.signIn.email({ email, password });
97
+ const res = await getAuthClientLazy().signIn.email({ email, password });
97
98
  if (res.error) {
98
99
  const error = new Error(res.error.message || res.error.statusText);
99
100
  error.name = res.error.code || "AuthError";
@@ -118,9 +119,9 @@ class BetterAuthApi implements AuthApi {
118
119
  }
119
120
 
120
121
  async signInWithSocial(provider: string) {
121
- const frontendUrl =
122
- import.meta.env.VITE_FRONTEND_URL || "http://localhost:5173";
123
- await authClient.signIn.social({
122
+ // Use current origin as callback URL (works in dev and production)
123
+ const frontendUrl = globalThis.location?.origin || "http://localhost:5173";
124
+ await getAuthClientLazy().signIn.social({
124
125
  provider,
125
126
  callbackURL: frontendUrl,
126
127
  errorCallbackURL: `${frontendUrl}${resolveRoute(
@@ -130,7 +131,7 @@ class BetterAuthApi implements AuthApi {
130
131
  }
131
132
 
132
133
  async signOut() {
133
- await authClient.signOut({
134
+ await getAuthClientLazy().signOut({
134
135
  fetchOptions: {
135
136
  onSuccess: () => {
136
137
  // Redirect to frontend root after successful logout
@@ -141,7 +142,7 @@ class BetterAuthApi implements AuthApi {
141
142
  }
142
143
 
143
144
  async getSession() {
144
- const res = await authClient.getSession();
145
+ const res = await getAuthClientLazy().getSession();
145
146
  if (res.error) {
146
147
  const error = new Error(res.error.message || res.error.statusText);
147
148
  error.name = res.error.code || "AuthError";
@@ -156,7 +157,7 @@ class BetterAuthApi implements AuthApi {
156
157
  }
157
158
 
158
159
  useSession() {
159
- const { data, isPending, error } = authClient.useSession();
160
+ const { data, isPending, error } = getAuthClientLazy().useSession();
160
161
  return {
161
162
  data: data as AuthSession | undefined,
162
163
  isPending,
@@ -202,6 +203,10 @@ export const authPlugin = createFrontendPlugin({
202
203
  route: authRoutes.routes.resetPassword,
203
204
  element: <ResetPasswordPage />,
204
205
  },
206
+ {
207
+ route: authRoutes.routes.changePassword,
208
+ element: <ChangePasswordPage />,
209
+ },
205
210
  ],
206
211
  extensions: [
207
212
  {
@@ -232,6 +237,25 @@ export const authPlugin = createFrontendPlugin({
232
237
  );
233
238
  },
234
239
  },
240
+ {
241
+ id: "auth.user-menu.change-password",
242
+ slot: UserMenuItemsSlot,
243
+ component: () => {
244
+ const navigate = useNavigate();
245
+ // Only show for credential-authenticated users
246
+ // The changePassword API requires current password, so only credential users can use it
247
+ return (
248
+ <DropdownMenuItem
249
+ onClick={() =>
250
+ navigate(resolveRoute(authRoutes.routes.changePassword))
251
+ }
252
+ icon={<Key className="h-4 w-4" />}
253
+ >
254
+ Change Password
255
+ </DropdownMenuItem>
256
+ );
257
+ },
258
+ },
235
259
  {
236
260
  id: "auth.user-menu.logout",
237
261
  slot: UserMenuItemsBottomSlot,
@@ -1,6 +1,55 @@
1
+ import { useMemo } from "react";
1
2
  import { createAuthClient } from "better-auth/react";
3
+ import { useRuntimeConfig } from "@checkmate-monitor/frontend-api";
2
4
 
3
- export const authClient = createAuthClient({
4
- baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:3000",
5
- basePath: "/api/auth",
6
- });
5
+ // Cache for lazy-initialized client
6
+ let cachedClient: ReturnType<typeof createAuthClient> | undefined;
7
+ let configPromise: Promise<string> | undefined;
8
+
9
+ /**
10
+ * React hook to get the auth client with proper runtime config.
11
+ * Uses RuntimeConfigProvider to get the base URL.
12
+ */
13
+ export function useAuthClient() {
14
+ const { baseUrl } = useRuntimeConfig();
15
+
16
+ return useMemo(
17
+ () =>
18
+ createAuthClient({
19
+ baseURL: baseUrl,
20
+ basePath: "/api/auth",
21
+ }),
22
+ [baseUrl]
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Lazy-initialized auth client for class-based APIs.
28
+ * Fetches config from /api/config if not already cached.
29
+ * Use useAuthClient hook in React components instead.
30
+ */
31
+ export function getAuthClientLazy(): ReturnType<typeof createAuthClient> {
32
+ if (!cachedClient) {
33
+ // Create with default URL initially
34
+ cachedClient = createAuthClient({
35
+ baseURL: "http://localhost:3000",
36
+ basePath: "/api/auth",
37
+ });
38
+
39
+ // Fetch real config and update
40
+ if (!configPromise) {
41
+ configPromise = fetch("/api/config")
42
+ .then((res) => res.json())
43
+ .then((data: { baseUrl: string }) => data.baseUrl)
44
+ .catch(() => "http://localhost:3000");
45
+ }
46
+
47
+ configPromise.then((baseUrl) => {
48
+ cachedClient = createAuthClient({
49
+ baseURL: baseUrl,
50
+ basePath: "/api/auth",
51
+ });
52
+ });
53
+ }
54
+ return cachedClient;
55
+ }