@rimelight/security 0.0.10 → 0.0.12

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.
@@ -1,3 +1,3 @@
1
- import { t as SecurityOptions } from "../types-t69O3m_O.mjs";
1
+ import { n as SecurityOptions, t as ConstructionOptions } from "../types-BdTXMZBc.mjs";
2
2
  import { buildCspHeader } from "../csp.mjs";
3
- export { SecurityOptions, buildCspHeader };
3
+ export { ConstructionOptions, SecurityOptions, buildCspHeader };
package/dist/csp.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SecurityOptions } from "./types-t69O3m_O.mjs";
1
+ import { n as SecurityOptions } from "./types-BdTXMZBc.mjs";
2
2
  //#region src/csp.d.ts
3
3
  /**
4
4
  * Builds the Content-Security-Policy header string dynamically for a given request nonce.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { t as SecurityOptions } from "./types-t69O3m_O.mjs";
1
+ import { n as SecurityOptions, t as ConstructionOptions } from "./types-BdTXMZBc.mjs";
2
2
  import { buildCspHeader } from "./csp.mjs";
3
3
  import { RimelightSecurityPlugin, SecurityPluginOptions, security } from "./vite.mjs";
4
- export { RimelightSecurityPlugin, SecurityOptions, SecurityPluginOptions, buildCspHeader, security, security as sri };
4
+ import { CONSTRUCTION_GUEST_COOKIE, construction, isConstructionGuest, signInConstructionGuest } from "./middleware/construction.mjs";
5
+ export { CONSTRUCTION_GUEST_COOKIE, ConstructionOptions, RimelightSecurityPlugin, SecurityOptions, SecurityPluginOptions, buildCspHeader, construction, isConstructionGuest, security, security as sri, signInConstructionGuest };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./types.mjs";
2
2
  import { buildCspHeader } from "./csp.mjs";
3
3
  import { security } from "./vite.mjs";
4
- export { buildCspHeader, security, security as sri };
4
+ import { CONSTRUCTION_GUEST_COOKIE, construction, isConstructionGuest, signInConstructionGuest } from "./middleware/construction.mjs";
5
+ export { CONSTRUCTION_GUEST_COOKIE, buildCspHeader, construction, isConstructionGuest, security, security as sri, signInConstructionGuest };
@@ -0,0 +1,20 @@
1
+ import { t as ConstructionOptions } from "../types-BdTXMZBc.mjs";
2
+ //#region src/middleware/construction.d.ts
3
+ export declare const CONSTRUCTION_GUEST_COOKIE = "rimelight-construction-guest";
4
+ /**
5
+ * Validates whether the incoming request carries a valid, signed construction guest cookie.
6
+ */
7
+ export declare const isConstructionGuest: (c: any, options?: ConstructionOptions) => Promise<boolean>;
8
+ /**
9
+ * Signs and sets the construction guest cookie on the Hono or Astro context.
10
+ */
11
+ export declare const signInConstructionGuest: (c: any, passphrase: string, rememberMe?: boolean, options?: ConstructionOptions) => Promise<boolean>;
12
+ /**
13
+ * Universal Construction Mode Middleware for Hono and Astro.
14
+ *
15
+ * Intercepts unauthenticated traffic when CONSTRUCTION_MODE is active, handles POST requests to the
16
+ * guest auth endpoint automatically, and passes authenticated requests or whitelisted assets
17
+ * cleanly.
18
+ */
19
+ export declare function construction(options?: ConstructionOptions): (c: any, next: any) => Promise<any>;
20
+ //#endregion
@@ -0,0 +1,192 @@
1
+ import { getCookie, setCookie } from "hono/cookie";
2
+ //#region src/middleware/construction.ts
3
+ const CONSTRUCTION_GUEST_COOKIE = "rimelight-construction-guest";
4
+ const DEFAULT_WHITELISTED_PATTERNS = [
5
+ "/construction",
6
+ "/api/auth",
7
+ "/api/construction-guest",
8
+ "/_astro/",
9
+ "/_image",
10
+ "/favicon.",
11
+ "/robots.txt",
12
+ "/sitemap",
13
+ "/.well-known/"
14
+ ];
15
+ const getPassphrase = (c, options) => {
16
+ if (options?.passphrase) return options.passphrase;
17
+ const envKey = options?.passphraseEnvKey ?? "CONSTRUCTION_PASSPHRASE";
18
+ return c?.env?.[envKey] ?? (typeof process !== "undefined" ? process.env?.[envKey] : void 0) ?? import.meta.env?.[envKey];
19
+ };
20
+ const isModeEnabled = (c, options) => {
21
+ const envKey = options?.modeEnvKey ?? "CONSTRUCTION_MODE";
22
+ const val = c?.env?.[envKey] ?? (typeof process !== "undefined" ? process.env?.[envKey] : void 0) ?? import.meta.env?.[envKey];
23
+ return val === "true" || val === true;
24
+ };
25
+ const encodeBase64Url = (value) => btoa(String.fromCharCode(...value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26
+ const decodeBase64Url = (value) => {
27
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
28
+ return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
29
+ };
30
+ const signHmac = async (payload, secret) => {
31
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
32
+ name: "HMAC",
33
+ hash: "SHA-256"
34
+ }, false, ["sign", "verify"]);
35
+ return {
36
+ key,
37
+ signature: await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))
38
+ };
39
+ };
40
+ const getContextHelpers = (c) => {
41
+ const req = c.req?.raw || c.request || c.req;
42
+ const urlObj = c.url instanceof URL ? c.url : req?.url ? new URL(req.url) : new URL("http://localhost/");
43
+ const path = c.req?.path || urlObj.pathname || "/";
44
+ const method = (c.req?.method || req?.method || "GET").toUpperCase();
45
+ const search = c.req?.url ? new URL(c.req.url).search : urlObj.search || "";
46
+ const redirect = (target) => {
47
+ if (typeof c.redirect === "function") return c.redirect(target);
48
+ const fullTarget = target.startsWith("http") ? target : new URL(target, urlObj.origin).toString();
49
+ return Response.redirect(fullTarget, 302);
50
+ };
51
+ const json = (data, status = 200) => {
52
+ if (typeof c.json === "function") return c.json(data, status);
53
+ return new Response(JSON.stringify(data), {
54
+ status,
55
+ headers: { "Content-Type": "application/json" }
56
+ });
57
+ };
58
+ const getCookieValue = (name) => {
59
+ try {
60
+ const fromHono = getCookie(c, name);
61
+ if (fromHono) return fromHono;
62
+ } catch {}
63
+ if (c.cookies?.get) {
64
+ const val = c.cookies.get(name);
65
+ return typeof val === "string" ? val : val?.value;
66
+ }
67
+ const match = (req?.headers?.get?.("cookie") || "").match(new RegExp(`(?:^|; )${name}=([^;]*)`));
68
+ return match ? decodeURIComponent(match[1]) : void 0;
69
+ };
70
+ return {
71
+ path,
72
+ method,
73
+ search,
74
+ redirect,
75
+ json,
76
+ getCookieValue
77
+ };
78
+ };
79
+ /**
80
+ * Validates whether the incoming request carries a valid, signed construction guest cookie.
81
+ */
82
+ const isConstructionGuest = async (c, options) => {
83
+ const cookieName = options?.cookieName ?? "rimelight-construction-guest";
84
+ const token = getContextHelpers(c).getCookieValue(cookieName);
85
+ const passphrase = getPassphrase(c, options);
86
+ if (!token || !passphrase) return false;
87
+ try {
88
+ const [encodedPayload, encodedSignature] = token.split(".");
89
+ if (!encodedPayload || !encodedSignature) return false;
90
+ const payload = new TextDecoder().decode(decodeBase64Url(encodedPayload));
91
+ const { key } = await signHmac(payload, passphrase);
92
+ const signatureBuffer = decodeBase64Url(encodedSignature);
93
+ return await crypto.subtle.verify("HMAC", key, signatureBuffer, new TextEncoder().encode(payload)) && JSON.parse(payload).expiresAt > Date.now();
94
+ } catch {
95
+ return false;
96
+ }
97
+ };
98
+ /**
99
+ * Signs and sets the construction guest cookie on the Hono or Astro context.
100
+ */
101
+ const signInConstructionGuest = async (c, passphrase, rememberMe = true, options) => {
102
+ const expectedPassphrase = getPassphrase(c, options);
103
+ if (!expectedPassphrase || !passphrase || passphrase !== expectedPassphrase) return false;
104
+ const cookieName = options?.cookieName ?? "rimelight-construction-guest";
105
+ const maxAge = options?.cookieMaxAge ?? 604800;
106
+ const payload = JSON.stringify({ expiresAt: Date.now() + maxAge * 1e3 });
107
+ const { signature } = await signHmac(payload, expectedPassphrase);
108
+ const cookieValue = encodeBase64Url(new TextEncoder().encode(payload)) + "." + encodeBase64Url(new Uint8Array(signature));
109
+ try {
110
+ setCookie(c, cookieName, cookieValue, {
111
+ httpOnly: true,
112
+ secure: true,
113
+ sameSite: "Lax",
114
+ path: "/",
115
+ ...rememberMe ? { maxAge } : {}
116
+ });
117
+ } catch {
118
+ if (c.cookies?.set) c.cookies.set(cookieName, cookieValue, {
119
+ httpOnly: true,
120
+ secure: true,
121
+ sameSite: "lax",
122
+ path: "/",
123
+ ...rememberMe ? { maxAge } : {}
124
+ });
125
+ }
126
+ return true;
127
+ };
128
+ const resolveLocale = (path, options) => {
129
+ if (options?.getLocale) return options.getLocale(path);
130
+ if (options?.locales && options.locales.length > 0) {
131
+ const matched = options.locales.find((loc) => path === `/${loc}` || path.startsWith(`/${loc}/`));
132
+ if (matched) return matched;
133
+ }
134
+ return path.match(/^\/([a-zA-Z]{2}(?:-[a-zA-Z]{2})?)(\/|$)/)?.[1]?.toLowerCase() ?? "en";
135
+ };
136
+ /**
137
+ * Universal Construction Mode Middleware for Hono and Astro.
138
+ *
139
+ * Intercepts unauthenticated traffic when CONSTRUCTION_MODE is active, handles POST requests to the
140
+ * guest auth endpoint automatically, and passes authenticated requests or whitelisted assets
141
+ * cleanly.
142
+ */
143
+ function construction(options = {}) {
144
+ const apiPath = options.apiPath !== void 0 ? options.apiPath : "/api/construction-guest";
145
+ const constructionPath = options.constructionPath ?? "/construction";
146
+ return async (c, next) => {
147
+ const { path, method, search, redirect, json } = getContextHelpers(c);
148
+ if (apiPath && path === apiPath && method === "POST") try {
149
+ let body = {};
150
+ if (typeof c.req?.json === "function") body = await c.req.json().catch(() => ({}));
151
+ else if (c.request) body = await c.request.json().catch(() => ({}));
152
+ if (!await signInConstructionGuest(c, body?.passphrase ?? "", body?.rememberMe ?? true, options)) return json({ error: "Invalid credentials" }, 401);
153
+ return json({ success: true });
154
+ } catch (err) {
155
+ return json({ error: err?.message || "Authentication failed" }, 400);
156
+ }
157
+ const enabled = isModeEnabled(c, options);
158
+ const locale = resolveLocale(path, options);
159
+ const isConstructionPage = path === constructionPath || path === `/${locale}${constructionPath}` || path.endsWith(constructionPath);
160
+ const isAuthorized = async () => {
161
+ if (options.isAuthorized) {
162
+ if (await options.isAuthorized(c)) return true;
163
+ }
164
+ if (c.get?.("session") || c.get?.("user") || c.locals?.session || c.locals?.user) return true;
165
+ return await isConstructionGuest(c, options);
166
+ };
167
+ if (isConstructionPage) {
168
+ if (!enabled || await isAuthorized()) return redirect(`/${locale}`);
169
+ return next();
170
+ }
171
+ if (!enabled) return next();
172
+ if (DEFAULT_WHITELISTED_PATTERNS.some((pattern) => path.includes(pattern))) return next();
173
+ if (options.whitelist) {
174
+ if (typeof options.whitelist === "function") {
175
+ if (options.whitelist(path)) return next();
176
+ } else if (Array.isArray(options.whitelist)) {
177
+ if (options.whitelist.some((pattern) => typeof pattern === "string" ? path.includes(pattern) : pattern.test(path))) return next();
178
+ }
179
+ }
180
+ if (await isAuthorized()) return next();
181
+ const redirectTo = encodeURIComponent(path + search);
182
+ const targetUrl = `/${locale}${constructionPath}?redirect=${redirectTo}`;
183
+ if (options.onUnauthorized) return options.onUnauthorized(c, {
184
+ locale,
185
+ path,
186
+ redirectUrl: targetUrl
187
+ });
188
+ return redirect(targetUrl);
189
+ };
190
+ }
191
+ //#endregion
192
+ export { CONSTRUCTION_GUEST_COOKIE, construction, isConstructionGuest, signInConstructionGuest };
@@ -1,3 +1,4 @@
1
+ import { CONSTRUCTION_GUEST_COOKIE, construction, isConstructionGuest, signInConstructionGuest } from "./construction.mjs";
1
2
  import { devOnly } from "./dev-only.mjs";
2
3
  import { security } from "./security.mjs";
3
- export { devOnly, security };
4
+ export { CONSTRUCTION_GUEST_COOKIE, construction, devOnly, isConstructionGuest, security, signInConstructionGuest };
@@ -1,3 +1,4 @@
1
+ import { CONSTRUCTION_GUEST_COOKIE, construction, isConstructionGuest, signInConstructionGuest } from "./construction.mjs";
1
2
  import { devOnly } from "./dev-only.mjs";
2
3
  import { security } from "./security.mjs";
3
- export { devOnly, security };
4
+ export { CONSTRUCTION_GUEST_COOKIE, construction, devOnly, isConstructionGuest, security, signInConstructionGuest };
@@ -1,4 +1,4 @@
1
- import { t as SecurityOptions } from "../types-t69O3m_O.mjs";
1
+ import { n as SecurityOptions } from "../types-BdTXMZBc.mjs";
2
2
  //#region src/middleware/security.d.ts
3
3
  /**
4
4
  * Universal Security Middleware for Hono and Web Standards (Fetch API). Injects security headers,
@@ -59,5 +59,63 @@ interface SecurityOptions {
59
59
  */
60
60
  headers?: Record<string, string>;
61
61
  }
62
+ interface ConstructionOptions {
63
+ /**
64
+ * Environment variable key for construction mode flag ("true" | "false"). Defaults to
65
+ * "CONSTRUCTION_MODE".
66
+ */
67
+ modeEnvKey?: string;
68
+ /**
69
+ * Environment variable key for the passphrase secret. Defaults to "CONSTRUCTION_PASSPHRASE".
70
+ */
71
+ passphraseEnvKey?: string;
72
+ /**
73
+ * Explicit passphrase override (if not reading from env).
74
+ */
75
+ passphrase?: string;
76
+ /**
77
+ * Cookie name used for guest authorization token. Defaults to "rimelight-construction-guest".
78
+ */
79
+ cookieName?: string;
80
+ /**
81
+ * Cookie max age in seconds when "remember me" is enabled. Defaults to 7 days (604,800 seconds).
82
+ */
83
+ cookieMaxAge?: number;
84
+ /**
85
+ * List of supported locales or custom resolver.
86
+ */
87
+ locales?: string[];
88
+ /**
89
+ * Custom locale extractor from request path.
90
+ */
91
+ getLocale?: (path: string) => string;
92
+ /**
93
+ * Custom path for the construction page. Defaults to "/construction".
94
+ */
95
+ constructionPath?: string;
96
+ /**
97
+ * API endpoint to automatically handle guest sign-in. Set to `false` to disable automatic API
98
+ * interception. Defaults to "/api/construction-guest".
99
+ */
100
+ apiPath?: string | false;
101
+ /**
102
+ * Additional routes / prefixes to whitelist and skip gating.
103
+ */
104
+ whitelist?: (string | RegExp)[] | ((path: string) => boolean);
105
+ /**
106
+ * Custom authorization predicate. If returns true, request is allowed through. Defaults to
107
+ * checking `c.get("session") || await isConstructionGuest(c, options)`.
108
+ */
109
+ isAuthorized?: (c: any) => boolean | Promise<boolean>;
110
+ /**
111
+ * Custom handler when unauthorized. Defaults to redirecting to
112
+ * `/${locale}/construction?redirect=${encodeURIComponent(path)}`.
113
+ */
114
+ onUnauthorized?: (c: any, meta: {
115
+ locale: string;
116
+ path: string;
117
+ redirectUrl: string;
118
+ }) => Response | Promise<Response>;
119
+ }
62
120
  //#endregion
63
- export { SecurityOptions as t };
121
+ export { SecurityOptions as n, ConstructionOptions as t };
package/dist/types.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { t as SecurityOptions } from "./types-t69O3m_O.mjs";
2
- export { SecurityOptions };
1
+ import { n as SecurityOptions, t as ConstructionOptions } from "./types-BdTXMZBc.mjs";
2
+ export { ConstructionOptions, SecurityOptions };
package/dist/vite.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SecurityOptions } from "./types-t69O3m_O.mjs";
1
+ import { n as SecurityOptions } from "./types-BdTXMZBc.mjs";
2
2
  //#region src/vite.d.ts
3
3
  export interface RimelightSecurityPlugin {
4
4
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/security",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment's Security Package",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -38,14 +38,24 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
+ "dependencies": {
42
+ "@rimelight/ui": "0.0.51"
43
+ },
41
44
  "devDependencies": {
42
45
  "@astrojs/check": "0.9.10",
43
- "@rimelight/config": "0.0.7",
46
+ "@rimelight/config": "0.0.9",
44
47
  "astro": "7.3.2",
48
+ "hono": "4.12.3",
45
49
  "typescript": "6.0.3"
46
50
  },
47
51
  "peerDependencies": {
48
- "astro": ">=7.0.0"
52
+ "astro": ">=7.0.0",
53
+ "hono": ">=4.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "hono": {
57
+ "optional": true
58
+ }
49
59
  },
50
60
  "engines": {
51
61
  "node": ">=26.8.2"
@@ -0,0 +1,148 @@
1
+ ---
2
+ import RLACard from "@rimelight/ui/components/card/RLACard.astro"
3
+ import RLAFormField from "@rimelight/ui/components/form-field/RLAFormField.astro"
4
+ import RLAInput from "@rimelight/ui/components/input/RLAInput.astro"
5
+ import RLAButton from "@rimelight/ui/components/button/RLAButton.astro"
6
+ import RLACheckbox from "@rimelight/ui/components/checkbox/RLACheckbox.astro"
7
+
8
+ interface Props {
9
+ allowAuth?: boolean
10
+ endpoint?: string
11
+ passphraseLabel?: string
12
+ passphrasePlaceholder?: string
13
+ rememberMeLabel?: string
14
+ signInButtonLabel?: string
15
+ loadingButtonLabel?: string
16
+ returnLaterText?: string
17
+ class?: string
18
+ }
19
+
20
+ const {
21
+ allowAuth = true,
22
+ endpoint = "/api/construction-guest",
23
+ passphraseLabel = "Passphrase",
24
+ passphrasePlaceholder = "Enter passphrase...",
25
+ rememberMeLabel = "Remember me",
26
+ signInButtonLabel = "Sign In",
27
+ loadingButtonLabel = "Signing In...",
28
+ returnLaterText = "Please return at a later stage.",
29
+ class: className = ""
30
+ } = Astro.props
31
+ ---
32
+
33
+ {
34
+ allowAuth ? (
35
+ <RLACard class={`bg-black text-left w-full max-w-md ${className}`}>
36
+ <slot name="header" />
37
+ <form id="construction-sign-in-form" data-endpoint={endpoint} class="flex flex-col gap-4">
38
+ <p id="construction-error-message" class="text-red-500 text-sm hidden" role="alert" />
39
+
40
+ <div class="flex flex-col gap-4">
41
+ <RLAFormField
42
+ label={passphraseLabel}
43
+ required
44
+ ui={{
45
+ label: "text-white",
46
+ description: "text-neutral-500"
47
+ }}
48
+ >
49
+ <RLAInput
50
+ id="construction-passphrase-input"
51
+ type="password"
52
+ name="passphrase"
53
+ placeholder={passphrasePlaceholder}
54
+ class="w-full text-white"
55
+ required
56
+ autocomplete="current-password"
57
+ />
58
+ </RLAFormField>
59
+
60
+ <RLACheckbox
61
+ id="construction-remember-me-checkbox"
62
+ name="rememberMe"
63
+ label={rememberMeLabel}
64
+ checked
65
+ ui={{
66
+ label: "text-white",
67
+ indicator: "data-[state=checked]:bg-primary-500 data-[state=checked]:text-white"
68
+ }}
69
+ />
70
+
71
+ <RLAButton
72
+ id="construction-submit-button"
73
+ type="submit"
74
+ color="primary"
75
+ variant="solid"
76
+ block
77
+ class="text-white bg-primary-500 hover:bg-primary-600 w-full"
78
+ data-label={signInButtonLabel}
79
+ data-loading-label={loadingButtonLabel}
80
+ >
81
+ {signInButtonLabel}
82
+ </RLAButton>
83
+ </div>
84
+ </form>
85
+ <slot name="footer" />
86
+ </RLACard>
87
+ ) : (
88
+ <p class="text-neutral-400">{returnLaterText}</p>
89
+ )
90
+ }
91
+
92
+ <script>
93
+ function getRedirectParam() {
94
+ const params = new URLSearchParams(window.location.search)
95
+ return params.get("redirect") || "/"
96
+ }
97
+
98
+ const form = document.getElementById("construction-sign-in-form") as HTMLFormElement | null
99
+ const passphraseInput = document.getElementById("construction-passphrase-input") as HTMLInputElement | null
100
+ const rememberCheckbox = document.getElementById("construction-remember-me-checkbox") as HTMLInputElement | null
101
+ const errorMessage = document.getElementById("construction-error-message")
102
+ const submitButton = document.getElementById("construction-submit-button")
103
+
104
+ if (form) {
105
+ form.addEventListener("submit", async (event) => {
106
+ event.preventDefault()
107
+ if (!passphraseInput || !submitButton || !errorMessage) return
108
+
109
+ const endpoint = form.getAttribute("data-endpoint") || "/api/construction-guest"
110
+ const defaultLabel = submitButton.getAttribute("data-label") || "Sign In"
111
+ const loadingLabel = submitButton.getAttribute("data-loading-label") || "Signing In..."
112
+
113
+ // Set loading state
114
+ submitButton.setAttribute("disabled", "true")
115
+ submitButton.textContent = loadingLabel
116
+ errorMessage.classList.add("hidden")
117
+ errorMessage.textContent = ""
118
+
119
+ try {
120
+ const response = await fetch(endpoint, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({
124
+ passphrase: passphraseInput.value,
125
+ rememberMe: rememberCheckbox ? rememberCheckbox.checked : true
126
+ })
127
+ })
128
+
129
+ const data = (await response.json().catch(() => ({}))) as { error?: string; success?: boolean }
130
+
131
+ if (!response.ok || !data.success) {
132
+ errorMessage.textContent = data?.error || "Invalid credentials"
133
+ errorMessage.classList.remove("hidden")
134
+ submitButton.removeAttribute("disabled")
135
+ submitButton.textContent = defaultLabel
136
+ return
137
+ }
138
+
139
+ window.location.href = getRedirectParam()
140
+ } catch (err: any) {
141
+ errorMessage.textContent = err?.message || "An unexpected error occurred."
142
+ errorMessage.classList.remove("hidden")
143
+ submitButton.removeAttribute("disabled")
144
+ submitButton.textContent = defaultLabel
145
+ }
146
+ })
147
+ }
148
+ </script>