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