release: WRNexusJS 0.5.0
This commit is contained in:
@@ -0,0 +1,971 @@
|
||||
--- 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();
|
||||
Reference in New Issue
Block a user