972 lines
44 KiB
Diff
972 lines
44 KiB
Diff
--- a/examples/auth-showcase/app/api/auth/login.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/login.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../lib/auth.ts";
|
|
-export default handlers.login;
|
|
+export const POST = handlers.login;
|
|
--- a/examples/auth-showcase/app/api/auth/logout.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/logout.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../lib/auth.ts";
|
|
-export default handlers.logout;
|
|
+export const POST = handlers.logout;
|
|
--- a/examples/auth-showcase/app/api/auth/mfa/complete.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/mfa/complete.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.completeMfa;
|
|
+export const POST = handlers.completeMfa;
|
|
--- a/examples/auth-showcase/app/api/auth/mfa/otp.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/mfa/otp.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.beginMfaOtp;
|
|
+export const POST = handlers.beginMfaOtp;
|
|
--- a/examples/auth-showcase/app/api/auth/otp/issue.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/otp/issue.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.issueOtp;
|
|
+export const POST = handlers.issueOtp;
|
|
--- a/examples/auth-showcase/app/api/auth/otp/verify.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/otp/verify.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.verifyOtp;
|
|
+export const POST = handlers.verifyOtp;
|
|
--- a/examples/auth-showcase/app/api/auth/password/request.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/password/request.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.requestPasswordReset;
|
|
+export const POST = handlers.requestPasswordReset;
|
|
--- a/examples/auth-showcase/app/api/auth/password/reset.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/password/reset.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.resetPassword;
|
|
+export const POST = handlers.resetPassword;
|
|
--- a/examples/auth-showcase/app/api/auth/register.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/register.ts
|
|
@@ -1,2 +1,13 @@
|
|
+import type { Context } from "@wrnexus/core";
|
|
+import { signUpSchema } from "@wrnexus/auth";
|
|
+import { parseBody } from "@wrnexus/validation";
|
|
import { handlers } from "../../lib/auth.ts";
|
|
-export default handlers.register;
|
|
+
|
|
+export async function POST(ctx: Context): Promise<Response> {
|
|
+ // Validate the same schema used by <SignUp data-schema="auth-register">.
|
|
+ // Clone the request because the shared auth handler also reads the body using
|
|
+ // the generic package registration schema.
|
|
+ const validation = await parseBody(signUpSchema, ctx.req.clone());
|
|
+ if (!validation.ok) return validation.response;
|
|
+ return handlers.register(ctx);
|
|
+}
|
|
--- a/examples/auth-showcase/app/api/auth/sessions/index.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/sessions/index.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.sessions;
|
|
+export const GET = handlers.sessions;
|
|
--- a/examples/auth-showcase/app/api/auth/sessions/revoke.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/sessions/revoke.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.revokeSession;
|
|
+export const POST = handlers.revokeSession;
|
|
--- a/examples/auth-showcase/app/api/auth/verify/email.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/verify/email.ts
|
|
@@ -1,2 +1,3 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.verifyEmail;
|
|
+export const GET = handlers.verifyEmail;
|
|
+export const POST = handlers.verifyEmail;
|
|
--- a/examples/auth-showcase/app/api/auth/verify/phone.ts
|
|
+++ b/examples/auth-showcase/app/api/auth/verify/phone.ts
|
|
@@ -1,2 +1,2 @@
|
|
import { handlers } from "../../../lib/auth.ts";
|
|
-export default handlers.verifyPhone;
|
|
+export const POST = handlers.verifyPhone;
|
|
--- a/examples/auth-showcase/app/pages/sign-up.wrn
|
|
+++ b/examples/auth-showcase/app/pages/sign-up.wrn
|
|
@@ -1,4 +1,8 @@
|
|
page SignUpPage {
|
|
seo { title = "Create account" }
|
|
- view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><SignUp action="/api/auth/register" /></main> }
|
|
+ view {
|
|
+ <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4">
|
|
+ <SignUp action="/api/auth/register" schema="auth-register" redirect="/sign-in" />
|
|
+ </main>
|
|
+ }
|
|
}
|
|
--- /dev/null
|
|
+++ b/examples/auth-showcase/app/schemas/auth-register.ts
|
|
@@ -0,0 +1,3 @@
|
|
+import { signUpSchema } from "@wrnexus/auth";
|
|
+
|
|
+export default signUpSchema;
|
|
--- a/examples/auth-showcase/package.json
|
|
+++ b/examples/auth-showcase/package.json
|
|
@@ -12,7 +12,8 @@
|
|
},
|
|
"dependencies": {
|
|
"@wrnexus/auth": "workspace:*",
|
|
- "@wrnexus/core": "workspace:*"
|
|
+ "@wrnexus/core": "workspace:*",
|
|
+ "@wrnexus/validation": "workspace:*"
|
|
},
|
|
"devDependencies": {
|
|
"@iconify-json/lucide": "^1.2.0",
|
|
--- a/examples/auth-showcase/test/showcase.test.ts
|
|
+++ b/examples/auth-showcase/test/showcase.test.ts
|
|
@@ -33,3 +33,15 @@
|
|
expect(readFileSync(join(root, "app", "pages", "magic-link.wrn"), "utf8")).toContain("MagicLinkSignIn");
|
|
expect(readFileSync(join(root, "app", "pages", "impersonation.wrn"), "utf8")).toContain("ImpersonationBanner");
|
|
});
|
|
+
|
|
+
|
|
+test("registration uses one @wrnexus/validation schema on the client and server", () => {
|
|
+ const schema = readFileSync(join(root, "app", "schemas", "auth-register.ts"), "utf8");
|
|
+ const route = readFileSync(join(root, "app", "api", "auth", "register.ts"), "utf8");
|
|
+ const page = readFileSync(join(root, "app", "pages", "sign-up.wrn"), "utf8");
|
|
+
|
|
+ expect(schema).toContain("signUpSchema");
|
|
+ expect(route).toContain("parseBody(signUpSchema");
|
|
+ expect(route).toContain("export async function POST");
|
|
+ expect(page).toContain('schema="auth-register"');
|
|
+});
|
|
--- a/examples/auth-showcase/wrnexus.config.ts
|
|
+++ b/examples/auth-showcase/wrnexus.config.ts
|
|
@@ -1,7 +1,9 @@
|
|
+import { authPlugin } from "@wrnexus/auth/plugin";
|
|
import type { AppConfig } from "@wrnexus/styles";
|
|
import { auth } from "./app/lib/auth.ts";
|
|
|
|
const config: AppConfig & { auth: { engine: typeof auth } } = {
|
|
+ plugins: [authPlugin({ includeMigrations: false })],
|
|
seo: {
|
|
title: "WRNexus Auth Showcase",
|
|
description: "Complete authentication, MFA, passkey, recovery, session, and security examples.",
|
|
--- a/packages/auth/README.md
|
|
+++ b/packages/auth/README.md
|
|
@@ -203,10 +203,36 @@
|
|
|
|
## Validation
|
|
|
|
-Reusable schemas are exported for registration, login, reset, OTP, MFA, invitation acceptance, magic links, and password changes.
|
|
+Reusable `@wrnexus/validation` schemas are exported for registration, sign-up forms, login, reset, OTP, MFA, invitation acceptance, magic links, and password changes. The HTTP handlers validate request bodies before calling the auth engine.
|
|
+
|
|
+Expose the same schema to the browser through the application's conventional `app/schemas/` directory:
|
|
+
|
|
+```ts
|
|
+// app/schemas/auth-register.ts
|
|
+import { signUpSchema } from "@wrnexus/auth";
|
|
+
|
|
+export default signUpSchema;
|
|
+```
|
|
+
|
|
+Then use the matching schema name on the packaged component:
|
|
+
|
|
+```wrn
|
|
+<SignUp schema="auth-register" action="/api/auth/register" />
|
|
+```
|
|
+
|
|
+An application-owned route can enforce the exact same form schema before delegating to the shared handler:
|
|
|
|
```ts
|
|
-import { loginSchema, invitationAcceptSchema } from "@wrnexus/auth";
|
|
+import type { Context } from "@wrnexus/core";
|
|
+import { signUpSchema } from "@wrnexus/auth";
|
|
+import { parseBody } from "@wrnexus/validation";
|
|
+import { handlers } from "../../lib/auth.ts";
|
|
+
|
|
+export async function POST(ctx: Context): Promise<Response> {
|
|
+ const validation = await parseBody(signUpSchema, ctx.req.clone());
|
|
+ if (!validation.ok) return validation.response;
|
|
+ return handlers.register(ctx);
|
|
+}
|
|
```
|
|
|
|
## Development
|
|
--- a/packages/auth/components/SignUp.wrn
|
|
+++ b/packages/auth/components/SignUp.wrn
|
|
@@ -1,6 +1,8 @@
|
|
component SignUp {
|
|
props {
|
|
action = "/api/auth/register"
|
|
+ schema = "auth-register"
|
|
+ redirect = ""
|
|
title = "Create your account"
|
|
description = "Use a strong password and verify your contact details."
|
|
submitLabel = "Create account"
|
|
@@ -14,17 +16,71 @@
|
|
}
|
|
view {
|
|
<section {...attrs} class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
|
|
- <header class="mb-6 space-y-1.5"><h1 class="m-0 text-2xl font-semibold">{title}</h1><p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p></header>
|
|
- <form method="post" action='{action}' class="grid gap-4 sm:grid-cols-2">
|
|
- <label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Full name</span><input name="displayName" autocomplete="name" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>
|
|
- <label class="space-y-1.5"><span class="text-sm font-medium">Email</span><input name="email" type="email" autocomplete="email" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>
|
|
- {#if showPhone}<label class="space-y-1.5"><span class="text-sm font-medium">Phone</span><input name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>{/if}
|
|
- {#if showUsername}<label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Username</span><input name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>{/if}
|
|
- <label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Password</span><input name="password" type="password" autocomplete="new-password" minlength="12" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><span class="text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</span></label>
|
|
- {#if requireConsent}<label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)] sm:col-span-2"><input name="consent" type="checkbox" required class="mt-0.5 size-4" /><span>I agree to the terms and privacy policy.</span></label>{/if}
|
|
+ <header class="mb-6 space-y-1.5">
|
|
+ <h1 class="m-0 text-2xl font-semibold">{title}</h1>
|
|
+ <p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
|
+ </header>
|
|
+
|
|
+ <form
|
|
+ method="post"
|
|
+ action='{action}'
|
|
+ data-schema='{schema}'
|
|
+ data-redirect='{redirect}'
|
|
+ class="grid gap-4 sm:grid-cols-2"
|
|
+ >
|
|
+ <div class="space-y-1.5 sm:col-span-2">
|
|
+ <label for="auth-sign-up-name" class="text-sm font-medium">Full name</label>
|
|
+ <input id="auth-sign-up-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
|
|
+ <p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+
|
|
+ <div class="space-y-1.5">
|
|
+ <label for="auth-sign-up-email" class="text-sm font-medium">Email</label>
|
|
+ <input id="auth-sign-up-email" name="email" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
|
|
+ <p data-error="email" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+
|
|
+ {#if showPhone}
|
|
+ <div class="space-y-1.5">
|
|
+ <label for="auth-sign-up-phone" class="text-sm font-medium">Phone</label>
|
|
+ <input id="auth-sign-up-phone" name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
|
|
+ <p data-error="phone" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+ {/if}
|
|
+
|
|
+ {#if showUsername}
|
|
+ <div class="space-y-1.5 sm:col-span-2">
|
|
+ <label for="auth-sign-up-username" class="text-sm font-medium">Username</label>
|
|
+ <input id="auth-sign-up-username" name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
|
|
+ <p data-error="username" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+ {/if}
|
|
+
|
|
+ <div class="space-y-1.5 sm:col-span-2">
|
|
+ <label for="auth-sign-up-password" class="text-sm font-medium">Password</label>
|
|
+ <input id="auth-sign-up-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
|
|
+ <p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
|
|
+ <p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+
|
|
+ {#if requireConsent}
|
|
+ <div class="space-y-1 sm:col-span-2">
|
|
+ <label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)]">
|
|
+ <input name="consent" type="checkbox" class="mt-0.5 size-4" />
|
|
+ <span>I agree to the terms and privacy policy.</span>
|
|
+ </label>
|
|
+ <p data-error="consent" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
|
+ </div>
|
|
+ {/if}
|
|
+
|
|
<div class="sm:col-span-2"><slot></slot></div>
|
|
- <button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2">{submitLabel}</button>
|
|
+
|
|
+ <p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"></p>
|
|
+ <p data-success="Account created successfully." role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"></p>
|
|
+
|
|
+ <button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2 disabled:cursor-not-allowed disabled:opacity-60">{submitLabel}</button>
|
|
</form>
|
|
+
|
|
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a></p>
|
|
</section>
|
|
}
|
|
--- a/packages/auth/src/http/index.ts
|
|
+++ b/packages/auth/src/http/index.ts
|
|
@@ -1,6 +1,18 @@
|
|
import type { Context } from "@wrnexus/core";
|
|
+import { parseBody } from "@wrnexus/validation";
|
|
import type { AuthEngine } from "../engine.ts";
|
|
import { clearAuthSession, establishAuthSession, getAuthSession, getAuthUser } from "../middleware.ts";
|
|
+import {
|
|
+ invitationAcceptSchema,
|
|
+ loginSchema,
|
|
+ magicLinkRequestSchema,
|
|
+ mfaSchema,
|
|
+ otpLoginRequestSchema,
|
|
+ otpSchema,
|
|
+ passwordResetRequestSchema,
|
|
+ passwordResetSchema,
|
|
+ registerSchema,
|
|
+} from "../validation.ts";
|
|
|
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
const type = request.headers.get("content-type") ?? "";
|
|
@@ -34,7 +46,9 @@
|
|
const engine = options.engine;
|
|
return {
|
|
async register(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(registerSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const result = await engine.register({
|
|
email: text(input.email) || undefined,
|
|
phone: text(input.phone) || undefined,
|
|
@@ -48,7 +62,9 @@
|
|
},
|
|
|
|
async login(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(loginSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const result = await engine.login({
|
|
identifier: text(input.identifier),
|
|
password: text(input.password),
|
|
@@ -87,19 +103,25 @@
|
|
},
|
|
|
|
async requestPasswordReset(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(passwordResetRequestSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
await engine.requestPasswordReset(text(input.identifier), options.baseUrl ?? ctx.url.origin);
|
|
return json({ ok: true });
|
|
},
|
|
|
|
async resetPassword(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(passwordResetSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const result = await engine.resetPassword(text(input.token), text(input.password));
|
|
return json(result, result.ok ? 200 : 400);
|
|
},
|
|
|
|
async acceptInvitation(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(invitationAcceptSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const result = await engine.acceptInvitation(text(input.token), {
|
|
password: text(input.password) || undefined,
|
|
displayName: text(input.displayName) || undefined,
|
|
@@ -108,7 +130,9 @@
|
|
},
|
|
|
|
async requestMagicLink(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(magicLinkRequestSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
await engine.requestMagicLink(text(input.identifier), options.baseUrl ?? ctx.url.origin);
|
|
return json({ ok: true });
|
|
},
|
|
@@ -138,7 +162,9 @@
|
|
},
|
|
|
|
async requestOtpLogin(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(otpLoginRequestSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
|
|
const challenge = await engine.requestOtpLogin(text(input.identifier), method);
|
|
return json({ ok: true, challenge: challenge ?? null });
|
|
@@ -165,7 +191,9 @@
|
|
},
|
|
|
|
async verifyOtp(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(otpSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const result = await engine.verifyOtp(text(input.challengeId), text(input.code));
|
|
return json(result, result.ok ? 200 : 400);
|
|
},
|
|
@@ -178,7 +206,9 @@
|
|
},
|
|
|
|
async completeMfa(ctx: Context): Promise<Response> {
|
|
- const input = await body(ctx.req);
|
|
+ const validation = await parseBody(mfaSchema, ctx.req);
|
|
+ if (!validation.ok) return validation.response;
|
|
+ const input = validation.value;
|
|
const methodValue = text(input.method);
|
|
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
|
|
? methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp"
|
|
--- a/packages/auth/src/index.ts
|
|
+++ b/packages/auth/src/index.ts
|
|
@@ -22,4 +22,4 @@
|
|
export { MemoryPasskeyChallengeStore, assertPasskeyProvider } from "./passkeys/index.ts";
|
|
export * from "./types.ts";
|
|
|
|
-export { registerSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "./validation.ts";
|
|
+export { registerSchema, signUpSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "./validation.ts";
|
|
--- a/packages/auth/src/routes/api.ts
|
|
+++ b/packages/auth/src/routes/api.ts
|
|
@@ -55,3 +55,8 @@
|
|
|
|
return Response.json({ ok: false, error: "Not Found" }, { status: 404 });
|
|
}
|
|
+
|
|
+// Package-contributed API entries are loaded by HTTP method. Export both
|
|
+// supported methods while retaining the default export for compatibility.
|
|
+export const GET = authApi;
|
|
+export const POST = authApi;
|
|
--- a/packages/auth/src/server/index.ts
|
|
+++ b/packages/auth/src/server/index.ts
|
|
@@ -15,6 +15,6 @@
|
|
export { SqlAuthStore } from "../stores/sql.ts";
|
|
export * from "../types.ts";
|
|
|
|
-export { registerSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "../validation.ts";
|
|
+export { registerSchema, signUpSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "../validation.ts";
|
|
|
|
export { createAuthSecretProtector } from "../protector.ts";
|
|
--- a/packages/auth/src/validation.ts
|
|
+++ b/packages/auth/src/validation.ts
|
|
@@ -1,11 +1,46 @@
|
|
import { v } from "@wrnexus/validation";
|
|
|
|
+const strongPassword = () =>
|
|
+ v
|
|
+ .string()
|
|
+ .min(12, "Password must be at least 12 characters")
|
|
+ .max(256, "Password must be at most 256 characters")
|
|
+ .pattern(
|
|
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
|
|
+ "Password must include uppercase, lowercase, and a number",
|
|
+ );
|
|
+
|
|
+/** Generic registration API schema. At least one identity is enforced by the auth engine. */
|
|
export const registerSchema = v.object({
|
|
displayName: v.string().trim().min(2).max(120),
|
|
email: v.string().trim().email().optional(),
|
|
phone: v.string().trim().min(7).max(24).optional(),
|
|
- username: v.string().trim().min(3).max(64).pattern(/^[a-zA-Z0-9._-]+$/).optional(),
|
|
- password: v.string().min(12).max(256),
|
|
+ username: v
|
|
+ .string()
|
|
+ .trim()
|
|
+ .min(3)
|
|
+ .max(64)
|
|
+ .pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
|
|
+ .optional(),
|
|
+ password: strongPassword(),
|
|
+ locale: v.string().max(32).optional(),
|
|
+ timezone: v.string().max(64).optional(),
|
|
+});
|
|
+
|
|
+/** Browser sign-up form schema used by the packaged SignUp component. */
|
|
+export const signUpSchema = v.object({
|
|
+ displayName: v.string().trim().min(2, "Enter your full name").max(120),
|
|
+ email: v.string().trim().email("Enter a valid email address"),
|
|
+ phone: v.string().trim().min(7).max(24).optional(),
|
|
+ username: v
|
|
+ .string()
|
|
+ .trim()
|
|
+ .min(3)
|
|
+ .max(64)
|
|
+ .pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
|
|
+ .optional(),
|
|
+ password: strongPassword(),
|
|
+ consent: v.boolean().required("Accept the terms and privacy policy to continue"),
|
|
locale: v.string().max(32).optional(),
|
|
timezone: v.string().max(64).optional(),
|
|
});
|
|
@@ -25,7 +60,7 @@
|
|
|
|
export const passwordResetSchema = v.object({
|
|
token: v.string().min(20).max(512),
|
|
- password: v.string().min(12).max(256),
|
|
+ password: strongPassword(),
|
|
});
|
|
|
|
export const otpSchema = v.object({
|
|
@@ -38,6 +73,7 @@
|
|
method: v.string().oneOf(["totp", "recovery-code", "email-otp", "sms-otp"]),
|
|
challengeId: v.string().max(191).optional(),
|
|
code: v.string().trim().min(6).max(32),
|
|
+ returnTo: v.string().max(2048).optional(),
|
|
});
|
|
|
|
export const otpLoginRequestSchema = v.object({
|
|
@@ -52,10 +88,10 @@
|
|
export const invitationAcceptSchema = v.object({
|
|
token: v.string().min(20).max(512),
|
|
displayName: v.string().trim().min(2).max(120).optional(),
|
|
- password: v.string().min(12).max(256).optional(),
|
|
+ password: strongPassword().optional(),
|
|
});
|
|
|
|
export const changePasswordSchema = v.object({
|
|
currentPassword: v.string().min(1).max(256),
|
|
- nextPassword: v.string().min(12).max(256),
|
|
+ nextPassword: strongPassword(),
|
|
});
|
|
--- a/packages/auth/test/http.test.ts
|
|
+++ b/packages/auth/test/http.test.ts
|
|
@@ -69,3 +69,34 @@
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({ ok: true });
|
|
});
|
|
+
|
|
+
|
|
+test("register handler returns validation field errors before calling the engine", async () => {
|
|
+ let registerCalls = 0;
|
|
+ const engine = {
|
|
+ register: async () => {
|
|
+ registerCalls += 1;
|
|
+ return { ok: true };
|
|
+ },
|
|
+ } as unknown as ReturnType<typeof createAuthEngine>;
|
|
+ const handlers = createAuthHttpHandlers({ engine });
|
|
+ const ctx = context(
|
|
+ new Request("https://example.test/api/auth/register", {
|
|
+ method: "POST",
|
|
+ headers: { "content-type": "application/json" },
|
|
+ body: JSON.stringify({ displayName: "A", email: "bad", password: "short" }),
|
|
+ }),
|
|
+ );
|
|
+
|
|
+ const response = await handlers.register(ctx);
|
|
+ expect(response.status).toBe(400);
|
|
+ expect(await response.json()).toMatchObject({
|
|
+ ok: false,
|
|
+ errors: {
|
|
+ displayName: expect.any(String),
|
|
+ email: expect.any(String),
|
|
+ password: expect.any(String),
|
|
+ },
|
|
+ });
|
|
+ expect(registerCalls).toBe(0);
|
|
+});
|
|
--- a/packages/auth/test/validation.test.ts
|
|
+++ b/packages/auth/test/validation.test.ts
|
|
@@ -5,6 +5,7 @@
|
|
mfaSchema,
|
|
otpLoginRequestSchema,
|
|
registerSchema,
|
|
+ signUpSchema,
|
|
} from "../src/validation.ts";
|
|
|
|
test("authentication schemas reject malformed input", () => {
|
|
@@ -14,3 +15,28 @@
|
|
expect(invitationAcceptSchema.parse({ token: "short" }).ok).toBe(false);
|
|
expect(mfaSchema.parse({ mfaToken: "short", method: "unknown", code: "1" }).ok).toBe(false);
|
|
});
|
|
+
|
|
+
|
|
+test("sign-up schema is shared by browser and server registration", () => {
|
|
+ const invalid = signUpSchema.parse({
|
|
+ displayName: "A",
|
|
+ email: "bad",
|
|
+ password: "weak",
|
|
+ consent: false,
|
|
+ });
|
|
+ expect(invalid.ok).toBe(false);
|
|
+ expect(invalid.errors).toMatchObject({
|
|
+ displayName: "Enter your full name",
|
|
+ email: "Enter a valid email address",
|
|
+ consent: "Accept the terms and privacy policy to continue",
|
|
+ });
|
|
+
|
|
+ expect(
|
|
+ signUpSchema.parse({
|
|
+ displayName: "Ada Lovelace",
|
|
+ email: "ada@example.com",
|
|
+ password: "StrongPassword123",
|
|
+ consent: true,
|
|
+ }).ok,
|
|
+ ).toBe(true);
|
|
+});
|
|
--- a/packages/validation/src/runtime.ts
|
|
+++ b/packages/validation/src/runtime.ts
|
|
@@ -1,178 +1,205 @@
|
|
-/**
|
|
- * Client-side validation. `renderSchemasScript` bakes the discovered schema
|
|
- * descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
|
|
- * eval-free validator that reads them and validates every `form[data-schema]`
|
|
- * on submit and blur, writing messages into `[data-error="<field>"]` elements.
|
|
- * The rule logic mirrors `checkField`/`applyRule` in index.ts.
|
|
- */
|
|
-
|
|
-import type { SchemaDescriptor } from "./index.ts";
|
|
-
|
|
-/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
|
|
-export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
|
|
- return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
|
|
-}
|
|
-
|
|
-export const VALIDATE_RUNTIME = String.raw`
|
|
-(function () {
|
|
- var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
- var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
|
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
-
|
|
- function applyRule(type, r, value) {
|
|
- if (r.kind === "min") {
|
|
- if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
|
|
- return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
|
|
- }
|
|
- if (r.kind === "max") {
|
|
- if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
|
|
- return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
|
|
- }
|
|
- if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
|
|
- if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
|
|
- if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
|
|
- if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
|
|
- if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
|
|
- if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
|
|
- if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
|
|
- if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
|
|
- return null;
|
|
- }
|
|
-
|
|
- function checkField(desc, raw) {
|
|
- if (desc.type === "boolean") {
|
|
- var b = raw === true || raw === "true" || raw === "on";
|
|
- return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
|
- }
|
|
- var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
|
- var empty = pre === undefined || pre === null || pre === "";
|
|
- if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
|
|
- var value;
|
|
- if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
|
|
- else value = String(pre);
|
|
- for (var i = 0; i < desc.rules.length; i++) {
|
|
- var err = applyRule(desc.type, desc.rules[i], value);
|
|
- if (err) return err;
|
|
- }
|
|
- return null;
|
|
- }
|
|
-
|
|
- function rawValue(form, name) {
|
|
- var el = form.elements[name];
|
|
- if (!el) return undefined;
|
|
- return el.type === "checkbox" ? el.checked : el.value;
|
|
- }
|
|
-
|
|
- function showError(form, name, err) {
|
|
- var box = form.querySelector('[data-error="' + name + '"]');
|
|
- if (box) box.textContent = err || "";
|
|
- var el = form.elements[name];
|
|
- if (el && el.setAttribute) {
|
|
- if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
|
|
- else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
|
|
- }
|
|
- }
|
|
-
|
|
- function validateForm(form, schema) {
|
|
- var errors = 0;
|
|
- Object.keys(schema.fields).forEach(function (name) {
|
|
- var err = checkField(schema.fields[name], rawValue(form, name));
|
|
- if (err) errors++;
|
|
- showError(form, name, err);
|
|
- });
|
|
- return errors;
|
|
- }
|
|
-
|
|
- function collect(form) {
|
|
- var out = {};
|
|
- for (var i = 0; i < form.elements.length; i++) {
|
|
- var el = form.elements[i];
|
|
- if (!el.name) continue;
|
|
- if (el.type === "checkbox") out[el.name] = el.checked;
|
|
- else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
|
|
- else out[el.name] = el.value;
|
|
- }
|
|
- return out;
|
|
- }
|
|
-
|
|
- function onSuccess(form, data) {
|
|
- var redirect = form.getAttribute("data-redirect") || (data && data.redirect);
|
|
- if (redirect) {
|
|
- // Prefer client-side navigation (no full reload) when it is available.
|
|
- if (window.__wrnexusNavigate) window.__wrnexusNavigate(redirect);
|
|
- else location.assign(redirect);
|
|
- return;
|
|
- }
|
|
- var box = form.querySelector("[data-success]");
|
|
- if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
|
|
- form.reset();
|
|
- form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
|
|
- }
|
|
-
|
|
- function csrfHeader() {
|
|
- var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
|
|
- return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
|
|
- }
|
|
-
|
|
- function submitForm(form, schema) {
|
|
- var method = (form.getAttribute("method") || "post").toUpperCase();
|
|
- var action = form.getAttribute("action") || location.pathname;
|
|
- var btns = form.querySelectorAll("[type=submit]");
|
|
- btns.forEach(function (b) { b.disabled = true; });
|
|
- var headers = { "content-type": "application/json", accept: "application/json" };
|
|
- var csrf = csrfHeader();
|
|
- for (var k in csrf) headers[k] = csrf[k];
|
|
- fetch(action, {
|
|
- method: method,
|
|
- headers: headers,
|
|
- credentials: "same-origin",
|
|
- body: JSON.stringify(collect(form)),
|
|
- })
|
|
- .then(function (res) {
|
|
- return res.json().catch(function () { return {}; }).then(function (data) { return { res: res, data: data }; });
|
|
- })
|
|
- .then(function (r) {
|
|
- if (r.res.ok) { onSuccess(form, r.data); return; }
|
|
- // Surface server-side field errors (e.g. "email already taken").
|
|
- var errors = r.data && r.data.errors;
|
|
- if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
|
|
- form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
|
|
- })
|
|
- .catch(function () {
|
|
- form.dispatchEvent(new CustomEvent("wire:error", { detail: { network: true }, bubbles: true }));
|
|
- })
|
|
- .then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
|
- }
|
|
-
|
|
- function bind(form) {
|
|
- if (form.__wireValidateBound) return;
|
|
- form.__wireValidateBound = 1;
|
|
- var name = form.getAttribute("data-schema");
|
|
- var schema = (window.__wireSchemas || {})[name];
|
|
- if (!schema) return;
|
|
- // Schema-backed forms use WRNexus messages instead of the browser's
|
|
- // non-themeable native validation bubbles.
|
|
- form.noValidate = true;
|
|
- form.setAttribute("novalidate", "");
|
|
- form.addEventListener("submit", function (e) {
|
|
- e.preventDefault();
|
|
- if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
|
|
- submitForm(form, schema);
|
|
- });
|
|
- form.addEventListener("blur", function (e) {
|
|
- var t = e.target;
|
|
- if (t && t.name && schema.fields[t.name]) {
|
|
- showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
|
|
- }
|
|
- }, true);
|
|
- }
|
|
-
|
|
- function init(root) {
|
|
- (root || document).querySelectorAll("form[data-schema]").forEach(bind);
|
|
- }
|
|
-
|
|
- window.__wireValidate = { init: init };
|
|
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
|
|
- else init(document);
|
|
-})();
|
|
-`.trim();
|
|
+/**
|
|
+ * Client-side validation. `renderSchemasScript` bakes the discovered schema
|
|
+ * descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
|
|
+ * eval-free validator that reads them and validates every `form[data-schema]`
|
|
+ * on submit and blur, writing messages into `[data-error="<field>"]` elements.
|
|
+ * The rule logic mirrors `checkField`/`applyRule` in index.ts.
|
|
+ */
|
|
+
|
|
+import type { SchemaDescriptor } from "./index.ts";
|
|
+
|
|
+/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
|
|
+export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
|
|
+ return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
|
|
+}
|
|
+
|
|
+export const VALIDATE_RUNTIME = String.raw`
|
|
+(function () {
|
|
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
+ var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
|
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
+
|
|
+ function applyRule(type, r, value) {
|
|
+ if (r.kind === "min") {
|
|
+ if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
|
|
+ return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
|
|
+ }
|
|
+ if (r.kind === "max") {
|
|
+ if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
|
|
+ return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
|
|
+ }
|
|
+ if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
|
|
+ if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
|
|
+ if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
|
|
+ if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
|
|
+ if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
|
|
+ if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
|
|
+ if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
|
|
+ if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
|
|
+ return null;
|
|
+ }
|
|
+
|
|
+ function checkField(desc, raw) {
|
|
+ if (desc.type === "boolean") {
|
|
+ var b = raw === true || raw === "true" || raw === "on";
|
|
+ return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
|
+ }
|
|
+ var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
|
+ var empty = pre === undefined || pre === null || pre === "";
|
|
+ if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
|
|
+ var value;
|
|
+ if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
|
|
+ else value = String(pre);
|
|
+ for (var i = 0; i < desc.rules.length; i++) {
|
|
+ var err = applyRule(desc.type, desc.rules[i], value);
|
|
+ if (err) return err;
|
|
+ }
|
|
+ return null;
|
|
+ }
|
|
+
|
|
+ function rawValue(form, name) {
|
|
+ var el = form.elements[name];
|
|
+ if (!el) return undefined;
|
|
+ return el.type === "checkbox" ? el.checked : el.value;
|
|
+ }
|
|
+
|
|
+ function showError(form, name, err) {
|
|
+ var box = form.querySelector('[data-error="' + name + '"]');
|
|
+ if (box) box.textContent = err || "";
|
|
+ var el = form.elements[name];
|
|
+ if (el && el.setAttribute) {
|
|
+ if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
|
|
+ else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ function showFormError(form, message) {
|
|
+ var box = form.querySelector('[data-error="_form"]');
|
|
+ if (!box) return;
|
|
+ box.textContent = message || "";
|
|
+ box.hidden = !message;
|
|
+ if (box.classList) box.classList.toggle("hidden", !message);
|
|
+ }
|
|
+
|
|
+ function validateForm(form, schema) {
|
|
+ var errors = 0;
|
|
+ Object.keys(schema.fields).forEach(function (name) {
|
|
+ var err = checkField(schema.fields[name], rawValue(form, name));
|
|
+ if (err) errors++;
|
|
+ showError(form, name, err);
|
|
+ });
|
|
+ return errors;
|
|
+ }
|
|
+
|
|
+ function collect(form) {
|
|
+ var out = {};
|
|
+ for (var i = 0; i < form.elements.length; i++) {
|
|
+ var el = form.elements[i];
|
|
+ if (!el.name) continue;
|
|
+ if (el.type === "checkbox") out[el.name] = el.checked;
|
|
+ else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
|
|
+ else out[el.name] = el.value;
|
|
+ }
|
|
+ return out;
|
|
+ }
|
|
+
|
|
+ function onSuccess(form, data, responseRedirect) {
|
|
+ showFormError(form, "");
|
|
+ form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
|
|
+ var redirect = responseRedirect || form.getAttribute("data-redirect") || (data && data.redirect);
|
|
+ if (redirect) {
|
|
+ // Prefer client-side navigation for same-origin application redirects.
|
|
+ try {
|
|
+ var target = new URL(redirect, location.href);
|
|
+ if (target.origin === location.origin && window.__wrnexusNavigate) {
|
|
+ window.__wrnexusNavigate(target.pathname + target.search + target.hash);
|
|
+ } else location.assign(target.href);
|
|
+ } catch (e) {
|
|
+ location.assign(redirect);
|
|
+ }
|
|
+ return;
|
|
+ }
|
|
+ var box = form.querySelector("[data-success]");
|
|
+ if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
|
|
+ form.reset();
|
|
+ }
|
|
+
|
|
+ function csrfHeader() {
|
|
+ var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
|
|
+ return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
|
|
+ }
|
|
+
|
|
+ function submitForm(form, schema) {
|
|
+ var method = (form.getAttribute("method") || "post").toUpperCase();
|
|
+ var action = form.getAttribute("action") || location.pathname;
|
|
+ var btns = form.querySelectorAll("[type=submit]");
|
|
+ showFormError(form, "");
|
|
+ btns.forEach(function (b) { b.disabled = true; });
|
|
+ var headers = { "content-type": "application/json", accept: "application/json" };
|
|
+ var csrf = csrfHeader();
|
|
+ for (var k in csrf) headers[k] = csrf[k];
|
|
+ fetch(action, {
|
|
+ method: method,
|
|
+ headers: headers,
|
|
+ credentials: "same-origin",
|
|
+ body: JSON.stringify(collect(form)),
|
|
+ })
|
|
+ .then(function (res) {
|
|
+ var responseRedirect = res.redirected && res.url ? res.url : "";
|
|
+ return res.json().catch(function () { return {}; }).then(function (data) {
|
|
+ return { res: res, data: data, responseRedirect: responseRedirect };
|
|
+ });
|
|
+ })
|
|
+ .then(function (r) {
|
|
+ if (r.res.ok && (!r.data || r.data.ok !== false)) {
|
|
+ onSuccess(form, r.data, r.responseRedirect);
|
|
+ return;
|
|
+ }
|
|
+ // Surface server-side field errors and a form-level API message.
|
|
+ var errors = r.data && r.data.errors;
|
|
+ if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
|
|
+ var message = r.data && (r.data.message || r.data.error);
|
|
+ showFormError(form, message || ("Request failed (" + r.res.status + ")"));
|
|
+ form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
|
|
+ })
|
|
+ .catch(function (error) {
|
|
+ var detail = { network: true, message: error && error.message ? error.message : "Network request failed" };
|
|
+ showFormError(form, detail.message);
|
|
+ form.dispatchEvent(new CustomEvent("wire:error", { detail: detail, bubbles: true }));
|
|
+ })
|
|
+ .then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
|
+ }
|
|
+
|
|
+ function bind(form) {
|
|
+ if (form.__wireValidateBound) return;
|
|
+ form.__wireValidateBound = 1;
|
|
+ var name = form.getAttribute("data-schema");
|
|
+ var schema = (window.__wireSchemas || {})[name];
|
|
+ if (!schema) return;
|
|
+ // Schema-backed forms use WRNexus messages instead of the browser's
|
|
+ // non-themeable native validation bubbles.
|
|
+ form.noValidate = true;
|
|
+ form.setAttribute("novalidate", "");
|
|
+ form.addEventListener("submit", function (e) {
|
|
+ e.preventDefault();
|
|
+ showFormError(form, "");
|
|
+ if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
|
|
+ submitForm(form, schema);
|
|
+ });
|
|
+ form.addEventListener("blur", function (e) {
|
|
+ var t = e.target;
|
|
+ if (t && t.name && schema.fields[t.name]) {
|
|
+ showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
|
|
+ }
|
|
+ }, true);
|
|
+ }
|
|
+
|
|
+ function init(root) {
|
|
+ (root || document).querySelectorAll("form[data-schema]").forEach(bind);
|
|
+ }
|
|
+
|
|
+ window.__wireValidate = { init: init };
|
|
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
|
|
+ else init(document);
|
|
+})();
|
|
+`.trim();
|