@mehdad67/apitogo 0.1.27 → 0.1.29

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 CHANGED
@@ -3545,6 +3545,13 @@ var init_ZudokuConfig = __esm({
3545
3545
  redirectToAfterSignUp: z8.string().optional(),
3546
3546
  redirectToAfterSignIn: z8.string().optional(),
3547
3547
  redirectToAfterSignOut: z8.string().optional()
3548
+ }),
3549
+ z8.object({
3550
+ type: z8.literal("dev-portal"),
3551
+ apiBaseUrl: z8.string().optional(),
3552
+ redirectToAfterSignUp: z8.string().optional(),
3553
+ redirectToAfterSignIn: z8.string().optional(),
3554
+ redirectToAfterSignOut: z8.string().optional()
3548
3555
  })
3549
3556
  ]);
3550
3557
  MetadataSchema = z8.object({
@@ -4086,6 +4093,9 @@ var getIssuer = async (config2) => {
4086
4093
  case "firebase": {
4087
4094
  return `https://securetoken.google.com/${config2.authentication.projectId}`;
4088
4095
  }
4096
+ case "dev-portal": {
4097
+ return void 0;
4098
+ }
4089
4099
  case void 0: {
4090
4100
  return void 0;
4091
4101
  }
@@ -4111,7 +4121,7 @@ import {
4111
4121
  // package.json
4112
4122
  var package_default = {
4113
4123
  name: "@mehdad67/apitogo",
4114
- version: "0.1.27",
4124
+ version: "0.1.29",
4115
4125
  type: "module",
4116
4126
  sideEffects: [
4117
4127
  "**/*.css",
@@ -4157,6 +4167,7 @@ var package_default = {
4157
4167
  "./auth/supabase": "./src/lib/authentication/providers/supabase.tsx",
4158
4168
  "./auth/azureb2c": "./src/lib/authentication/providers/azureb2c.tsx",
4159
4169
  "./auth/firebase": "./src/lib/authentication/providers/firebase.tsx",
4170
+ "./auth/dev-portal": "./src/lib/authentication/providers/dev-portal.tsx",
4160
4171
  "./plugins": "./src/lib/core/plugins.ts",
4161
4172
  "./plugins/api-keys": "./src/lib/plugins/api-keys/index.tsx",
4162
4173
  "./plugins/markdown": "./src/lib/plugins/markdown/index.tsx",
@@ -4406,6 +4417,10 @@ var package_default = {
4406
4417
  types: "./dist/declarations/lib/authentication/providers/firebase.d.ts",
4407
4418
  default: "./src/lib/authentication/providers/firebase.tsx"
4408
4419
  },
4420
+ "./auth/dev-portal": {
4421
+ types: "./dist/declarations/lib/authentication/providers/dev-portal.d.ts",
4422
+ default: "./src/lib/authentication/providers/dev-portal.tsx"
4423
+ },
4409
4424
  "./plugins": {
4410
4425
  types: "./dist/declarations/lib/core/plugins.d.ts",
4411
4426
  default: "./src/lib/core/plugins.ts"
@@ -24,3 +24,6 @@ export type FirebaseAuthenticationConfig = Extract<AuthenticationConfig, {
24
24
  export type AzureB2CAuthenticationConfig = Extract<AuthenticationConfig, {
25
25
  type: "azureb2c";
26
26
  }>;
27
+ export type DevPortalAuthenticationConfig = Extract<AuthenticationConfig, {
28
+ type: "dev-portal";
29
+ }>;
@@ -366,6 +366,12 @@ declare const AuthenticationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
366
366
  redirectToAfterSignUp: z.ZodOptional<z.ZodString>;
367
367
  redirectToAfterSignIn: z.ZodOptional<z.ZodString>;
368
368
  redirectToAfterSignOut: z.ZodOptional<z.ZodString>;
369
+ }, z.core.$strip>, z.ZodObject<{
370
+ type: z.ZodLiteral<"dev-portal">;
371
+ apiBaseUrl: z.ZodOptional<z.ZodString>;
372
+ redirectToAfterSignUp: z.ZodOptional<z.ZodString>;
373
+ redirectToAfterSignIn: z.ZodOptional<z.ZodString>;
374
+ redirectToAfterSignOut: z.ZodOptional<z.ZodString>;
369
375
  }, z.core.$strip>], "type">;
370
376
  declare const FontConfigSchema: z.ZodUnion<readonly [z.ZodEnum<{
371
377
  Inter: "Inter";
@@ -6728,6 +6734,12 @@ export declare const ZudokuConfig: z.ZodObject<{
6728
6734
  redirectToAfterSignUp: z.ZodOptional<z.ZodString>;
6729
6735
  redirectToAfterSignIn: z.ZodOptional<z.ZodString>;
6730
6736
  redirectToAfterSignOut: z.ZodOptional<z.ZodString>;
6737
+ }, z.core.$strip>, z.ZodObject<{
6738
+ type: z.ZodLiteral<"dev-portal">;
6739
+ apiBaseUrl: z.ZodOptional<z.ZodString>;
6740
+ redirectToAfterSignUp: z.ZodOptional<z.ZodString>;
6741
+ redirectToAfterSignIn: z.ZodOptional<z.ZodString>;
6742
+ redirectToAfterSignOut: z.ZodOptional<z.ZodString>;
6731
6743
  }, z.core.$strip>], "type">>;
6732
6744
  search: z.ZodOptional<z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
6733
6745
  type: z.ZodLiteral<"inkeep">;
@@ -0,0 +1 @@
1
+ export declare const ProductionUnlockPage: () => import("react/jsx-runtime").JSX.Element;
@@ -11,6 +11,8 @@ export declare const useVerifiedEmail: () => {
11
11
  requestEmailVerification: (options?: AuthActionOptions) => Promise<void>;
12
12
  };
13
13
  export declare const useAuth: () => {
14
+ isBackendAvailable: boolean;
15
+ authMode: import("./state.js").AuthMode;
14
16
  login: (options?: AuthActionOptions) => Promise<void>;
15
17
  logout: () => Promise<void>;
16
18
  signup: (options?: AuthActionOptions) => Promise<void>;
@@ -0,0 +1,16 @@
1
+ export declare const DEV_PORTAL_PLACEHOLDER_API_URL = "https://dp-example.azurecontainerapps.io";
2
+ export type DevPortalAuthMode = "live" | "preview";
3
+ export type EndUserMeResponse = {
4
+ userId: string;
5
+ displayName: string | null;
6
+ subscriptions: Array<{
7
+ planSku: string;
8
+ status: string;
9
+ entitlementState: string | null;
10
+ currentPeriodEnd: string | null;
11
+ }>;
12
+ };
13
+ export type DevPortalAuthStatusResponse = {
14
+ available: boolean;
15
+ oidcConfigured: boolean;
16
+ };
@@ -0,0 +1,9 @@
1
+ import type { DevPortalAuthenticationConfig } from "../../../config/config.js";
2
+ import { type EndUserMeResponse } from "./dev-portal-constants.js";
3
+ export declare function resolveDevPortalApiBaseUrl(config: Pick<DevPortalAuthenticationConfig, "apiBaseUrl">): string | undefined;
4
+ export declare function isPlaceholderDevPortalApiUrl(apiBaseUrl: string | undefined): boolean;
5
+ export declare function toAbsoluteReturnUrl(pathOrUrl: string): string;
6
+ export declare function buildDevPortalLoginUrl(apiBaseUrl: string, returnUrl: string): string;
7
+ export declare function buildDevPortalLogoutUrl(apiBaseUrl: string, returnUrl: string): string;
8
+ export declare function probeDevPortalBackend(apiBaseUrl: string, fetchImpl?: typeof fetch): Promise<boolean>;
9
+ export declare function mapEndUserMeToProfile(me: EndUserMeResponse): import("../state.js").UserProfile;
@@ -0,0 +1,36 @@
1
+ import type { DevPortalAuthenticationConfig } from "../../../config/config.js";
2
+ import type { ZudokuContext } from "../../core/ZudokuContext.js";
3
+ import type { AuthActionContext, AuthActionOptions, AuthenticationPlugin, AuthenticationProviderInitializer } from "../authentication.js";
4
+ import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
5
+ import type { DevPortalAuthMode } from "./dev-portal-constants.js";
6
+ export type DevPortalProviderData = {
7
+ type: "dev-portal";
8
+ apiBaseUrl: string;
9
+ authMode: DevPortalAuthMode;
10
+ };
11
+ declare module "../state.js" {
12
+ interface ProviderDataRegistry {
13
+ "dev-portal": DevPortalProviderData;
14
+ }
15
+ }
16
+ export declare class DevPortalAuthenticationProvider extends CoreAuthenticationPlugin implements AuthenticationPlugin {
17
+ private readonly apiBaseUrl;
18
+ private readonly redirectToAfterSignIn;
19
+ private readonly redirectToAfterSignUp;
20
+ private readonly redirectToAfterSignOut;
21
+ private authMode;
22
+ private backendAvailable;
23
+ constructor({ apiBaseUrl, redirectToAfterSignIn, redirectToAfterSignUp, redirectToAfterSignOut, }: DevPortalAuthenticationConfig);
24
+ initialize(_context: ZudokuContext): Promise<void>;
25
+ onPageLoad: () => Promise<void>;
26
+ private syncBackendAvailability;
27
+ private setPreviewState;
28
+ private ensureLiveBackend;
29
+ refreshUserProfile(): Promise<boolean>;
30
+ signIn(_: AuthActionContext, { redirectTo }?: AuthActionOptions): Promise<void>;
31
+ signUp(_: AuthActionContext, { redirectTo }?: AuthActionOptions): Promise<void>;
32
+ signOut(_: AuthActionContext): Promise<void>;
33
+ signRequest(request: Request): Promise<Request>;
34
+ }
35
+ declare const devPortalAuth: AuthenticationProviderInitializer<DevPortalAuthenticationConfig>;
36
+ export default devPortalAuth;
@@ -1,11 +1,14 @@
1
1
  export interface ProviderDataRegistry {
2
2
  }
3
3
  export type ProviderData = [keyof ProviderDataRegistry] extends [never] ? unknown : ProviderDataRegistry[keyof ProviderDataRegistry];
4
+ export type AuthMode = "live" | "preview";
4
5
  export interface AuthState {
5
6
  isAuthenticated: boolean;
6
7
  isPending: boolean;
7
8
  profile: UserProfile | null;
8
9
  providerData: ProviderData | null;
10
+ isBackendAvailable: boolean;
11
+ authMode: AuthMode;
9
12
  setAuthenticationPending: () => void;
10
13
  setLoggedOut: () => void;
11
14
  setLoggedIn: (args: {
@@ -305,6 +305,12 @@ export interface FlatZudokuConfig {
305
305
  redirectToAfterSignUp?: string
306
306
  redirectToAfterSignIn?: string
307
307
  redirectToAfterSignOut?: string
308
+ } | {
309
+ type: "dev-portal"
310
+ apiBaseUrl?: string
311
+ redirectToAfterSignUp?: string
312
+ redirectToAfterSignIn?: string
313
+ redirectToAfterSignOut?: string
308
314
  })
309
315
  search?: ({
310
316
  type: "inkeep"
@@ -15,12 +15,42 @@ the authentication provider you use.
15
15
 
16
16
  ## Authentication Providers
17
17
 
18
- APIToGo supports Clerk, Auth0, Supabase, Azure B2C, and any OpenID provider that supports the OpenID
19
- Connect protocol.
18
+ APIToGo supports Clerk, Auth0, Supabase, Azure B2C, any OpenID provider that supports the OpenID
19
+ Connect protocol, and **dev-portal** (backend-delegated OIDC for APItoGo provisioned developer
20
+ portals).
20
21
 
21
22
  Not seeing your authentication provider?
22
23
  [Let us know](https://github.com/lukoweb/apitogo-doc-tool/issues)
23
24
 
25
+ ### Dev portal (APItoGo)
26
+
27
+ For sites provisioned through APItoGo MCP, use backend-delegated authentication. The static site
28
+ redirects sign-in to the provisioned dev-portal API; OIDC client credentials stay on the backend
29
+ only.
30
+
31
+ ```typescript
32
+ {
33
+ // ...
34
+ authentication: {
35
+ type: "dev-portal",
36
+ },
37
+ // ...
38
+ }
39
+ ```
40
+
41
+ Set the backend API URL via environment variable (MCP writes this at scaffold/publish):
42
+
43
+ ```bash
44
+ VITE_APITOGO_DEV_PORTAL_API_URL=https://your-dev-portal-api.example.com
45
+ ```
46
+
47
+ **Local preview:** When the backend URL is a placeholder or unreachable, the account area shows a
48
+ message that sign-in unlocks after publish. Docs and API reference remain public.
49
+
50
+ **Production:** Register one OIDC app with redirect URI `https://{backend}/signin-oidc` (see MCP
51
+ `manualSteps.oidcRedirectUri`). The frontend uses cookie sessions via `credentials: "include"` on
52
+ `/api/v1/auth/me`.
53
+
24
54
  ### Auth0
25
55
 
26
56
  For Auth0, you will need the `clientId` associated with the domain you are using.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehdad67/apitogo",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -46,6 +46,7 @@
46
46
  "./auth/supabase": "./src/lib/authentication/providers/supabase.tsx",
47
47
  "./auth/azureb2c": "./src/lib/authentication/providers/azureb2c.tsx",
48
48
  "./auth/firebase": "./src/lib/authentication/providers/firebase.tsx",
49
+ "./auth/dev-portal": "./src/lib/authentication/providers/dev-portal.tsx",
49
50
  "./plugins": "./src/lib/core/plugins.ts",
50
51
  "./plugins/api-keys": "./src/lib/plugins/api-keys/index.tsx",
51
52
  "./plugins/markdown": "./src/lib/plugins/markdown/index.tsx",
@@ -295,6 +296,10 @@
295
296
  "types": "./dist/declarations/lib/authentication/providers/firebase.d.ts",
296
297
  "default": "./src/lib/authentication/providers/firebase.tsx"
297
298
  },
299
+ "./auth/dev-portal": {
300
+ "types": "./dist/declarations/lib/authentication/providers/dev-portal.d.ts",
301
+ "default": "./src/lib/authentication/providers/dev-portal.tsx"
302
+ },
298
303
  "./plugins": {
299
304
  "types": "./dist/declarations/lib/core/plugins.d.ts",
300
305
  "default": "./src/lib/core/plugins.ts"
@@ -68,3 +68,7 @@ export type AzureB2CAuthenticationConfig = Extract<
68
68
  AuthenticationConfig,
69
69
  { type: "azureb2c" }
70
70
  >;
71
+ export type DevPortalAuthenticationConfig = Extract<
72
+ AuthenticationConfig,
73
+ { type: "dev-portal" }
74
+ >;
@@ -491,6 +491,13 @@ const AuthenticationSchema = z.discriminatedUnion("type", [
491
491
  redirectToAfterSignIn: z.string().optional(),
492
492
  redirectToAfterSignOut: z.string().optional(),
493
493
  }),
494
+ z.object({
495
+ type: z.literal("dev-portal"),
496
+ apiBaseUrl: z.string().optional(),
497
+ redirectToAfterSignUp: z.string().optional(),
498
+ redirectToAfterSignIn: z.string().optional(),
499
+ redirectToAfterSignOut: z.string().optional(),
500
+ }),
494
501
  ]);
495
502
 
496
503
  const MetadataSchema = z
@@ -21,6 +21,9 @@ export const getIssuer = async (config: ZudokuConfig) => {
21
21
  case "firebase": {
22
22
  return `https://securetoken.google.com/${config.authentication.projectId}`;
23
23
  }
24
+ case "dev-portal": {
25
+ return undefined;
26
+ }
24
27
  case undefined: {
25
28
  return undefined;
26
29
  }
@@ -0,0 +1,27 @@
1
+ import { Button } from "@mehdad67/apitogo/ui/Button.js";
2
+ import { Link } from "react-router";
3
+ import { Layout } from "../../components/Layout.js";
4
+
5
+ export const ProductionUnlockPage = () => (
6
+ <Layout>
7
+ <div className="mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-4 text-center">
8
+ <h1 className="text-2xl font-semibold tracking-tight">
9
+ Account sign-in unlocks after publish
10
+ </h1>
11
+ <p className="text-muted-foreground">
12
+ Your developer portal account area uses live authentication backed by
13
+ APItoGo. Preview this site locally for docs and API reference; sign-in,
14
+ plans, and subscriptions activate once the portal is published to
15
+ production.
16
+ </p>
17
+ <div className="flex flex-wrap items-center justify-center gap-3">
18
+ <Button asChild variant="default">
19
+ <Link to="/introduction">Browse documentation</Link>
20
+ </Button>
21
+ <Button asChild variant="outline">
22
+ <Link to="/">Back to home</Link>
23
+ </Button>
24
+ </div>
25
+ </div>
26
+ </Layout>
27
+ );
@@ -77,6 +77,8 @@ export const useAuth = () => {
77
77
  return {
78
78
  isAuthEnabled,
79
79
  ...authState,
80
+ isBackendAvailable: authState.isBackendAvailable,
81
+ authMode: authState.authMode,
80
82
 
81
83
  login: async (options?: AuthActionOptions) => {
82
84
  if (!isAuthEnabled) {
@@ -0,0 +1,21 @@
1
+ /** Placeholder backend URL written by MCP before publish. */
2
+ export const DEV_PORTAL_PLACEHOLDER_API_URL =
3
+ "https://dp-example.azurecontainerapps.io";
4
+
5
+ export type DevPortalAuthMode = "live" | "preview";
6
+
7
+ export type EndUserMeResponse = {
8
+ userId: string;
9
+ displayName: string | null;
10
+ subscriptions: Array<{
11
+ planSku: string;
12
+ status: string;
13
+ entitlementState: string | null;
14
+ currentPeriodEnd: string | null;
15
+ }>;
16
+ };
17
+
18
+ export type DevPortalAuthStatusResponse = {
19
+ available: boolean;
20
+ oidcConfigured: boolean;
21
+ };
@@ -0,0 +1,107 @@
1
+ import type { DevPortalAuthenticationConfig } from "../../../config/config.js";
2
+ import {
3
+ DEV_PORTAL_PLACEHOLDER_API_URL,
4
+ type DevPortalAuthStatusResponse,
5
+ type EndUserMeResponse,
6
+ } from "./dev-portal-constants.js";
7
+
8
+ declare const VITE_APITOGO_DEV_PORTAL_API_URL: string | undefined;
9
+
10
+ export function resolveDevPortalApiBaseUrl(
11
+ config: Pick<DevPortalAuthenticationConfig, "apiBaseUrl">,
12
+ ): string | undefined {
13
+ const fromConfig = config.apiBaseUrl?.trim();
14
+ if (fromConfig) {
15
+ return fromConfig.replace(/\/+$/, "");
16
+ }
17
+
18
+ const fromEnv =
19
+ typeof VITE_APITOGO_DEV_PORTAL_API_URL === "string"
20
+ ? VITE_APITOGO_DEV_PORTAL_API_URL.trim()
21
+ : "";
22
+
23
+ return fromEnv ? fromEnv.replace(/\/+$/, "") : undefined;
24
+ }
25
+
26
+ export function isPlaceholderDevPortalApiUrl(
27
+ apiBaseUrl: string | undefined,
28
+ ): boolean {
29
+ if (!apiBaseUrl) {
30
+ return true;
31
+ }
32
+
33
+ return (
34
+ apiBaseUrl === DEV_PORTAL_PLACEHOLDER_API_URL ||
35
+ apiBaseUrl.includes("dp-example.azurecontainerapps.io")
36
+ );
37
+ }
38
+
39
+ export function toAbsoluteReturnUrl(pathOrUrl: string): string {
40
+ if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
41
+ return pathOrUrl;
42
+ }
43
+
44
+ if (typeof window === "undefined") {
45
+ return pathOrUrl;
46
+ }
47
+
48
+ return new URL(pathOrUrl, window.location.origin).href;
49
+ }
50
+
51
+ export function buildDevPortalLoginUrl(
52
+ apiBaseUrl: string,
53
+ returnUrl: string,
54
+ ): string {
55
+ const url = new URL("/api/v1/auth/login", apiBaseUrl);
56
+ url.searchParams.set("returnUrl", toAbsoluteReturnUrl(returnUrl));
57
+ return url.toString();
58
+ }
59
+
60
+ export function buildDevPortalLogoutUrl(
61
+ apiBaseUrl: string,
62
+ returnUrl: string,
63
+ ): string {
64
+ const url = new URL("/api/v1/auth/logout", apiBaseUrl);
65
+ url.searchParams.set("returnUrl", toAbsoluteReturnUrl(returnUrl));
66
+ return url.toString();
67
+ }
68
+
69
+ export async function probeDevPortalBackend(
70
+ apiBaseUrl: string,
71
+ fetchImpl: typeof fetch = fetch,
72
+ ): Promise<boolean> {
73
+ const controller = new AbortController();
74
+ const timeout = setTimeout(() => controller.abort(), 8_000);
75
+
76
+ try {
77
+ const response = await fetchImpl(`${apiBaseUrl}/api/v1/auth/status`, {
78
+ method: "GET",
79
+ headers: { Accept: "application/json" },
80
+ credentials: "include",
81
+ signal: controller.signal,
82
+ });
83
+
84
+ if (!response.ok) {
85
+ return false;
86
+ }
87
+
88
+ const body = (await response.json()) as DevPortalAuthStatusResponse;
89
+ return body.available === true;
90
+ } catch {
91
+ return false;
92
+ } finally {
93
+ clearTimeout(timeout);
94
+ }
95
+ }
96
+
97
+ export function mapEndUserMeToProfile(
98
+ me: EndUserMeResponse,
99
+ ): import("../state.js").UserProfile {
100
+ return {
101
+ sub: me.userId,
102
+ email: undefined,
103
+ emailVerified: false,
104
+ name: me.displayName ?? undefined,
105
+ pictureUrl: undefined,
106
+ };
107
+ }
@@ -0,0 +1,218 @@
1
+ import type { DevPortalAuthenticationConfig } from "../../../config/config.js";
2
+ import type { ZudokuContext } from "../../core/ZudokuContext.js";
3
+ import type {
4
+ AuthActionContext,
5
+ AuthActionOptions,
6
+ AuthenticationPlugin,
7
+ AuthenticationProviderInitializer,
8
+ } from "../authentication.js";
9
+ import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
10
+ import { type UserProfile, useAuthState } from "../state.js";
11
+ import type {
12
+ DevPortalAuthMode,
13
+ EndUserMeResponse,
14
+ } from "./dev-portal-constants.js";
15
+ import {
16
+ buildDevPortalLoginUrl,
17
+ buildDevPortalLogoutUrl,
18
+ isPlaceholderDevPortalApiUrl,
19
+ mapEndUserMeToProfile,
20
+ probeDevPortalBackend,
21
+ resolveDevPortalApiBaseUrl,
22
+ } from "./dev-portal-utils.js";
23
+
24
+ export type DevPortalProviderData = {
25
+ type: "dev-portal";
26
+ apiBaseUrl: string;
27
+ authMode: DevPortalAuthMode;
28
+ };
29
+
30
+ declare module "../state.js" {
31
+ interface ProviderDataRegistry {
32
+ "dev-portal": DevPortalProviderData;
33
+ }
34
+ }
35
+
36
+ export class DevPortalAuthenticationProvider
37
+ extends CoreAuthenticationPlugin
38
+ implements AuthenticationPlugin
39
+ {
40
+ private readonly apiBaseUrl: string | undefined;
41
+ private readonly redirectToAfterSignIn: string | undefined;
42
+ private readonly redirectToAfterSignUp: string | undefined;
43
+ private readonly redirectToAfterSignOut: string;
44
+ private authMode: DevPortalAuthMode = "preview";
45
+ private backendAvailable = false;
46
+
47
+ constructor({
48
+ apiBaseUrl,
49
+ redirectToAfterSignIn,
50
+ redirectToAfterSignUp,
51
+ redirectToAfterSignOut = "/",
52
+ }: DevPortalAuthenticationConfig) {
53
+ super();
54
+ this.apiBaseUrl = resolveDevPortalApiBaseUrl({ apiBaseUrl });
55
+ this.redirectToAfterSignIn = redirectToAfterSignIn;
56
+ this.redirectToAfterSignUp = redirectToAfterSignUp;
57
+ this.redirectToAfterSignOut = redirectToAfterSignOut;
58
+ }
59
+
60
+ async initialize(_context: ZudokuContext): Promise<void> {
61
+ await this.syncBackendAvailability();
62
+ if (!this.backendAvailable) {
63
+ this.setPreviewState();
64
+ return;
65
+ }
66
+
67
+ await this.refreshUserProfile();
68
+ }
69
+
70
+ onPageLoad = async () => {
71
+ if (!this.backendAvailable) {
72
+ this.setPreviewState();
73
+ return;
74
+ }
75
+
76
+ await this.refreshUserProfile();
77
+ };
78
+
79
+ private async syncBackendAvailability(): Promise<void> {
80
+ if (!this.apiBaseUrl || isPlaceholderDevPortalApiUrl(this.apiBaseUrl)) {
81
+ this.backendAvailable = false;
82
+ this.authMode = "preview";
83
+ return;
84
+ }
85
+
86
+ this.backendAvailable = await probeDevPortalBackend(this.apiBaseUrl);
87
+ this.authMode = this.backendAvailable ? "live" : "preview";
88
+ }
89
+
90
+ private setPreviewState(): void {
91
+ useAuthState.setState({
92
+ isAuthenticated: false,
93
+ isPending: false,
94
+ profile: null,
95
+ providerData: this.apiBaseUrl
96
+ ? {
97
+ type: "dev-portal",
98
+ apiBaseUrl: this.apiBaseUrl,
99
+ authMode: "preview",
100
+ }
101
+ : null,
102
+ isBackendAvailable: false,
103
+ authMode: "preview",
104
+ });
105
+ }
106
+
107
+ private ensureLiveBackend(): string {
108
+ if (!this.apiBaseUrl || !this.backendAvailable) {
109
+ throw new Error("Dev portal authentication is not available in preview.");
110
+ }
111
+
112
+ return this.apiBaseUrl;
113
+ }
114
+
115
+ async refreshUserProfile(): Promise<boolean> {
116
+ if (!this.apiBaseUrl) {
117
+ this.setPreviewState();
118
+ return false;
119
+ }
120
+
121
+ if (isPlaceholderDevPortalApiUrl(this.apiBaseUrl)) {
122
+ this.setPreviewState();
123
+ return false;
124
+ }
125
+
126
+ await this.syncBackendAvailability();
127
+ if (!this.backendAvailable) {
128
+ this.setPreviewState();
129
+ return false;
130
+ }
131
+
132
+ try {
133
+ const response = await fetch(`${this.apiBaseUrl}/api/v1/auth/me`, {
134
+ method: "GET",
135
+ headers: { Accept: "application/json" },
136
+ credentials: "include",
137
+ });
138
+
139
+ if (response.status === 401) {
140
+ useAuthState.setState({
141
+ isAuthenticated: false,
142
+ isPending: false,
143
+ profile: null,
144
+ providerData: {
145
+ type: "dev-portal",
146
+ apiBaseUrl: this.apiBaseUrl,
147
+ authMode: "live",
148
+ },
149
+ isBackendAvailable: true,
150
+ authMode: "live",
151
+ });
152
+ return false;
153
+ }
154
+
155
+ if (!response.ok) {
156
+ this.setPreviewState();
157
+ return false;
158
+ }
159
+
160
+ const me = (await response.json()) as EndUserMeResponse;
161
+ const profile: UserProfile = mapEndUserMeToProfile(me);
162
+
163
+ useAuthState.getState().setLoggedIn({
164
+ profile,
165
+ providerData: {
166
+ type: "dev-portal",
167
+ apiBaseUrl: this.apiBaseUrl,
168
+ authMode: "live",
169
+ },
170
+ });
171
+ useAuthState.setState({
172
+ isBackendAvailable: true,
173
+ authMode: "live",
174
+ });
175
+ return true;
176
+ } catch {
177
+ this.setPreviewState();
178
+ return false;
179
+ }
180
+ }
181
+
182
+ async signIn(
183
+ _: AuthActionContext,
184
+ { redirectTo }: AuthActionOptions = {},
185
+ ): Promise<void> {
186
+ const apiBaseUrl = this.ensureLiveBackend();
187
+ const target =
188
+ redirectTo ?? this.redirectToAfterSignIn ?? window.location.href;
189
+ window.location.assign(buildDevPortalLoginUrl(apiBaseUrl, target));
190
+ }
191
+
192
+ async signUp(
193
+ _: AuthActionContext,
194
+ { redirectTo }: AuthActionOptions = {},
195
+ ): Promise<void> {
196
+ const apiBaseUrl = this.ensureLiveBackend();
197
+ const target =
198
+ redirectTo ?? this.redirectToAfterSignUp ?? window.location.href;
199
+ window.location.assign(buildDevPortalLoginUrl(apiBaseUrl, target));
200
+ }
201
+
202
+ async signOut(_: AuthActionContext): Promise<void> {
203
+ const apiBaseUrl = this.ensureLiveBackend();
204
+ window.location.assign(
205
+ buildDevPortalLogoutUrl(apiBaseUrl, this.redirectToAfterSignOut),
206
+ );
207
+ }
208
+
209
+ async signRequest(request: Request): Promise<Request> {
210
+ return request;
211
+ }
212
+ }
213
+
214
+ const devPortalAuth: AuthenticationProviderInitializer<
215
+ DevPortalAuthenticationConfig
216
+ > = (options) => new DevPortalAuthenticationProvider(options);
217
+
218
+ export default devPortalAuth;
@@ -21,11 +21,17 @@ export type ProviderData = [keyof ProviderDataRegistry] extends [never]
21
21
  ? unknown
22
22
  : ProviderDataRegistry[keyof ProviderDataRegistry];
23
23
 
24
+ export type AuthMode = "live" | "preview";
25
+
24
26
  export interface AuthState {
25
27
  isAuthenticated: boolean;
26
28
  isPending: boolean;
27
29
  profile: UserProfile | null;
28
30
  providerData: ProviderData | null;
31
+ /** False when dev-portal backend is unavailable (local preview). Defaults true. */
32
+ isBackendAvailable: boolean;
33
+ /** Live when backend auth is reachable; preview otherwise. */
34
+ authMode: AuthMode;
29
35
  setAuthenticationPending: () => void;
30
36
  setLoggedOut: () => void;
31
37
  setLoggedIn: (args: {
@@ -41,12 +47,16 @@ export const authState = create<AuthState>()(
41
47
  isPending: true,
42
48
  profile: null,
43
49
  providerData: null,
50
+ isBackendAvailable: true,
51
+ authMode: "live",
44
52
  setAuthenticationPending: () =>
45
53
  set(() => ({
46
54
  isAuthenticated: false,
47
55
  isPending: false,
48
56
  profile: null,
49
57
  providerData: null,
58
+ isBackendAvailable: true,
59
+ authMode: "live",
50
60
  })),
51
61
  setLoggedOut: () =>
52
62
  set(() => ({
@@ -54,6 +64,8 @@ export const authState = create<AuthState>()(
54
64
  isPending: false,
55
65
  profile: null,
56
66
  providerData: null,
67
+ isBackendAvailable: true,
68
+ authMode: "live",
57
69
  })),
58
70
  setLoggedIn: ({ profile, providerData }) =>
59
71
  set(() => ({
@@ -73,9 +73,9 @@ const ProfileMenu = () => {
73
73
  const context = useZudoku();
74
74
  const profileItems = context.getProfileMenuItems();
75
75
  const auth = useAuth();
76
- const { isAuthEnabled, isAuthenticated, profile } = auth;
76
+ const { isAuthEnabled, isAuthenticated, profile, isBackendAvailable } = auth;
77
77
 
78
- if (!isAuthEnabled) return null;
78
+ if (!isAuthEnabled || !isBackendAvailable) return null;
79
79
 
80
80
  return (
81
81
  <ClientOnly fallback={<Skeleton className="rounded-sm h-5 w-24 mr-4" />}>
@@ -130,7 +130,8 @@ export const MobileTopNavigation = () => {
130
130
  getProfileMenuItems,
131
131
  } = context;
132
132
  const headerNavigation = header?.navigation ?? [];
133
- const { isAuthenticated, profile, isAuthEnabled } = authState;
133
+ const { isAuthenticated, profile, isAuthEnabled, isBackendAvailable } =
134
+ authState;
134
135
  const [drawerOpen, setDrawerOpen] = useState(false);
135
136
 
136
137
  const accountItems = getProfileMenuItems();
@@ -232,7 +233,7 @@ export const MobileTopNavigation = () => {
232
233
  </div>
233
234
  <div className="border-t shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] px-4 pt-3 flex flex-col gap-2">
234
235
  <div className="flex items-center justify-between">
235
- {isAuthEnabled && (
236
+ {isAuthEnabled && isBackendAvailable && (
236
237
  <ClientOnly
237
238
  fallback={<Skeleton className="rounded-sm h-8 w-16" />}
238
239
  >
@@ -17,6 +17,7 @@ import {
17
17
  useNavigate,
18
18
  } from "react-router";
19
19
  import { REASON_CODES } from "../../config/validators/reason-codes.js";
20
+ import { ProductionUnlockPage } from "../authentication/components/ProductionUnlockPage.js";
20
21
  import { useAuth } from "../authentication/hook.js";
21
22
  import { RenderContext } from "../components/context/RenderContext.js";
22
23
  import { useZudoku } from "../components/context/ZudokuContext.js";
@@ -174,7 +175,16 @@ export const RouteGuard = () => {
174
175
  return null;
175
176
  }
176
177
 
177
- const showDialog = needsToSignIn || isBlocked;
178
+ if (
179
+ isProtectedRoute &&
180
+ needsToSignIn &&
181
+ auth.isAuthEnabled &&
182
+ !auth.isBackendAvailable
183
+ ) {
184
+ return <ProductionUnlockPage />;
185
+ }
186
+
187
+ const showDialog = auth.isBackendAvailable && (needsToSignIn || isBlocked);
178
188
  const redirectTo = isBlocked
179
189
  ? blocker.location.pathname + blocker.location.search
180
190
  : location.pathname + location.search;