@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/index.tsx
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ApiRef,
|
|
4
|
+
permissionApiRef,
|
|
5
|
+
PermissionApi,
|
|
6
|
+
createFrontendPlugin,
|
|
7
|
+
createSlotExtension,
|
|
8
|
+
NavbarRightSlot,
|
|
9
|
+
UserMenuItemsSlot,
|
|
10
|
+
UserMenuItemsBottomSlot,
|
|
11
|
+
} from "@checkstack/frontend-api";
|
|
12
|
+
import {
|
|
13
|
+
LoginPage,
|
|
14
|
+
LoginNavbarAction,
|
|
15
|
+
LogoutMenuItem,
|
|
16
|
+
} from "./components/LoginPage";
|
|
17
|
+
import { RegisterPage } from "./components/RegisterPage";
|
|
18
|
+
import { AuthErrorPage } from "./components/AuthErrorPage";
|
|
19
|
+
import { ForgotPasswordPage } from "./components/ForgotPasswordPage";
|
|
20
|
+
import { ResetPasswordPage } from "./components/ResetPasswordPage";
|
|
21
|
+
import { ChangePasswordPage } from "./components/ChangePasswordPage";
|
|
22
|
+
import { authApiRef, AuthApi, AuthSession } from "./api";
|
|
23
|
+
import { getAuthClientLazy } from "./lib/auth-client";
|
|
24
|
+
|
|
25
|
+
import { usePermissions } from "./hooks/usePermissions";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
PermissionAction,
|
|
29
|
+
qualifyPermissionId,
|
|
30
|
+
} from "@checkstack/common";
|
|
31
|
+
import { useNavigate } from "react-router-dom";
|
|
32
|
+
import { Settings2, Key } from "lucide-react";
|
|
33
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
34
|
+
import { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
35
|
+
import { AuthSettingsPage } from "./components/AuthSettingsPage";
|
|
36
|
+
import {
|
|
37
|
+
permissions as authPermissions,
|
|
38
|
+
authRoutes,
|
|
39
|
+
pluginMetadata,
|
|
40
|
+
} from "@checkstack/auth-common";
|
|
41
|
+
import { resolveRoute } from "@checkstack/common";
|
|
42
|
+
|
|
43
|
+
class AuthPermissionApi implements PermissionApi {
|
|
44
|
+
usePermission(permission: string): { loading: boolean; allowed: boolean } {
|
|
45
|
+
const { permissions, loading } = usePermissions();
|
|
46
|
+
|
|
47
|
+
if (loading) {
|
|
48
|
+
return { loading: true, allowed: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If no user, or user has no permissions, return false
|
|
52
|
+
if (!permissions || permissions.length === 0) {
|
|
53
|
+
return { loading: false, allowed: false };
|
|
54
|
+
}
|
|
55
|
+
const allowed =
|
|
56
|
+
permissions.includes("*") || permissions.includes(permission);
|
|
57
|
+
return { loading: false, allowed };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
useResourcePermission(
|
|
61
|
+
resource: string,
|
|
62
|
+
action: PermissionAction
|
|
63
|
+
): { loading: boolean; allowed: boolean } {
|
|
64
|
+
const { permissions, loading } = usePermissions();
|
|
65
|
+
|
|
66
|
+
if (loading) {
|
|
67
|
+
return { loading: true, allowed: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!permissions || permissions.length === 0) {
|
|
71
|
+
return { loading: false, allowed: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isWildcard = permissions.includes("*");
|
|
75
|
+
const hasResourceManage = permissions.includes(`${resource}.manage`);
|
|
76
|
+
const hasSpecificPermission = permissions.includes(`${resource}.${action}`);
|
|
77
|
+
|
|
78
|
+
// manage implies read
|
|
79
|
+
const isAllowed =
|
|
80
|
+
isWildcard ||
|
|
81
|
+
hasResourceManage ||
|
|
82
|
+
(action === "read" && hasResourceManage) ||
|
|
83
|
+
hasSpecificPermission;
|
|
84
|
+
|
|
85
|
+
return { loading: false, allowed: isAllowed };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
useManagePermission(resource: string): {
|
|
89
|
+
loading: boolean;
|
|
90
|
+
allowed: boolean;
|
|
91
|
+
} {
|
|
92
|
+
return this.useResourcePermission(resource, "manage");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* BetterAuthApi wraps only better-auth client methods.
|
|
98
|
+
* For RPC calls, use rpcApiRef.forPlugin<AuthClient>("auth") directly.
|
|
99
|
+
*/
|
|
100
|
+
class BetterAuthApi implements AuthApi {
|
|
101
|
+
async signIn(email: string, password: string) {
|
|
102
|
+
const res = await getAuthClientLazy().signIn.email({ email, password });
|
|
103
|
+
if (res.error) {
|
|
104
|
+
const error = new Error(res.error.message || res.error.statusText);
|
|
105
|
+
error.name = res.error.code || "AuthError";
|
|
106
|
+
return { data: undefined, error };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = res.data as typeof res.data & {
|
|
110
|
+
session?: AuthSession["session"];
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
data: {
|
|
114
|
+
session: data.session || {
|
|
115
|
+
token: data.token,
|
|
116
|
+
id: "session-id",
|
|
117
|
+
userId: data.user.id,
|
|
118
|
+
expiresAt: new Date(),
|
|
119
|
+
},
|
|
120
|
+
user: data.user,
|
|
121
|
+
} as AuthSession,
|
|
122
|
+
error: undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async signInWithSocial(provider: string) {
|
|
127
|
+
// Use current origin as callback URL (works in dev and production)
|
|
128
|
+
const frontendUrl = globalThis.location?.origin || "http://localhost:5173";
|
|
129
|
+
await getAuthClientLazy().signIn.social({
|
|
130
|
+
provider,
|
|
131
|
+
callbackURL: frontendUrl,
|
|
132
|
+
errorCallbackURL: `${frontendUrl}${resolveRoute(
|
|
133
|
+
authRoutes.routes.error
|
|
134
|
+
)}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async signOut() {
|
|
139
|
+
await getAuthClientLazy().signOut({
|
|
140
|
+
fetchOptions: {
|
|
141
|
+
onSuccess: () => {
|
|
142
|
+
// Redirect to frontend root after successful logout
|
|
143
|
+
globalThis.location.href = "/";
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getSession() {
|
|
150
|
+
const res = await getAuthClientLazy().getSession();
|
|
151
|
+
if (res.error) {
|
|
152
|
+
const error = new Error(res.error.message || res.error.statusText);
|
|
153
|
+
error.name = res.error.code || "AuthError";
|
|
154
|
+
return { data: undefined, error };
|
|
155
|
+
}
|
|
156
|
+
if (!res.data) return { data: undefined, error: undefined };
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
data: res.data as AuthSession,
|
|
160
|
+
error: undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
useSession() {
|
|
165
|
+
const { data, isPending, error } = getAuthClientLazy().useSession();
|
|
166
|
+
return {
|
|
167
|
+
data: data as AuthSession | undefined,
|
|
168
|
+
isPending,
|
|
169
|
+
error: error as Error | undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const authPlugin = createFrontendPlugin({
|
|
175
|
+
metadata: pluginMetadata,
|
|
176
|
+
apis: [
|
|
177
|
+
{
|
|
178
|
+
ref: authApiRef as ApiRef<unknown>,
|
|
179
|
+
factory: () => new BetterAuthApi(),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
ref: permissionApiRef as ApiRef<unknown>,
|
|
183
|
+
factory: () => new AuthPermissionApi(),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
routes: [
|
|
187
|
+
{
|
|
188
|
+
route: authRoutes.routes.login,
|
|
189
|
+
element: <LoginPage />,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
route: authRoutes.routes.register,
|
|
193
|
+
element: <RegisterPage />,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
route: authRoutes.routes.error,
|
|
197
|
+
element: <AuthErrorPage />,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
route: authRoutes.routes.settings,
|
|
201
|
+
element: <AuthSettingsPage />,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
route: authRoutes.routes.forgotPassword,
|
|
205
|
+
element: <ForgotPasswordPage />,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
route: authRoutes.routes.resetPassword,
|
|
209
|
+
element: <ResetPasswordPage />,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
route: authRoutes.routes.changePassword,
|
|
213
|
+
element: <ChangePasswordPage />,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
extensions: [
|
|
217
|
+
{
|
|
218
|
+
id: "auth.navbar.action",
|
|
219
|
+
slot: NavbarRightSlot,
|
|
220
|
+
component: LoginNavbarAction,
|
|
221
|
+
},
|
|
222
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
223
|
+
id: "auth.user-menu.settings",
|
|
224
|
+
component: ({ permissions: userPerms }: UserMenuItemsContext) => {
|
|
225
|
+
const navigate = useNavigate();
|
|
226
|
+
const qualifiedId = qualifyPermissionId(
|
|
227
|
+
pluginMetadata,
|
|
228
|
+
authPermissions.strategiesManage
|
|
229
|
+
);
|
|
230
|
+
const canManage =
|
|
231
|
+
userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
232
|
+
|
|
233
|
+
if (!canManage) return <React.Fragment />;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<DropdownMenuItem
|
|
237
|
+
onClick={() => navigate(resolveRoute(authRoutes.routes.settings))}
|
|
238
|
+
icon={<Settings2 className="h-4 w-4" />}
|
|
239
|
+
>
|
|
240
|
+
Auth Settings
|
|
241
|
+
</DropdownMenuItem>
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
246
|
+
id: "auth.user-menu.change-password",
|
|
247
|
+
component: ({ hasCredentialAccount }: UserMenuItemsContext) => {
|
|
248
|
+
const navigate = useNavigate();
|
|
249
|
+
|
|
250
|
+
// Only show for credential-authenticated users
|
|
251
|
+
// The changePassword API requires current password, so only credential users can use it
|
|
252
|
+
if (!hasCredentialAccount) return <React.Fragment />;
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<DropdownMenuItem
|
|
256
|
+
onClick={() =>
|
|
257
|
+
navigate(resolveRoute(authRoutes.routes.changePassword))
|
|
258
|
+
}
|
|
259
|
+
icon={<Key className="h-4 w-4" />}
|
|
260
|
+
>
|
|
261
|
+
Change Password
|
|
262
|
+
</DropdownMenuItem>
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
createSlotExtension(UserMenuItemsBottomSlot, {
|
|
267
|
+
id: "auth.user-menu.logout",
|
|
268
|
+
component: LogoutMenuItem,
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { createAuthClient } from "better-auth/react";
|
|
3
|
+
import { useRuntimeConfig } from "@checkstack/frontend-api";
|
|
4
|
+
|
|
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
|
+
}
|