@lukoweb/apitogo 0.1.53 → 0.1.55
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/dist/cli/cli.js +686 -373
- package/dist/declarations/config/apitogo-config-core.d.ts +14 -4
- package/dist/declarations/config/landing-manifest.d.ts +76 -0
- package/dist/declarations/config/local-manifest.d.ts +91 -1
- package/dist/declarations/config/validators/ModulesSchema.d.ts +227 -0
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +84 -2
- package/dist/declarations/lib/authentication/header-nav-filter.d.ts +4 -0
- package/dist/declarations/lib/authentication/providers/dev-portal-auth-pages.d.ts +15 -0
- package/dist/declarations/lib/authentication/providers/dev-portal-constants.d.ts +19 -1
- package/dist/declarations/lib/authentication/providers/dev-portal-utils.d.ts +5 -1
- package/dist/declarations/lib/authentication/providers/dev-portal.d.ts +5 -0
- package/dist/declarations/lib/components/HeaderAuthActions.d.ts +3 -0
- package/dist/declarations/lib/components/SiteTitle.d.ts +6 -0
- package/dist/declarations/lib/components/ThemeSwitch.d.ts +3 -1
- package/dist/declarations/lib/components/TopNavigation.d.ts +1 -1
- package/dist/declarations/lib/core/ZudokuContext.d.ts +1 -0
- package/dist/flat-config.d.ts +61 -1
- package/package.json +1 -1
- package/src/app/main.css +2 -2
- package/src/config/apitogo-config-core.ts +140 -11
- package/src/config/apitogo-config-loader.browser.ts +17 -0
- package/src/config/apitogo-config-loader.node.ts +28 -8
- package/src/config/build-apitogo-config.ts +13 -4
- package/src/config/landing-manifest.ts +11 -0
- package/src/config/landing-openapi-showcase.ts +115 -0
- package/src/config/loader.ts +17 -3
- package/src/config/local-manifest.ts +146 -11
- package/src/config/resolve-modules.ts +22 -1
- package/src/config/validators/ModulesSchema.ts +84 -0
- package/src/config/validators/ZudokuConfig.ts +26 -1
- package/src/lib/authentication/auth-header-nav.ts +5 -15
- package/src/lib/authentication/header-nav-filter.ts +36 -0
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +499 -0
- package/src/lib/authentication/providers/dev-portal-constants.ts +15 -1
- package/src/lib/authentication/providers/dev-portal-utils.ts +128 -3
- package/src/lib/authentication/providers/dev-portal.tsx +51 -6
- package/src/lib/components/Header.tsx +49 -39
- package/src/lib/components/HeaderAuthActions.tsx +36 -0
- package/src/lib/components/HeaderNavigation.tsx +11 -9
- package/src/lib/components/MobileTopNavigation.tsx +43 -24
- package/src/lib/components/SiteTitle.tsx +20 -0
- package/src/lib/components/ThemeSwitch.tsx +36 -2
- package/src/lib/components/TopNavigation.tsx +20 -33
- package/src/lib/core/ZudokuContext.ts +1 -0
- package/src/vite/config.ts +21 -0
- package/src/vite/plugin-auth.ts +4 -1
- package/src/vite/plugin-config.ts +39 -4
|
@@ -0,0 +1,499 @@
|
|
|
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 } from "react";
|
|
13
|
+
import { Link, useSearchParams } from "react-router";
|
|
14
|
+
import type { DevPortalAuthConfigResponse } from "./dev-portal-constants.js";
|
|
15
|
+
import {
|
|
16
|
+
buildDevPortalOidcChallengeUrl,
|
|
17
|
+
fetchDevPortalAuthConfig,
|
|
18
|
+
isNativeAuthEnabled,
|
|
19
|
+
postDevPortalAuth,
|
|
20
|
+
resolveDevPortalApiBaseUrl,
|
|
21
|
+
toAbsoluteReturnUrl,
|
|
22
|
+
} from "./dev-portal-utils.js";
|
|
23
|
+
|
|
24
|
+
type AuthPageShellProps = {
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function AuthPageShell({ title, description, children }: AuthPageShellProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex items-center justify-center mt-8 px-4">
|
|
33
|
+
<Card className="max-w-md w-full">
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle className="text-lg">{title}</CardTitle>
|
|
36
|
+
{description ? (
|
|
37
|
+
<CardDescription>{description}</CardDescription>
|
|
38
|
+
) : null}
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent className="space-y-4">{children}</CardContent>
|
|
41
|
+
</Card>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useReturnUrl(): string {
|
|
47
|
+
const [search] = useSearchParams();
|
|
48
|
+
return search.get("redirect") ?? search.get("returnUrl") ?? "/";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useApiBaseUrl(apiBaseUrl?: string): string | undefined {
|
|
52
|
+
return resolveDevPortalApiBaseUrl({ apiBaseUrl });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function AuthError({ message }: { message: string | null }) {
|
|
56
|
+
if (!message) return null;
|
|
57
|
+
return <p className="text-sm text-destructive">{message}</p>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function OidcProviderButtons({
|
|
61
|
+
apiBaseUrl,
|
|
62
|
+
config,
|
|
63
|
+
returnUrl,
|
|
64
|
+
}: {
|
|
65
|
+
apiBaseUrl: string;
|
|
66
|
+
config: DevPortalAuthConfigResponse;
|
|
67
|
+
returnUrl: string;
|
|
68
|
+
}) {
|
|
69
|
+
if (!config.providers.length) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
{isNativeAuthEnabled(config) ? (
|
|
76
|
+
<p className="text-sm text-muted-foreground text-center">
|
|
77
|
+
or continue with
|
|
78
|
+
</p>
|
|
79
|
+
) : null}
|
|
80
|
+
{config.providers.map((provider) => (
|
|
81
|
+
<Button
|
|
82
|
+
key={provider.id}
|
|
83
|
+
type="button"
|
|
84
|
+
variant="outline"
|
|
85
|
+
className="w-full"
|
|
86
|
+
onClick={() => {
|
|
87
|
+
window.location.assign(
|
|
88
|
+
buildDevPortalOidcChallengeUrl(
|
|
89
|
+
apiBaseUrl,
|
|
90
|
+
provider.id,
|
|
91
|
+
returnUrl,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{provider.displayName || provider.id}
|
|
97
|
+
</Button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function DevPortalLoginPage({ apiBaseUrl }: { apiBaseUrl?: string }) {
|
|
104
|
+
const baseUrl = useApiBaseUrl(apiBaseUrl);
|
|
105
|
+
const returnUrl = useReturnUrl();
|
|
106
|
+
const [config, setConfig] = useState<DevPortalAuthConfigResponse | null>(
|
|
107
|
+
null,
|
|
108
|
+
);
|
|
109
|
+
const [configLoading, setConfigLoading] = useState(true);
|
|
110
|
+
const [configError, setConfigError] = useState<string | null>(null);
|
|
111
|
+
const [email, setEmail] = useState("");
|
|
112
|
+
const [password, setPassword] = useState("");
|
|
113
|
+
const [error, setError] = useState<string | null>(null);
|
|
114
|
+
const [pending, setPending] = useState(false);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!baseUrl) return;
|
|
118
|
+
setConfigLoading(true);
|
|
119
|
+
setConfigError(null);
|
|
120
|
+
void fetchDevPortalAuthConfig(baseUrl)
|
|
121
|
+
.then((loaded) => {
|
|
122
|
+
setConfig(loaded);
|
|
123
|
+
setConfigLoading(false);
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {
|
|
126
|
+
setConfig(null);
|
|
127
|
+
setConfigLoading(false);
|
|
128
|
+
setConfigError(
|
|
129
|
+
`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.`,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
}, [baseUrl]);
|
|
133
|
+
|
|
134
|
+
const onSubmit = useCallback(
|
|
135
|
+
async (event: React.FormEvent) => {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
if (!baseUrl) return;
|
|
138
|
+
setPending(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
try {
|
|
141
|
+
await postDevPortalAuth(baseUrl, "/api/v1/auth/login", {
|
|
142
|
+
email,
|
|
143
|
+
password,
|
|
144
|
+
});
|
|
145
|
+
window.location.assign(toAbsoluteReturnUrl(returnUrl));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
setError(err instanceof Error ? err.message : "Login failed.");
|
|
148
|
+
} finally {
|
|
149
|
+
setPending(false);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
[baseUrl, email, password, returnUrl],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (!baseUrl) {
|
|
156
|
+
return (
|
|
157
|
+
<AuthPageShell
|
|
158
|
+
title="Sign in"
|
|
159
|
+
description="Dev portal API is not configured."
|
|
160
|
+
>
|
|
161
|
+
<p className="text-sm text-muted-foreground">
|
|
162
|
+
Set authentication.apiBaseUrl to continue.
|
|
163
|
+
</p>
|
|
164
|
+
</AuthPageShell>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<AuthPageShell title="Sign in" description="Access your developer account.">
|
|
170
|
+
{isNativeAuthEnabled(config) ? (
|
|
171
|
+
<form className="space-y-4" onSubmit={onSubmit}>
|
|
172
|
+
<div className="space-y-2">
|
|
173
|
+
<Label htmlFor="email">Email</Label>
|
|
174
|
+
<Input
|
|
175
|
+
id="email"
|
|
176
|
+
type="email"
|
|
177
|
+
autoComplete="email"
|
|
178
|
+
value={email}
|
|
179
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
180
|
+
required
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="space-y-2">
|
|
184
|
+
<Label htmlFor="password">Password</Label>
|
|
185
|
+
<Input
|
|
186
|
+
id="password"
|
|
187
|
+
type="password"
|
|
188
|
+
autoComplete="current-password"
|
|
189
|
+
value={password}
|
|
190
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
191
|
+
required
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
<AuthError message={error} />
|
|
195
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
196
|
+
{pending ? <Spinner /> : "Sign in"}
|
|
197
|
+
</Button>
|
|
198
|
+
<div className="flex justify-between text-sm">
|
|
199
|
+
<Link
|
|
200
|
+
className="text-primary hover:underline"
|
|
201
|
+
to="/forgot-password"
|
|
202
|
+
>
|
|
203
|
+
Forgot password?
|
|
204
|
+
</Link>
|
|
205
|
+
<Link
|
|
206
|
+
className="text-primary hover:underline"
|
|
207
|
+
to={`/register?redirect=${encodeURIComponent(returnUrl)}`}
|
|
208
|
+
>
|
|
209
|
+
Create account
|
|
210
|
+
</Link>
|
|
211
|
+
</div>
|
|
212
|
+
</form>
|
|
213
|
+
) : null}
|
|
214
|
+
{configError ? <AuthError message={configError} /> : null}
|
|
215
|
+
{configLoading ? (
|
|
216
|
+
<div className="flex justify-center">
|
|
217
|
+
<Spinner />
|
|
218
|
+
</div>
|
|
219
|
+
) : config ? (
|
|
220
|
+
<>
|
|
221
|
+
<OidcProviderButtons
|
|
222
|
+
apiBaseUrl={baseUrl}
|
|
223
|
+
config={config}
|
|
224
|
+
returnUrl={returnUrl}
|
|
225
|
+
/>
|
|
226
|
+
{!isNativeAuthEnabled(config) && !config.providers.length ? (
|
|
227
|
+
<p className="text-sm text-muted-foreground text-center">
|
|
228
|
+
No sign-in methods are configured.
|
|
229
|
+
</p>
|
|
230
|
+
) : null}
|
|
231
|
+
</>
|
|
232
|
+
) : null}
|
|
233
|
+
</AuthPageShell>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function DevPortalRegisterPage({ apiBaseUrl }: { apiBaseUrl?: string }) {
|
|
238
|
+
const baseUrl = useApiBaseUrl(apiBaseUrl);
|
|
239
|
+
const returnUrl = useReturnUrl();
|
|
240
|
+
const [email, setEmail] = useState("");
|
|
241
|
+
const [password, setPassword] = useState("");
|
|
242
|
+
const [displayName, setDisplayName] = useState("");
|
|
243
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
244
|
+
const [error, setError] = useState<string | null>(null);
|
|
245
|
+
const [pending, setPending] = useState(false);
|
|
246
|
+
|
|
247
|
+
const onSubmit = useCallback(
|
|
248
|
+
async (event: React.FormEvent) => {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
if (!baseUrl) return;
|
|
251
|
+
setPending(true);
|
|
252
|
+
setError(null);
|
|
253
|
+
setMessage(null);
|
|
254
|
+
try {
|
|
255
|
+
const response = await postDevPortalAuth<{ message: string }>(
|
|
256
|
+
baseUrl,
|
|
257
|
+
"/api/v1/auth/register",
|
|
258
|
+
{ email, password, displayName: displayName || null },
|
|
259
|
+
);
|
|
260
|
+
setMessage(response.message);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
setError(err instanceof Error ? err.message : "Registration failed.");
|
|
263
|
+
} finally {
|
|
264
|
+
setPending(false);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
[baseUrl, displayName, email, password],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (!baseUrl) {
|
|
271
|
+
return (
|
|
272
|
+
<AuthPageShell
|
|
273
|
+
title="Create account"
|
|
274
|
+
description="Dev portal API is not configured."
|
|
275
|
+
>
|
|
276
|
+
<p className="text-sm text-muted-foreground">
|
|
277
|
+
Set authentication.apiBaseUrl to continue.
|
|
278
|
+
</p>
|
|
279
|
+
</AuthPageShell>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<AuthPageShell
|
|
285
|
+
title="Create account"
|
|
286
|
+
description="Register for API access."
|
|
287
|
+
>
|
|
288
|
+
<form className="space-y-4" onSubmit={onSubmit}>
|
|
289
|
+
<div className="space-y-2">
|
|
290
|
+
<Label htmlFor="displayName">Name</Label>
|
|
291
|
+
<Input
|
|
292
|
+
id="displayName"
|
|
293
|
+
value={displayName}
|
|
294
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="space-y-2">
|
|
298
|
+
<Label htmlFor="email">Email</Label>
|
|
299
|
+
<Input
|
|
300
|
+
id="email"
|
|
301
|
+
type="email"
|
|
302
|
+
autoComplete="email"
|
|
303
|
+
value={email}
|
|
304
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
305
|
+
required
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
<div className="space-y-2">
|
|
309
|
+
<Label htmlFor="password">Password</Label>
|
|
310
|
+
<Input
|
|
311
|
+
id="password"
|
|
312
|
+
type="password"
|
|
313
|
+
autoComplete="new-password"
|
|
314
|
+
value={password}
|
|
315
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
316
|
+
required
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
<AuthError message={error} />
|
|
320
|
+
{message ? (
|
|
321
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
322
|
+
) : null}
|
|
323
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
324
|
+
{pending ? <Spinner /> : "Create account"}
|
|
325
|
+
</Button>
|
|
326
|
+
<p className="text-sm text-center">
|
|
327
|
+
<Link
|
|
328
|
+
className="text-primary hover:underline"
|
|
329
|
+
to={`/login?redirect=${encodeURIComponent(returnUrl)}`}
|
|
330
|
+
>
|
|
331
|
+
Back to sign in
|
|
332
|
+
</Link>
|
|
333
|
+
</p>
|
|
334
|
+
</form>
|
|
335
|
+
</AuthPageShell>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function DevPortalVerifyEmailPage({
|
|
340
|
+
apiBaseUrl,
|
|
341
|
+
}: {
|
|
342
|
+
apiBaseUrl?: string;
|
|
343
|
+
}) {
|
|
344
|
+
const baseUrl = useApiBaseUrl(apiBaseUrl);
|
|
345
|
+
const [search] = useSearchParams();
|
|
346
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
347
|
+
const [error, setError] = useState<string | null>(null);
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
const token = search.get("token");
|
|
351
|
+
if (!baseUrl || !token) return;
|
|
352
|
+
void postDevPortalAuth<{ message: string }>(
|
|
353
|
+
baseUrl,
|
|
354
|
+
"/api/v1/auth/verify-email",
|
|
355
|
+
{
|
|
356
|
+
token,
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
.then((response) => setMessage(response.message))
|
|
360
|
+
.catch((err) =>
|
|
361
|
+
setError(err instanceof Error ? err.message : "Verification failed."),
|
|
362
|
+
);
|
|
363
|
+
}, [baseUrl, search]);
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<AuthPageShell title="Verify email">
|
|
367
|
+
<AuthError message={error} />
|
|
368
|
+
{message ? (
|
|
369
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
370
|
+
) : (
|
|
371
|
+
<Spinner />
|
|
372
|
+
)}
|
|
373
|
+
<Link className="text-sm text-primary hover:underline" to="/login">
|
|
374
|
+
Sign in
|
|
375
|
+
</Link>
|
|
376
|
+
</AuthPageShell>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function DevPortalForgotPasswordPage({
|
|
381
|
+
apiBaseUrl,
|
|
382
|
+
}: {
|
|
383
|
+
apiBaseUrl?: string;
|
|
384
|
+
}) {
|
|
385
|
+
const baseUrl = useApiBaseUrl(apiBaseUrl);
|
|
386
|
+
const [email, setEmail] = useState("");
|
|
387
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
388
|
+
const [error, setError] = useState<string | null>(null);
|
|
389
|
+
const [pending, setPending] = useState(false);
|
|
390
|
+
|
|
391
|
+
const onSubmit = useCallback(
|
|
392
|
+
async (event: React.FormEvent) => {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
if (!baseUrl) return;
|
|
395
|
+
setPending(true);
|
|
396
|
+
setError(null);
|
|
397
|
+
try {
|
|
398
|
+
const response = await postDevPortalAuth<{ message: string }>(
|
|
399
|
+
baseUrl,
|
|
400
|
+
"/api/v1/auth/forgot-password",
|
|
401
|
+
{ email },
|
|
402
|
+
);
|
|
403
|
+
setMessage(response.message);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
setError(err instanceof Error ? err.message : "Request failed.");
|
|
406
|
+
} finally {
|
|
407
|
+
setPending(false);
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
[baseUrl, email],
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<AuthPageShell
|
|
415
|
+
title="Reset password"
|
|
416
|
+
description="We'll email you a reset link."
|
|
417
|
+
>
|
|
418
|
+
<form className="space-y-4" onSubmit={onSubmit}>
|
|
419
|
+
<div className="space-y-2">
|
|
420
|
+
<Label htmlFor="email">Email</Label>
|
|
421
|
+
<Input
|
|
422
|
+
id="email"
|
|
423
|
+
type="email"
|
|
424
|
+
value={email}
|
|
425
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
426
|
+
required
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
<AuthError message={error} />
|
|
430
|
+
{message ? (
|
|
431
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
432
|
+
) : null}
|
|
433
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
434
|
+
{pending ? <Spinner /> : "Send reset link"}
|
|
435
|
+
</Button>
|
|
436
|
+
</form>
|
|
437
|
+
</AuthPageShell>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function DevPortalResetPasswordPage({
|
|
442
|
+
apiBaseUrl,
|
|
443
|
+
}: {
|
|
444
|
+
apiBaseUrl?: string;
|
|
445
|
+
}) {
|
|
446
|
+
const baseUrl = useApiBaseUrl(apiBaseUrl);
|
|
447
|
+
const [search] = useSearchParams();
|
|
448
|
+
const [password, setPassword] = useState("");
|
|
449
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
450
|
+
const [error, setError] = useState<string | null>(null);
|
|
451
|
+
const [pending, setPending] = useState(false);
|
|
452
|
+
|
|
453
|
+
const onSubmit = useCallback(
|
|
454
|
+
async (event: React.FormEvent) => {
|
|
455
|
+
event.preventDefault();
|
|
456
|
+
const token = search.get("token");
|
|
457
|
+
if (!baseUrl || !token) return;
|
|
458
|
+
setPending(true);
|
|
459
|
+
setError(null);
|
|
460
|
+
try {
|
|
461
|
+
const response = await postDevPortalAuth<{ message: string }>(
|
|
462
|
+
baseUrl,
|
|
463
|
+
"/api/v1/auth/reset-password",
|
|
464
|
+
{ token, newPassword: password },
|
|
465
|
+
);
|
|
466
|
+
setMessage(response.message);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
setError(err instanceof Error ? err.message : "Reset failed.");
|
|
469
|
+
} finally {
|
|
470
|
+
setPending(false);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
[baseUrl, password, search],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<AuthPageShell title="Choose a new password">
|
|
478
|
+
<form className="space-y-4" onSubmit={onSubmit}>
|
|
479
|
+
<div className="space-y-2">
|
|
480
|
+
<Label htmlFor="password">New password</Label>
|
|
481
|
+
<Input
|
|
482
|
+
id="password"
|
|
483
|
+
type="password"
|
|
484
|
+
value={password}
|
|
485
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
486
|
+
required
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
<AuthError message={error} />
|
|
490
|
+
{message ? (
|
|
491
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
492
|
+
) : null}
|
|
493
|
+
<Button type="submit" className="w-full" disabled={pending}>
|
|
494
|
+
{pending ? <Spinner /> : "Update password"}
|
|
495
|
+
</Button>
|
|
496
|
+
</form>
|
|
497
|
+
</AuthPageShell>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
@@ -19,5 +19,19 @@ export type EndUserMeResponse = {
|
|
|
19
19
|
|
|
20
20
|
export type DevPortalAuthStatusResponse = {
|
|
21
21
|
available: boolean;
|
|
22
|
-
|
|
22
|
+
nativeEnabled?: boolean;
|
|
23
|
+
oidcConfigured?: boolean;
|
|
24
|
+
providerCount?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type DevPortalAuthConfigResponse = {
|
|
28
|
+
native: { enabled: boolean };
|
|
29
|
+
email?: { provider: string; from: string } | null;
|
|
30
|
+
providers: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
authority: string;
|
|
34
|
+
clientId: string;
|
|
35
|
+
scopes: string[];
|
|
36
|
+
}>;
|
|
23
37
|
};
|
|
@@ -53,11 +53,15 @@ export function toAbsoluteReturnUrl(pathOrUrl: string): string {
|
|
|
53
53
|
return new URL(pathOrUrl, window.location.origin).href;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function
|
|
56
|
+
export function buildDevPortalOidcChallengeUrl(
|
|
57
57
|
apiBaseUrl: string,
|
|
58
|
+
providerId: string,
|
|
58
59
|
returnUrl: string,
|
|
59
60
|
): string {
|
|
60
|
-
const url = new URL(
|
|
61
|
+
const url = new URL(
|
|
62
|
+
`/api/v1/auth/oidc/${encodeURIComponent(providerId)}/challenge`,
|
|
63
|
+
apiBaseUrl,
|
|
64
|
+
);
|
|
61
65
|
url.searchParams.set("returnUrl", toAbsoluteReturnUrl(returnUrl));
|
|
62
66
|
return url.toString();
|
|
63
67
|
}
|
|
@@ -71,6 +75,127 @@ export function buildDevPortalLogoutUrl(
|
|
|
71
75
|
return url.toString();
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
export function normalizeDevPortalAuthConfig(
|
|
79
|
+
raw: unknown,
|
|
80
|
+
): import("./dev-portal-constants.js").DevPortalAuthConfigResponse {
|
|
81
|
+
const data = (raw ?? {}) as Record<string, unknown>;
|
|
82
|
+
const nestedNative = data.native as { enabled?: boolean } | undefined;
|
|
83
|
+
const nativeEnabled =
|
|
84
|
+
typeof nestedNative?.enabled === "boolean"
|
|
85
|
+
? nestedNative.enabled
|
|
86
|
+
: typeof data.nativeEnabled === "boolean"
|
|
87
|
+
? data.nativeEnabled
|
|
88
|
+
: false;
|
|
89
|
+
|
|
90
|
+
const emailRaw = data.email;
|
|
91
|
+
const email =
|
|
92
|
+
emailRaw &&
|
|
93
|
+
typeof emailRaw === "object" &&
|
|
94
|
+
typeof (emailRaw as { provider?: unknown }).provider === "string" &&
|
|
95
|
+
typeof (emailRaw as { from?: unknown }).from === "string"
|
|
96
|
+
? {
|
|
97
|
+
provider: (emailRaw as { provider: string }).provider,
|
|
98
|
+
from: (emailRaw as { from: string }).from,
|
|
99
|
+
}
|
|
100
|
+
: null;
|
|
101
|
+
|
|
102
|
+
const providers = Array.isArray(data.providers)
|
|
103
|
+
? data.providers
|
|
104
|
+
.filter(
|
|
105
|
+
(provider): provider is Record<string, unknown> =>
|
|
106
|
+
provider != null && typeof provider === "object",
|
|
107
|
+
)
|
|
108
|
+
.map((provider) => ({
|
|
109
|
+
id: String(provider.id ?? ""),
|
|
110
|
+
displayName: String(provider.displayName ?? provider.id ?? ""),
|
|
111
|
+
authority: String(provider.authority ?? ""),
|
|
112
|
+
clientId: String(provider.clientId ?? ""),
|
|
113
|
+
scopes: Array.isArray(provider.scopes)
|
|
114
|
+
? provider.scopes.map((scope) => String(scope))
|
|
115
|
+
: [],
|
|
116
|
+
}))
|
|
117
|
+
.filter((provider) => provider.id.length > 0)
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
native: { enabled: nativeEnabled },
|
|
122
|
+
email,
|
|
123
|
+
providers,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isNativeAuthEnabled(
|
|
128
|
+
config:
|
|
129
|
+
| import("./dev-portal-constants.js").DevPortalAuthConfigResponse
|
|
130
|
+
| null
|
|
131
|
+
| undefined,
|
|
132
|
+
): boolean {
|
|
133
|
+
if (!config) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const raw =
|
|
138
|
+
config as import("./dev-portal-constants.js").DevPortalAuthConfigResponse & {
|
|
139
|
+
nativeEnabled?: boolean;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return raw.native?.enabled === true || raw.nativeEnabled === true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function fetchDevPortalAuthConfig(
|
|
146
|
+
apiBaseUrl: string,
|
|
147
|
+
fetchImpl: typeof fetch = fetch,
|
|
148
|
+
): Promise<import("./dev-portal-constants.js").DevPortalAuthConfigResponse> {
|
|
149
|
+
const response = await fetchImpl(`${apiBaseUrl}/api/v1/auth/config`, {
|
|
150
|
+
method: "GET",
|
|
151
|
+
headers: { Accept: "application/json" },
|
|
152
|
+
credentials: "include",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error("Failed to load auth configuration.");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return normalizeDevPortalAuthConfig(await response.json());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function postDevPortalAuth<T>(
|
|
163
|
+
apiBaseUrl: string,
|
|
164
|
+
path: string,
|
|
165
|
+
body: unknown,
|
|
166
|
+
fetchImpl: typeof fetch = fetch,
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
Accept: "application/json",
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
},
|
|
174
|
+
credentials: "include",
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (response.status === 204) {
|
|
179
|
+
return {} as T;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
let detail = "Request failed.";
|
|
184
|
+
try {
|
|
185
|
+
const problem = (await response.json()) as {
|
|
186
|
+
detail?: string;
|
|
187
|
+
title?: string;
|
|
188
|
+
};
|
|
189
|
+
detail = problem.detail ?? problem.title ?? detail;
|
|
190
|
+
} catch {
|
|
191
|
+
// ignore parse errors
|
|
192
|
+
}
|
|
193
|
+
throw new Error(detail);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (await response.json()) as T;
|
|
197
|
+
}
|
|
198
|
+
|
|
74
199
|
export async function probeDevPortalBackend(
|
|
75
200
|
apiBaseUrl: string,
|
|
76
201
|
fetchImpl: typeof fetch = fetch,
|
|
@@ -137,7 +262,7 @@ export function mapEndUserMeToProfile(
|
|
|
137
262
|
raw.emailVerified,
|
|
138
263
|
raw.EmailVerified,
|
|
139
264
|
raw.email_verified,
|
|
140
|
-
) ??
|
|
265
|
+
) ?? email != null;
|
|
141
266
|
|
|
142
267
|
return {
|
|
143
268
|
sub: me.userId,
|