@lukoweb/apitogo 0.2.31 → 0.2.34

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.
Files changed (41) hide show
  1. package/dist/cli/cli.js +436 -153
  2. package/dist/declarations/app/entry.server.d.ts +2 -2
  3. package/dist/declarations/app/main.d.ts +3 -1
  4. package/dist/declarations/cli/platform/client/platform-api.d.ts +0 -4
  5. package/dist/declarations/cli/platform/helpers/deploy-artifact-hash.d.ts +1 -1
  6. package/dist/declarations/cli/platform/helpers/package-files.d.ts +1 -1
  7. package/dist/declarations/cli/platform/helpers/zip.d.ts +1 -1
  8. package/dist/declarations/cli/platform/tools/infrastructure.d.ts +0 -4
  9. package/dist/declarations/config/effective-redirects.d.ts +6 -0
  10. package/dist/declarations/config/header-navigation-guard.d.ts +2 -0
  11. package/dist/declarations/config/resolve-modules.d.ts +1 -4
  12. package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +1 -2
  13. package/docs/cli/commands.md +19 -0
  14. package/docs/configuration/gateway.md +52 -0
  15. package/docs/guides/customer-account.md +11 -1
  16. package/docs/mcp/tools.md +101 -102
  17. package/package.json +2 -1
  18. package/src/app/demo.tsx +78 -77
  19. package/src/app/entry.client.tsx +115 -107
  20. package/src/app/entry.server.tsx +164 -160
  21. package/src/app/main.tsx +175 -167
  22. package/src/app/standalone.tsx +61 -60
  23. package/src/config/effective-redirects.ts +31 -0
  24. package/src/config/header-navigation-guard.ts +13 -0
  25. package/src/config/resolve-modules.ts +331 -354
  26. package/src/config/v2/home-redirect.ts +104 -98
  27. package/src/config/v2/index.ts +127 -126
  28. package/src/config/v2/ledger.ts +242 -235
  29. package/src/config/v2/resolve.ts +12 -4
  30. package/src/config/v2/rules.ts +716 -714
  31. package/src/config/v2/services.ts +799 -663
  32. package/src/config/v2/workspace.ts +17 -1
  33. package/src/config/validators/HeaderNavigationSchema.ts +42 -44
  34. package/src/lib/assets/powered-by-icon.png +0 -0
  35. package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +667 -602
  36. package/src/lib/authentication/providers/dev-portal.tsx +476 -448
  37. package/src/lib/components/HeaderNavigation.tsx +176 -176
  38. package/src/lib/components/MobileTopNavigation.tsx +283 -283
  39. package/src/lib/components/navigation/PoweredByZudoku.tsx +42 -39
  40. package/src/vite/plugin-offroot-resolve.ts +68 -67
  41. package/src/vite/prerender/prerender.ts +242 -242
@@ -1,602 +1,667 @@
1
- import { Spinner } from "@lukoweb/apitogo/components";
2
- import { Button } from "@lukoweb/apitogo/ui/Button.js";
3
- import {
4
- Card,
5
- CardContent,
6
- CardDescription,
7
- CardHeader,
8
- CardTitle,
9
- } from "@lukoweb/apitogo/ui/Card.js";
10
- import { Input } from "@lukoweb/apitogo/ui/Input.js";
11
- import { Label } from "@lukoweb/apitogo/ui/Label.js";
12
- import { useCallback, useEffect, useState, type ReactNode } from "react";
13
- import { Link, useSearchParams } from "react-router";
14
- import type { DevPortalAuthConfigResponse } from "./dev-portal-constants.js";
15
- import {
16
- DEMO_EMAIL,
17
- DEMO_PASSWORD,
18
- activateDemoSession,
19
- isDemoCredential,
20
- } from "./dev-portal-demo.js";
21
- import {
22
- buildDevPortalOidcChallengeUrl,
23
- fetchDevPortalAuthConfig,
24
- isNativeAuthEnabled,
25
- isPlaceholderDevPortalApiUrl,
26
- postDevPortalAuth,
27
- resolveDevPortalApiBaseUrl,
28
- toAbsoluteReturnUrl,
29
- } from "./dev-portal-utils.js";
30
-
31
- type AuthPageShellProps = {
32
- title: string;
33
- description?: string;
34
- badge?: ReactNode;
35
- children: ReactNode;
36
- };
37
-
38
- function AuthPageShell({
39
- title,
40
- description,
41
- badge,
42
- children,
43
- }: AuthPageShellProps) {
44
- return (
45
- <div className="flex items-center justify-center bg-muted px-5 py-10">
46
- <Card className="w-full max-w-md gap-0 bg-card py-8">
47
- <CardHeader className="gap-0 px-8 pb-0">
48
- {badge ? <div className="mb-3.5">{badge}</div> : null}
49
- <CardTitle className="text-2xl font-bold tracking-tight">
50
- {title}
51
- </CardTitle>
52
- {description ? (
53
- <CardDescription className="mt-1 mb-[22px] text-sm">
54
- {description}
55
- </CardDescription>
56
- ) : null}
57
- </CardHeader>
58
- <CardContent className="space-y-4 px-8">{children}</CardContent>
59
- </Card>
60
- </div>
61
- );
62
- }
63
-
64
- function useReturnUrl(defaultReturnUrl = "/"): string {
65
- const [search] = useSearchParams();
66
- return search.get("redirect") ?? search.get("returnUrl") ?? defaultReturnUrl;
67
- }
68
-
69
- function useApiBaseUrl(apiBaseUrl?: string): string | undefined {
70
- return resolveDevPortalApiBaseUrl({ apiBaseUrl });
71
- }
72
-
73
- function AuthError({ message }: { message: string | null }) {
74
- if (!message) return null;
75
- return <p className="text-sm text-destructive">{message}</p>;
76
- }
77
-
78
- function OidcProviderButtons({
79
- apiBaseUrl,
80
- config,
81
- returnUrl,
82
- }: {
83
- apiBaseUrl: string;
84
- config: DevPortalAuthConfigResponse;
85
- returnUrl: string;
86
- }) {
87
- if (!config.providers.length) {
88
- return null;
89
- }
90
-
91
- return (
92
- <div className="space-y-2">
93
- {isNativeAuthEnabled(config) ? (
94
- <p className="text-sm text-muted-foreground text-center">
95
- or continue with
96
- </p>
97
- ) : null}
98
- {config.providers.map((provider) => (
99
- <Button
100
- key={provider.id}
101
- type="button"
102
- variant="outline"
103
- className="w-full"
104
- onClick={() => {
105
- window.location.assign(
106
- buildDevPortalOidcChallengeUrl(
107
- apiBaseUrl,
108
- provider.id,
109
- returnUrl,
110
- ),
111
- );
112
- }}
113
- >
114
- {provider.displayName || provider.id}
115
- </Button>
116
- ))}
117
- </div>
118
- );
119
- }
120
-
121
- function DemoLoginHint() {
122
- return (
123
- <div className="rounded-lg border border-dashed bg-muted/30 p-3 text-sm text-muted-foreground">
124
- <p className="font-medium text-foreground">Local demo sign-in</p>
125
- <p className="mt-1">
126
- Use <code className="rounded bg-muted px-1 py-0.5">{DEMO_EMAIL}</code>{" "}
127
- with password{" "}
128
- <code className="rounded bg-muted px-1 py-0.5">{DEMO_PASSWORD}</code> to
129
- explore the account area without a dev-portal backend.
130
- </p>
131
- </div>
132
- );
133
- }
134
-
135
- export function DevPortalLoginPage({
136
- apiBaseUrl,
137
- demoMode = false,
138
- defaultReturnUrl = "/",
139
- }: {
140
- apiBaseUrl?: string;
141
- demoMode?: boolean;
142
- defaultReturnUrl?: string;
143
- }) {
144
- const baseUrl = useApiBaseUrl(apiBaseUrl);
145
- const returnUrl = useReturnUrl(defaultReturnUrl);
146
- const [config, setConfig] = useState<DevPortalAuthConfigResponse | null>(
147
- null,
148
- );
149
- const [configLoading, setConfigLoading] = useState(
150
- Boolean(baseUrl) && !isPlaceholderDevPortalApiUrl(baseUrl),
151
- );
152
- const [configError, setConfigError] = useState<string | null>(null);
153
- const [useDemoFallback, setUseDemoFallback] = useState(
154
- demoMode && (!baseUrl || isPlaceholderDevPortalApiUrl(baseUrl)),
155
- );
156
- const demoAuthAvailable = demoMode && useDemoFallback;
157
- const [email, setEmail] = useState("");
158
- const [password, setPassword] = useState("");
159
- const [error, setError] = useState<string | null>(null);
160
- const [pending, setPending] = useState(false);
161
-
162
- useEffect(() => {
163
- if (!demoMode) {
164
- setUseDemoFallback(false);
165
- } else if (!baseUrl || isPlaceholderDevPortalApiUrl(baseUrl)) {
166
- setUseDemoFallback(true);
167
- }
168
- }, [baseUrl, demoMode]);
169
-
170
- useEffect(() => {
171
- if (demoAuthAvailable) {
172
- setConfig({ native: { enabled: true }, email: null, providers: [] });
173
- setConfigLoading(false);
174
- return;
175
- }
176
-
177
- if (!baseUrl) return;
178
- setConfigLoading(true);
179
- setConfigError(null);
180
- void fetchDevPortalAuthConfig(baseUrl)
181
- .then((loaded) => {
182
- setConfig(loaded);
183
- setConfigLoading(false);
184
- })
185
- .catch(() => {
186
- if (demoMode) {
187
- setUseDemoFallback(true);
188
- setConfig({ native: { enabled: true }, email: null, providers: [] });
189
- setConfigLoading(false);
190
- setConfigError(null);
191
- return;
192
- }
193
-
194
- setConfig(null);
195
- setConfigLoading(false);
196
- setConfigError(
197
- `Could not reach the dev portal API at ${baseUrl}. If you use a local HTTPS certificate, open ${baseUrl}/api/v1/auth/status in your browser first to trust it.`,
198
- );
199
- });
200
- }, [baseUrl, demoAuthAvailable, demoMode]);
201
-
202
- const onSubmit = useCallback(
203
- async (event: React.FormEvent) => {
204
- event.preventDefault();
205
- setPending(true);
206
- setError(null);
207
-
208
- if (demoAuthAvailable) {
209
- if (!isDemoCredential(email, password)) {
210
- setError("Invalid demo credentials.");
211
- setPending(false);
212
- return;
213
- }
214
-
215
- activateDemoSession(baseUrl);
216
- window.location.assign(toAbsoluteReturnUrl(returnUrl));
217
- return;
218
- }
219
-
220
- if (!baseUrl) return;
221
- try {
222
- await postDevPortalAuth(baseUrl, "/api/v1/auth/login", {
223
- email,
224
- password,
225
- });
226
- window.location.assign(toAbsoluteReturnUrl(returnUrl));
227
- } catch (err) {
228
- setError(err instanceof Error ? err.message : "Login failed.");
229
- } finally {
230
- setPending(false);
231
- }
232
- },
233
- [baseUrl, demoAuthAvailable, email, password, returnUrl],
234
- );
235
-
236
- if (!baseUrl && !demoAuthAvailable) {
237
- return (
238
- <AuthPageShell
239
- title="Sign in"
240
- description="Dev portal API is not configured."
241
- >
242
- <p className="text-sm text-muted-foreground">
243
- Set authentication.apiBaseUrl to continue.
244
- </p>
245
- </AuthPageShell>
246
- );
247
- }
248
-
249
- return (
250
- <AuthPageShell title="Sign in" description="Access your developer account.">
251
- {demoAuthAvailable ? <DemoLoginHint /> : null}
252
- {isNativeAuthEnabled(config) || demoAuthAvailable ? (
253
- <form className="space-y-4" onSubmit={onSubmit}>
254
- <div className="space-y-2">
255
- <Label htmlFor="email">Email</Label>
256
- <Input
257
- id="email"
258
- type="email"
259
- autoComplete="email"
260
- value={email}
261
- onChange={(event) => setEmail(event.target.value)}
262
- required
263
- />
264
- </div>
265
- <div className="space-y-2">
266
- <Label htmlFor="password">Password</Label>
267
- <Input
268
- id="password"
269
- type="password"
270
- autoComplete="current-password"
271
- value={password}
272
- onChange={(event) => setPassword(event.target.value)}
273
- required
274
- />
275
- </div>
276
- <AuthError message={error} />
277
- <Button type="submit" className="w-full" disabled={pending}>
278
- {pending ? <Spinner /> : "Sign in"}
279
- </Button>
280
- <div className="flex justify-between text-sm">
281
- <Link
282
- className="text-primary hover:underline"
283
- to="/forgot-password"
284
- >
285
- Forgot password?
286
- </Link>
287
- <Link
288
- className="text-primary hover:underline"
289
- to={`/register?redirect=${encodeURIComponent(returnUrl)}`}
290
- >
291
- Create account
292
- </Link>
293
- </div>
294
- </form>
295
- ) : null}
296
- {configError && !demoAuthAvailable ? (
297
- <AuthError message={configError} />
298
- ) : null}
299
- {configLoading ? (
300
- <div className="flex justify-center">
301
- <Spinner />
302
- </div>
303
- ) : config && !demoAuthAvailable && baseUrl ? (
304
- <>
305
- <OidcProviderButtons
306
- apiBaseUrl={baseUrl}
307
- config={config}
308
- returnUrl={returnUrl}
309
- />
310
- {!isNativeAuthEnabled(config) && !config.providers.length ? (
311
- <p className="text-sm text-muted-foreground text-center">
312
- No sign-in methods are configured.
313
- </p>
314
- ) : null}
315
- </>
316
- ) : null}
317
- </AuthPageShell>
318
- );
319
- }
320
-
321
- export function DevPortalRegisterPage({
322
- apiBaseUrl,
323
- defaultReturnUrl = "/",
324
- }: {
325
- apiBaseUrl?: string;
326
- defaultReturnUrl?: string;
327
- }) {
328
- const baseUrl = useApiBaseUrl(apiBaseUrl);
329
- const returnUrl = useReturnUrl(defaultReturnUrl);
330
- const [email, setEmail] = useState("");
331
- const [password, setPassword] = useState("");
332
- const [displayName, setDisplayName] = useState("");
333
- const [message, setMessage] = useState<string | null>(null);
334
- const [error, setError] = useState<string | null>(null);
335
- const [pending, setPending] = useState(false);
336
-
337
- const onSubmit = useCallback(
338
- async (event: React.FormEvent) => {
339
- event.preventDefault();
340
- if (!baseUrl) return;
341
- setPending(true);
342
- setError(null);
343
- setMessage(null);
344
- try {
345
- const response = await postDevPortalAuth<{ message: string }>(
346
- baseUrl,
347
- "/api/v1/auth/register",
348
- { email, password, displayName: displayName || null },
349
- );
350
- setMessage(response.message);
351
- } catch (err) {
352
- setError(err instanceof Error ? err.message : "Registration failed.");
353
- } finally {
354
- setPending(false);
355
- }
356
- },
357
- [baseUrl, displayName, email, password],
358
- );
359
-
360
- if (!baseUrl) {
361
- return (
362
- <AuthPageShell
363
- title="Create your account"
364
- description="Dev portal API is not configured."
365
- >
366
- <p className="text-sm text-muted-foreground">
367
- Set authentication.apiBaseUrl to continue.
368
- </p>
369
- </AuthPageShell>
370
- );
371
- }
372
-
373
- return (
374
- <AuthPageShell title="Create your account">
375
- <form className="flex flex-col gap-4" onSubmit={onSubmit}>
376
- <div className="space-y-2">
377
- <Label htmlFor="displayName">Full name</Label>
378
- <Input
379
- id="displayName"
380
- placeholder="Priya Shah"
381
- value={displayName}
382
- onChange={(event) => setDisplayName(event.target.value)}
383
- className="h-10"
384
- />
385
- </div>
386
- <div className="space-y-2">
387
- <Label htmlFor="email">Email</Label>
388
- <Input
389
- id="email"
390
- type="email"
391
- autoComplete="email"
392
- placeholder="you@company.com"
393
- value={email}
394
- onChange={(event) => setEmail(event.target.value)}
395
- required
396
- className="h-10"
397
- />
398
- </div>
399
- <div className="space-y-2">
400
- <Label htmlFor="password">Password</Label>
401
- <Input
402
- id="password"
403
- type="password"
404
- autoComplete="new-password"
405
- placeholder="••••••••"
406
- value={password}
407
- onChange={(event) => setPassword(event.target.value)}
408
- required
409
- className="h-10"
410
- />
411
- <p className="text-xs text-muted-foreground">At least 8 characters</p>
412
- </div>
413
- <AuthError message={error} />
414
- {message ? (
415
- <p className="text-sm text-muted-foreground">{message}</p>
416
- ) : null}
417
- <Button
418
- type="submit"
419
- size="lg"
420
- className="h-12 w-full text-base"
421
- disabled={pending}
422
- >
423
- {pending ? <Spinner /> : "Create account"}
424
- </Button>
425
- <p className="text-xs leading-relaxed text-muted-foreground">
426
- By creating an account you agree to our Terms and Privacy Policy.
427
- </p>
428
- <p className="text-sm text-muted-foreground">
429
- Already have an account?{" "}
430
- <Link
431
- className="font-semibold text-primary no-underline hover:underline"
432
- to={`/login?redirect=${encodeURIComponent(returnUrl)}`}
433
- >
434
- Log in
435
- </Link>
436
- </p>
437
- </form>
438
- </AuthPageShell>
439
- );
440
- }
441
-
442
- export function DevPortalVerifyEmailPage({
443
- apiBaseUrl,
444
- }: {
445
- apiBaseUrl?: string;
446
- }) {
447
- const baseUrl = useApiBaseUrl(apiBaseUrl);
448
- const [search] = useSearchParams();
449
- const [message, setMessage] = useState<string | null>(null);
450
- const [error, setError] = useState<string | null>(null);
451
-
452
- useEffect(() => {
453
- const token = search.get("token");
454
- if (!baseUrl || !token) return;
455
- void postDevPortalAuth<{ message: string }>(
456
- baseUrl,
457
- "/api/v1/auth/verify-email",
458
- {
459
- token,
460
- },
461
- )
462
- .then((response) => setMessage(response.message))
463
- .catch((err) =>
464
- setError(err instanceof Error ? err.message : "Verification failed."),
465
- );
466
- }, [baseUrl, search]);
467
-
468
- return (
469
- <AuthPageShell title="Verify email">
470
- <AuthError message={error} />
471
- {message ? (
472
- <p className="text-sm text-muted-foreground">{message}</p>
473
- ) : (
474
- <Spinner />
475
- )}
476
- <Link className="text-sm text-primary hover:underline" to="/login">
477
- Sign in
478
- </Link>
479
- </AuthPageShell>
480
- );
481
- }
482
-
483
- export function DevPortalForgotPasswordPage({
484
- apiBaseUrl,
485
- }: {
486
- apiBaseUrl?: string;
487
- }) {
488
- const baseUrl = useApiBaseUrl(apiBaseUrl);
489
- const [email, setEmail] = useState("");
490
- const [message, setMessage] = useState<string | null>(null);
491
- const [error, setError] = useState<string | null>(null);
492
- const [pending, setPending] = useState(false);
493
-
494
- const onSubmit = useCallback(
495
- async (event: React.FormEvent) => {
496
- event.preventDefault();
497
- if (!baseUrl) return;
498
- setPending(true);
499
- setError(null);
500
- try {
501
- const response = await postDevPortalAuth<{ message: string }>(
502
- baseUrl,
503
- "/api/v1/auth/forgot-password",
504
- { email },
505
- );
506
- setMessage(response.message);
507
- } catch (err) {
508
- setError(err instanceof Error ? err.message : "Request failed.");
509
- } finally {
510
- setPending(false);
511
- }
512
- },
513
- [baseUrl, email],
514
- );
515
-
516
- return (
517
- <AuthPageShell
518
- title="Reset password"
519
- description="We'll email you a reset link."
520
- >
521
- <form className="space-y-4" onSubmit={onSubmit}>
522
- <div className="space-y-2">
523
- <Label htmlFor="email">Email</Label>
524
- <Input
525
- id="email"
526
- type="email"
527
- value={email}
528
- onChange={(event) => setEmail(event.target.value)}
529
- required
530
- />
531
- </div>
532
- <AuthError message={error} />
533
- {message ? (
534
- <p className="text-sm text-muted-foreground">{message}</p>
535
- ) : null}
536
- <Button type="submit" className="w-full" disabled={pending}>
537
- {pending ? <Spinner /> : "Send reset link"}
538
- </Button>
539
- </form>
540
- </AuthPageShell>
541
- );
542
- }
543
-
544
- export function DevPortalResetPasswordPage({
545
- apiBaseUrl,
546
- }: {
547
- apiBaseUrl?: string;
548
- }) {
549
- const baseUrl = useApiBaseUrl(apiBaseUrl);
550
- const [search] = useSearchParams();
551
- const [password, setPassword] = useState("");
552
- const [message, setMessage] = useState<string | null>(null);
553
- const [error, setError] = useState<string | null>(null);
554
- const [pending, setPending] = useState(false);
555
-
556
- const onSubmit = useCallback(
557
- async (event: React.FormEvent) => {
558
- event.preventDefault();
559
- const token = search.get("token");
560
- if (!baseUrl || !token) return;
561
- setPending(true);
562
- setError(null);
563
- try {
564
- const response = await postDevPortalAuth<{ message: string }>(
565
- baseUrl,
566
- "/api/v1/auth/reset-password",
567
- { token, newPassword: password },
568
- );
569
- setMessage(response.message);
570
- } catch (err) {
571
- setError(err instanceof Error ? err.message : "Reset failed.");
572
- } finally {
573
- setPending(false);
574
- }
575
- },
576
- [baseUrl, password, search],
577
- );
578
-
579
- return (
580
- <AuthPageShell title="Choose a new password">
581
- <form className="space-y-4" onSubmit={onSubmit}>
582
- <div className="space-y-2">
583
- <Label htmlFor="password">New password</Label>
584
- <Input
585
- id="password"
586
- type="password"
587
- value={password}
588
- onChange={(event) => setPassword(event.target.value)}
589
- required
590
- />
591
- </div>
592
- <AuthError message={error} />
593
- {message ? (
594
- <p className="text-sm text-muted-foreground">{message}</p>
595
- ) : null}
596
- <Button type="submit" className="w-full" disabled={pending}>
597
- {pending ? <Spinner /> : "Update password"}
598
- </Button>
599
- </form>
600
- </AuthPageShell>
601
- );
602
- }
1
+ import { Spinner } from "@lukoweb/apitogo/components";
2
+ import { Button } from "@lukoweb/apitogo/ui/Button.js";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@lukoweb/apitogo/ui/Card.js";
10
+ import { Input } from "@lukoweb/apitogo/ui/Input.js";
11
+ import { Label } from "@lukoweb/apitogo/ui/Label.js";
12
+ import { useCallback, useEffect, useState, type ReactNode } from "react";
13
+ import { Link, useSearchParams } from "react-router";
14
+ import type { DevPortalAuthConfigResponse } from "./dev-portal-constants.js";
15
+ import {
16
+ DEMO_EMAIL,
17
+ DEMO_PASSWORD,
18
+ activateDemoSession,
19
+ isDemoCredential,
20
+ } from "./dev-portal-demo.js";
21
+ import {
22
+ buildDevPortalOidcChallengeUrl,
23
+ fetchDevPortalAuthConfig,
24
+ isNativeAuthEnabled,
25
+ isPlaceholderDevPortalApiUrl,
26
+ postDevPortalAuth,
27
+ resolveDevPortalApiBaseUrl,
28
+ toAbsoluteReturnUrl,
29
+ } from "./dev-portal-utils.js";
30
+
31
+ type AuthPageShellProps = {
32
+ title: string;
33
+ description?: string;
34
+ badge?: ReactNode;
35
+ children: ReactNode;
36
+ };
37
+
38
+ function AuthPageShell({
39
+ title,
40
+ description,
41
+ badge,
42
+ children,
43
+ }: AuthPageShellProps) {
44
+ return (
45
+ <div className="flex items-center justify-center bg-muted px-5 py-10">
46
+ <Card className="w-full max-w-md gap-0 bg-card py-8">
47
+ <CardHeader className="gap-0 px-8 pb-0">
48
+ {badge ? <div className="mb-3.5">{badge}</div> : null}
49
+ <CardTitle className="text-2xl font-bold tracking-tight">
50
+ {title}
51
+ </CardTitle>
52
+ {description ? (
53
+ <CardDescription className="mt-1 mb-[22px] text-sm">
54
+ {description}
55
+ </CardDescription>
56
+ ) : null}
57
+ </CardHeader>
58
+ <CardContent className="space-y-4 px-8">{children}</CardContent>
59
+ </Card>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ function useReturnUrl(defaultReturnUrl = "/"): string {
65
+ const [search] = useSearchParams();
66
+ return search.get("redirect") ?? search.get("returnUrl") ?? defaultReturnUrl;
67
+ }
68
+
69
+ function useApiBaseUrl(apiBaseUrl?: string): string | undefined {
70
+ return resolveDevPortalApiBaseUrl({ apiBaseUrl });
71
+ }
72
+
73
+ function AuthError({ message }: { message: string | null }) {
74
+ if (!message) return null;
75
+ return <p className="text-sm text-destructive">{message}</p>;
76
+ }
77
+
78
+ function OidcProviderButtons({
79
+ apiBaseUrl,
80
+ config,
81
+ returnUrl,
82
+ label,
83
+ }: {
84
+ apiBaseUrl: string;
85
+ config: DevPortalAuthConfigResponse;
86
+ returnUrl: string;
87
+ /** Replaces the provider's own name on the button, for example on the sign-up page. */
88
+ label?: string;
89
+ }) {
90
+ if (!config.providers.length) {
91
+ return null;
92
+ }
93
+
94
+ return (
95
+ <div className="space-y-2">
96
+ {isNativeAuthEnabled(config) ? (
97
+ <p className="text-sm text-muted-foreground text-center">
98
+ or continue with
99
+ </p>
100
+ ) : null}
101
+ {config.providers.map((provider) => (
102
+ <Button
103
+ key={provider.id}
104
+ type="button"
105
+ variant="outline"
106
+ className="w-full"
107
+ onClick={() => {
108
+ window.location.assign(
109
+ buildDevPortalOidcChallengeUrl(
110
+ apiBaseUrl,
111
+ provider.id,
112
+ returnUrl,
113
+ ),
114
+ );
115
+ }}
116
+ >
117
+ {label ?? (provider.displayName || provider.id)}
118
+ </Button>
119
+ ))}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function DemoLoginHint() {
125
+ return (
126
+ <div className="rounded-lg border border-dashed bg-muted/30 p-3 text-sm text-muted-foreground">
127
+ <p className="font-medium text-foreground">Local demo sign-in</p>
128
+ <p className="mt-1">
129
+ Use <code className="rounded bg-muted px-1 py-0.5">{DEMO_EMAIL}</code>{" "}
130
+ with password{" "}
131
+ <code className="rounded bg-muted px-1 py-0.5">{DEMO_PASSWORD}</code> to
132
+ explore the account area without a dev-portal backend.
133
+ </p>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ export function DevPortalLoginPage({
139
+ apiBaseUrl,
140
+ demoMode = false,
141
+ defaultReturnUrl = "/",
142
+ }: {
143
+ apiBaseUrl?: string;
144
+ demoMode?: boolean;
145
+ defaultReturnUrl?: string;
146
+ }) {
147
+ const baseUrl = useApiBaseUrl(apiBaseUrl);
148
+ const returnUrl = useReturnUrl(defaultReturnUrl);
149
+ const [config, setConfig] = useState<DevPortalAuthConfigResponse | null>(
150
+ null,
151
+ );
152
+ const [configLoading, setConfigLoading] = useState(
153
+ Boolean(baseUrl) && !isPlaceholderDevPortalApiUrl(baseUrl),
154
+ );
155
+ const [configError, setConfigError] = useState<string | null>(null);
156
+ const [useDemoFallback, setUseDemoFallback] = useState(
157
+ demoMode && (!baseUrl || isPlaceholderDevPortalApiUrl(baseUrl)),
158
+ );
159
+ const demoAuthAvailable = demoMode && useDemoFallback;
160
+ const [email, setEmail] = useState("");
161
+ const [password, setPassword] = useState("");
162
+ const [error, setError] = useState<string | null>(null);
163
+ const [pending, setPending] = useState(false);
164
+
165
+ useEffect(() => {
166
+ if (!demoMode) {
167
+ setUseDemoFallback(false);
168
+ } else if (!baseUrl || isPlaceholderDevPortalApiUrl(baseUrl)) {
169
+ setUseDemoFallback(true);
170
+ }
171
+ }, [baseUrl, demoMode]);
172
+
173
+ useEffect(() => {
174
+ if (demoAuthAvailable) {
175
+ setConfig({ native: { enabled: true }, email: null, providers: [] });
176
+ setConfigLoading(false);
177
+ return;
178
+ }
179
+
180
+ if (!baseUrl) return;
181
+ setConfigLoading(true);
182
+ setConfigError(null);
183
+ void fetchDevPortalAuthConfig(baseUrl)
184
+ .then((loaded) => {
185
+ setConfig(loaded);
186
+ setConfigLoading(false);
187
+ })
188
+ .catch(() => {
189
+ if (demoMode) {
190
+ setUseDemoFallback(true);
191
+ setConfig({ native: { enabled: true }, email: null, providers: [] });
192
+ setConfigLoading(false);
193
+ setConfigError(null);
194
+ return;
195
+ }
196
+
197
+ setConfig(null);
198
+ setConfigLoading(false);
199
+ setConfigError(
200
+ `Could not reach the dev portal API at ${baseUrl}. If you use a local HTTPS certificate, open ${baseUrl}/api/v1/auth/status in your browser first to trust it.`,
201
+ );
202
+ });
203
+ }, [baseUrl, demoAuthAvailable, demoMode]);
204
+
205
+ const onSubmit = useCallback(
206
+ async (event: React.FormEvent) => {
207
+ event.preventDefault();
208
+ setPending(true);
209
+ setError(null);
210
+
211
+ if (demoAuthAvailable) {
212
+ if (!isDemoCredential(email, password)) {
213
+ setError("Invalid demo credentials.");
214
+ setPending(false);
215
+ return;
216
+ }
217
+
218
+ activateDemoSession(baseUrl);
219
+ window.location.assign(toAbsoluteReturnUrl(returnUrl));
220
+ return;
221
+ }
222
+
223
+ if (!baseUrl) return;
224
+ try {
225
+ await postDevPortalAuth(baseUrl, "/api/v1/auth/login", {
226
+ email,
227
+ password,
228
+ });
229
+ window.location.assign(toAbsoluteReturnUrl(returnUrl));
230
+ } catch (err) {
231
+ setError(err instanceof Error ? err.message : "Login failed.");
232
+ } finally {
233
+ setPending(false);
234
+ }
235
+ },
236
+ [baseUrl, demoAuthAvailable, email, password, returnUrl],
237
+ );
238
+
239
+ if (!baseUrl && !demoAuthAvailable) {
240
+ return (
241
+ <AuthPageShell
242
+ title="Sign in"
243
+ description="Dev portal API is not configured."
244
+ >
245
+ <p className="text-sm text-muted-foreground">
246
+ Set authentication.apiBaseUrl to continue.
247
+ </p>
248
+ </AuthPageShell>
249
+ );
250
+ }
251
+
252
+ return (
253
+ <AuthPageShell title="Sign in" description="Access your developer account.">
254
+ {demoAuthAvailable ? <DemoLoginHint /> : null}
255
+ {isNativeAuthEnabled(config) || demoAuthAvailable ? (
256
+ <form className="space-y-4" onSubmit={onSubmit}>
257
+ <div className="space-y-2">
258
+ <Label htmlFor="email">Email</Label>
259
+ <Input
260
+ id="email"
261
+ type="email"
262
+ autoComplete="email"
263
+ value={email}
264
+ onChange={(event) => setEmail(event.target.value)}
265
+ required
266
+ />
267
+ </div>
268
+ <div className="space-y-2">
269
+ <Label htmlFor="password">Password</Label>
270
+ <Input
271
+ id="password"
272
+ type="password"
273
+ autoComplete="current-password"
274
+ value={password}
275
+ onChange={(event) => setPassword(event.target.value)}
276
+ required
277
+ />
278
+ </div>
279
+ <AuthError message={error} />
280
+ <Button type="submit" className="w-full" disabled={pending}>
281
+ {pending ? <Spinner /> : "Sign in"}
282
+ </Button>
283
+ <div className="flex justify-between text-sm">
284
+ <Link
285
+ className="text-primary hover:underline"
286
+ to="/forgot-password"
287
+ >
288
+ Forgot password?
289
+ </Link>
290
+ <Link
291
+ className="text-primary hover:underline"
292
+ to={`/register?redirect=${encodeURIComponent(returnUrl)}`}
293
+ >
294
+ Create account
295
+ </Link>
296
+ </div>
297
+ </form>
298
+ ) : null}
299
+ {configError && !demoAuthAvailable ? (
300
+ <AuthError message={configError} />
301
+ ) : null}
302
+ {configLoading ? (
303
+ <div className="flex justify-center">
304
+ <Spinner />
305
+ </div>
306
+ ) : config && !demoAuthAvailable && baseUrl ? (
307
+ <>
308
+ <OidcProviderButtons
309
+ apiBaseUrl={baseUrl}
310
+ config={config}
311
+ returnUrl={returnUrl}
312
+ />
313
+ {!isNativeAuthEnabled(config) && !config.providers.length ? (
314
+ <p className="text-sm text-muted-foreground text-center">
315
+ No sign-in methods are configured.
316
+ </p>
317
+ ) : null}
318
+ </>
319
+ ) : null}
320
+ </AuthPageShell>
321
+ );
322
+ }
323
+
324
+ export function DevPortalRegisterPage({
325
+ apiBaseUrl,
326
+ defaultReturnUrl = "/",
327
+ }: {
328
+ apiBaseUrl?: string;
329
+ defaultReturnUrl?: string;
330
+ }) {
331
+ const baseUrl = useApiBaseUrl(apiBaseUrl);
332
+ const returnUrl = useReturnUrl(defaultReturnUrl);
333
+ const [email, setEmail] = useState("");
334
+ const [password, setPassword] = useState("");
335
+ const [displayName, setDisplayName] = useState("");
336
+ const [message, setMessage] = useState<string | null>(null);
337
+ const [error, setError] = useState<string | null>(null);
338
+ const [pending, setPending] = useState(false);
339
+ const [config, setConfig] = useState<DevPortalAuthConfigResponse | null>(
340
+ null,
341
+ );
342
+ const [configLoading, setConfigLoading] = useState(
343
+ Boolean(baseUrl) && !isPlaceholderDevPortalApiUrl(baseUrl),
344
+ );
345
+
346
+ // Native sign-up only exists when the backend says so. Otherwise sign-up happens at the identity provider, the same
347
+ // way sign-in does, and the form below would only end in "Native authentication is not enabled".
348
+ useEffect(() => {
349
+ if (!baseUrl || isPlaceholderDevPortalApiUrl(baseUrl)) {
350
+ setConfigLoading(false);
351
+ return;
352
+ }
353
+
354
+ setConfigLoading(true);
355
+ void fetchDevPortalAuthConfig(baseUrl)
356
+ .then((loaded) => setConfig(loaded))
357
+ .catch(() => setConfig(null))
358
+ .finally(() => setConfigLoading(false));
359
+ }, [baseUrl]);
360
+
361
+ const onSubmit = useCallback(
362
+ async (event: React.FormEvent) => {
363
+ event.preventDefault();
364
+ if (!baseUrl) return;
365
+ setPending(true);
366
+ setError(null);
367
+ setMessage(null);
368
+ try {
369
+ const response = await postDevPortalAuth<{ message: string }>(
370
+ baseUrl,
371
+ "/api/v1/auth/register",
372
+ { email, password, displayName: displayName || null },
373
+ );
374
+ setMessage(response.message);
375
+ } catch (err) {
376
+ setError(err instanceof Error ? err.message : "Registration failed.");
377
+ } finally {
378
+ setPending(false);
379
+ }
380
+ },
381
+ [baseUrl, displayName, email, password],
382
+ );
383
+
384
+ if (!baseUrl) {
385
+ return (
386
+ <AuthPageShell
387
+ title="Create your account"
388
+ description="Dev portal API is not configured."
389
+ >
390
+ <p className="text-sm text-muted-foreground">
391
+ Set authentication.apiBaseUrl to continue.
392
+ </p>
393
+ </AuthPageShell>
394
+ );
395
+ }
396
+
397
+ if (configLoading) {
398
+ return (
399
+ <AuthPageShell title="Create your account">
400
+ <div className="flex justify-center">
401
+ <Spinner />
402
+ </div>
403
+ </AuthPageShell>
404
+ );
405
+ }
406
+
407
+ if (config && !isNativeAuthEnabled(config)) {
408
+ return (
409
+ <AuthPageShell
410
+ title="Create your account"
411
+ description="Accounts are created on our secure sign-in provider."
412
+ >
413
+ {config.providers.length ? (
414
+ <OidcProviderButtons
415
+ apiBaseUrl={baseUrl}
416
+ config={config}
417
+ returnUrl={returnUrl}
418
+ label="Continue to create your account"
419
+ />
420
+ ) : (
421
+ <p className="text-sm text-muted-foreground text-center">
422
+ Sign-up is not available.
423
+ </p>
424
+ )}
425
+ <p className="text-sm text-muted-foreground">
426
+ Already have an account?{" "}
427
+ <Link
428
+ className="font-semibold text-primary no-underline hover:underline"
429
+ to={`/login?redirect=${encodeURIComponent(returnUrl)}`}
430
+ >
431
+ Log in
432
+ </Link>
433
+ </p>
434
+ </AuthPageShell>
435
+ );
436
+ }
437
+
438
+ return (
439
+ <AuthPageShell title="Create your account">
440
+ <form className="flex flex-col gap-4" onSubmit={onSubmit}>
441
+ <div className="space-y-2">
442
+ <Label htmlFor="displayName">Full name</Label>
443
+ <Input
444
+ id="displayName"
445
+ placeholder="Priya Shah"
446
+ value={displayName}
447
+ onChange={(event) => setDisplayName(event.target.value)}
448
+ className="h-10"
449
+ />
450
+ </div>
451
+ <div className="space-y-2">
452
+ <Label htmlFor="email">Email</Label>
453
+ <Input
454
+ id="email"
455
+ type="email"
456
+ autoComplete="email"
457
+ placeholder="you@company.com"
458
+ value={email}
459
+ onChange={(event) => setEmail(event.target.value)}
460
+ required
461
+ className="h-10"
462
+ />
463
+ </div>
464
+ <div className="space-y-2">
465
+ <Label htmlFor="password">Password</Label>
466
+ <Input
467
+ id="password"
468
+ type="password"
469
+ autoComplete="new-password"
470
+ placeholder="••••••••"
471
+ value={password}
472
+ onChange={(event) => setPassword(event.target.value)}
473
+ required
474
+ className="h-10"
475
+ />
476
+ <p className="text-xs text-muted-foreground">At least 8 characters</p>
477
+ </div>
478
+ <AuthError message={error} />
479
+ {message ? (
480
+ <p className="text-sm text-muted-foreground">{message}</p>
481
+ ) : null}
482
+ <Button
483
+ type="submit"
484
+ size="lg"
485
+ className="h-12 w-full text-base"
486
+ disabled={pending}
487
+ >
488
+ {pending ? <Spinner /> : "Create account"}
489
+ </Button>
490
+ <p className="text-xs leading-relaxed text-muted-foreground">
491
+ By creating an account you agree to our Terms and Privacy Policy.
492
+ </p>
493
+ <p className="text-sm text-muted-foreground">
494
+ Already have an account?{" "}
495
+ <Link
496
+ className="font-semibold text-primary no-underline hover:underline"
497
+ to={`/login?redirect=${encodeURIComponent(returnUrl)}`}
498
+ >
499
+ Log in
500
+ </Link>
501
+ </p>
502
+ </form>
503
+ </AuthPageShell>
504
+ );
505
+ }
506
+
507
+ export function DevPortalVerifyEmailPage({
508
+ apiBaseUrl,
509
+ }: {
510
+ apiBaseUrl?: string;
511
+ }) {
512
+ const baseUrl = useApiBaseUrl(apiBaseUrl);
513
+ const [search] = useSearchParams();
514
+ const [message, setMessage] = useState<string | null>(null);
515
+ const [error, setError] = useState<string | null>(null);
516
+
517
+ useEffect(() => {
518
+ const token = search.get("token");
519
+ if (!baseUrl || !token) return;
520
+ void postDevPortalAuth<{ message: string }>(
521
+ baseUrl,
522
+ "/api/v1/auth/verify-email",
523
+ {
524
+ token,
525
+ },
526
+ )
527
+ .then((response) => setMessage(response.message))
528
+ .catch((err) =>
529
+ setError(err instanceof Error ? err.message : "Verification failed."),
530
+ );
531
+ }, [baseUrl, search]);
532
+
533
+ return (
534
+ <AuthPageShell title="Verify email">
535
+ <AuthError message={error} />
536
+ {message ? (
537
+ <p className="text-sm text-muted-foreground">{message}</p>
538
+ ) : (
539
+ <Spinner />
540
+ )}
541
+ <Link className="text-sm text-primary hover:underline" to="/login">
542
+ Sign in
543
+ </Link>
544
+ </AuthPageShell>
545
+ );
546
+ }
547
+
548
+ export function DevPortalForgotPasswordPage({
549
+ apiBaseUrl,
550
+ }: {
551
+ apiBaseUrl?: string;
552
+ }) {
553
+ const baseUrl = useApiBaseUrl(apiBaseUrl);
554
+ const [email, setEmail] = useState("");
555
+ const [message, setMessage] = useState<string | null>(null);
556
+ const [error, setError] = useState<string | null>(null);
557
+ const [pending, setPending] = useState(false);
558
+
559
+ const onSubmit = useCallback(
560
+ async (event: React.FormEvent) => {
561
+ event.preventDefault();
562
+ if (!baseUrl) return;
563
+ setPending(true);
564
+ setError(null);
565
+ try {
566
+ const response = await postDevPortalAuth<{ message: string }>(
567
+ baseUrl,
568
+ "/api/v1/auth/forgot-password",
569
+ { email },
570
+ );
571
+ setMessage(response.message);
572
+ } catch (err) {
573
+ setError(err instanceof Error ? err.message : "Request failed.");
574
+ } finally {
575
+ setPending(false);
576
+ }
577
+ },
578
+ [baseUrl, email],
579
+ );
580
+
581
+ return (
582
+ <AuthPageShell
583
+ title="Reset password"
584
+ description="We'll email you a reset link."
585
+ >
586
+ <form className="space-y-4" onSubmit={onSubmit}>
587
+ <div className="space-y-2">
588
+ <Label htmlFor="email">Email</Label>
589
+ <Input
590
+ id="email"
591
+ type="email"
592
+ value={email}
593
+ onChange={(event) => setEmail(event.target.value)}
594
+ required
595
+ />
596
+ </div>
597
+ <AuthError message={error} />
598
+ {message ? (
599
+ <p className="text-sm text-muted-foreground">{message}</p>
600
+ ) : null}
601
+ <Button type="submit" className="w-full" disabled={pending}>
602
+ {pending ? <Spinner /> : "Send reset link"}
603
+ </Button>
604
+ </form>
605
+ </AuthPageShell>
606
+ );
607
+ }
608
+
609
+ export function DevPortalResetPasswordPage({
610
+ apiBaseUrl,
611
+ }: {
612
+ apiBaseUrl?: string;
613
+ }) {
614
+ const baseUrl = useApiBaseUrl(apiBaseUrl);
615
+ const [search] = useSearchParams();
616
+ const [password, setPassword] = useState("");
617
+ const [message, setMessage] = useState<string | null>(null);
618
+ const [error, setError] = useState<string | null>(null);
619
+ const [pending, setPending] = useState(false);
620
+
621
+ const onSubmit = useCallback(
622
+ async (event: React.FormEvent) => {
623
+ event.preventDefault();
624
+ const token = search.get("token");
625
+ if (!baseUrl || !token) return;
626
+ setPending(true);
627
+ setError(null);
628
+ try {
629
+ const response = await postDevPortalAuth<{ message: string }>(
630
+ baseUrl,
631
+ "/api/v1/auth/reset-password",
632
+ { token, newPassword: password },
633
+ );
634
+ setMessage(response.message);
635
+ } catch (err) {
636
+ setError(err instanceof Error ? err.message : "Reset failed.");
637
+ } finally {
638
+ setPending(false);
639
+ }
640
+ },
641
+ [baseUrl, password, search],
642
+ );
643
+
644
+ return (
645
+ <AuthPageShell title="Choose a new password">
646
+ <form className="space-y-4" onSubmit={onSubmit}>
647
+ <div className="space-y-2">
648
+ <Label htmlFor="password">New password</Label>
649
+ <Input
650
+ id="password"
651
+ type="password"
652
+ value={password}
653
+ onChange={(event) => setPassword(event.target.value)}
654
+ required
655
+ />
656
+ </div>
657
+ <AuthError message={error} />
658
+ {message ? (
659
+ <p className="text-sm text-muted-foreground">{message}</p>
660
+ ) : null}
661
+ <Button type="submit" className="w-full" disabled={pending}>
662
+ {pending ? <Spinner /> : "Update password"}
663
+ </Button>
664
+ </form>
665
+ </AuthPageShell>
666
+ );
667
+ }