release: WRNexusJS 0.5.10
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -36,13 +36,9 @@ component AuthenticatorSetup {
|
||||
novalidate
|
||||
class='mt-5 space-y-4 {credentialId == "" ? "" : "hidden"}'
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-authenticator-label" class="block text-sm font-medium">Authenticator name</label>
|
||||
<input id="auth-authenticator-label" name="label" value="Authenticator" autocomplete="off" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" />
|
||||
<p data-error="label" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<Input id="auth-authenticator-label" name="label" label="Authenticator name" value="Authenticator" autocomplete="off" icon="icon-[lucide--smartphone]" color="{color}" size="{size}" />
|
||||
<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)]"></p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{setupLabel}</button>
|
||||
<Button type="submit" label="{setupLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
|
||||
<div data-auth-authenticator-details class='{credentialId == "" ? "hidden" : ""}'>
|
||||
@@ -53,15 +49,11 @@ component AuthenticatorSetup {
|
||||
</div>
|
||||
<form method="post" action='{action}' data-schema='{schema}' data-auth-authenticator-confirm novalidate class="mt-5 space-y-4">
|
||||
<input type="hidden" name="credentialId" value='{credentialId}' />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-authenticator-code" class="block text-sm font-medium">Authenticator code</label>
|
||||
<input id="auth-authenticator-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
|
||||
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<PinInput label="Authenticator code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
|
||||
<p data-error="credentialId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ component ForgotPassword {
|
||||
title = "Recover your account"
|
||||
description = "Enter your email, phone, or username."
|
||||
identifierLabel = "Account identifier"
|
||||
identifierPlaceholder = "Enter your registered email, mobile number, or username"
|
||||
submitLabel = "Send recovery link"
|
||||
successMessage = "If an account matches, a recovery link has been sent."
|
||||
signInHref = "/sign-in"
|
||||
@@ -17,14 +18,11 @@ component ForgotPassword {
|
||||
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-recovery-identifier" class="block text-sm font-medium">{identifierLabel}</label>
|
||||
<input id="auth-recovery-identifier" name="identifier" autocomplete="username" aria-describedby="auth-recovery-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
|
||||
<p id="auth-recovery-identifier-error" data-error="identifier" role="alert" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<Input id="auth-recovery-identifier" name="identifier" label="{identifierLabel}" autocomplete="username" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
||||
<slot></slot>
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
<a href='{signInHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Back to sign in</a>
|
||||
</section>
|
||||
|
||||
@@ -22,12 +22,12 @@ component InvitationAccept {
|
||||
<div class="mt-5 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4 text-sm"><p class="m-0 font-semibold">{organization}</p><p class="mb-0 mt-1 text-[var(--wire-color-muted)]">Invited by {inviter}</p></div>
|
||||
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-5 space-y-4">
|
||||
<input type="hidden" name="token" value='{token}' />
|
||||
<div class="space-y-1.5"><label for="auth-invitation-name" class="block text-sm font-medium">Display name</label><input id="auth-invitation-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" /><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-invitation-password" class="block text-sm font-medium">Create password</label><input id="auth-invitation-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" /><p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
|
||||
<Input id="auth-invitation-name" name="displayName" label="Display name" autocomplete="name" placeholder="Enter your display name" icon="icon-[lucide--user]" color="{color}" size="{size}" />
|
||||
<StrongPassword label="Create password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
|
||||
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ component MagicLinkSignIn {
|
||||
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mb-0 mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
|
||||
<div class="space-y-1.5"><label for="auth-magic-link-identifier" class="block text-sm font-medium">{emailLabel}</label><input id="auth-magic-link-identifier" name="identifier" 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 text-sm outline-none focus:border-[var(--wire-color-primary)]" /><p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
|
||||
<Input id="auth-magic-link-identifier" name="identifier" type="email" label="{emailLabel}" autocomplete="email" placeholder="Enter your email address" icon="icon-[lucide--mail]" color="{color}" size="{size}" />
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
<a href='{signInHref}' class="mt-4 inline-flex text-sm font-medium text-[var(--wire-color-primary)] hover:underline">Return to password sign-in</a>
|
||||
</section>
|
||||
|
||||
@@ -41,15 +41,11 @@ component OtpSignIn {
|
||||
class='mt-6 space-y-4 {challengeId == "" ? "" : "hidden"}'
|
||||
>
|
||||
<input type="hidden" name="method" value='{method}' />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-otp-identifier" class="block text-sm font-medium">{identifierLabel}</label>
|
||||
<input id="auth-otp-identifier" name="identifier" type="text" 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 text-sm outline-none focus:border-[var(--wire-color-primary)]" />
|
||||
<p data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<Input id="auth-otp-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username" placeholder="Enter your email address or phone number" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
||||
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{requestLabel}</button>
|
||||
<Button type="submit" label="{requestLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
|
||||
<form
|
||||
@@ -62,15 +58,11 @@ component OtpSignIn {
|
||||
>
|
||||
<input type="hidden" name="challengeId" value='{challengeId}' />
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-otp-code" class="block text-sm font-medium">One-time code</label>
|
||||
<input id="auth-otp-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em] outline-none focus:border-[var(--wire-color-primary)]" />
|
||||
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<PinInput label="One-time code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
|
||||
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white disabled:cursor-wait disabled:opacity-60">{verifyLabel}</button>
|
||||
<button type="button" data-auth-otp-back class="w-full text-sm text-[var(--wire-color-primary)] hover:underline">Use a different account</button>
|
||||
<Button type="submit" label="{verifyLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
<Button type="button" data-auth-otp-back label="Use a different account" variant="ghost" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -18,16 +18,11 @@ component ResetPassword {
|
||||
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="mt-6 space-y-4">
|
||||
<input type="hidden" name="token" value='{token}' />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-reset-password" class="block text-sm font-medium">New password</label>
|
||||
<input id="auth-reset-password" name="password" type="password" autocomplete="new-password" aria-describedby="auth-reset-password-error" 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 id="auth-reset-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<StrongPassword label="New password" name="password" autocomplete="new-password" minLength="12" requireLowercase="true" requireUppercase="true" requireNumber="true" requireSpecialCharacter="true" color="{color}" size="{size}" />
|
||||
<p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ component SignIn {
|
||||
title = "Welcome back"
|
||||
description = "Sign in to continue to your account."
|
||||
identifierLabel = "Email, phone, or username"
|
||||
identifierPlaceholder = "Enter your email, mobile number, or username"
|
||||
passwordLabel = "Password"
|
||||
passwordPlaceholder = "Enter your password"
|
||||
submitLabel = "Sign in"
|
||||
forgotHref = "/recover"
|
||||
signUpHref = "/sign-up"
|
||||
@@ -29,22 +31,25 @@ component SignIn {
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<input type="hidden" name="deviceFingerprint" value="" />
|
||||
<input type="hidden" name="deviceName" value="" />
|
||||
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-sign-in-identifier" class="block text-sm font-medium">{identifierLabel}</label>
|
||||
<input id="auth-sign-in-identifier" name="identifier" type="text" autocomplete="username webauthn" aria-describedby="auth-sign-in-identifier-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
|
||||
<p id="auth-sign-in-identifier-error" data-error="identifier" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-3 text-sm font-medium"><label for="auth-sign-in-password">{passwordLabel}</label><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
|
||||
<input id="auth-sign-in-password" name="password" type="password" autocomplete="current-password" aria-describedby="auth-sign-in-password-error" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-sm outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[color-mix(in_srgb,var(--wire-color-primary)_25%,transparent)]" />
|
||||
<p id="auth-sign-in-password-error" data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<div class="flex justify-end"><a href='{forgotHref}' class="text-xs text-[var(--wire-color-primary)] hover:underline">Forgot password?</a></div>
|
||||
<TogglePassword
|
||||
label="{passwordLabel}"
|
||||
name="password"
|
||||
placeholder="{passwordPlaceholder}"
|
||||
autocomplete="current-password"
|
||||
pattern=""
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
/>
|
||||
</div>
|
||||
{#if showRemember}
|
||||
<label class="flex items-center gap-2 text-sm text-[var(--wire-color-muted)]"><input name="rememberDevice" type="checkbox" class="size-4 rounded border-[var(--wire-color-border)]" />Trust this device</label>
|
||||
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
|
||||
{/if}
|
||||
<slot></slot>
|
||||
<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)]"></p>
|
||||
<button type="submit" class="inline-flex h-11 w-full items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
{#if showPasskey}
|
||||
<div class="my-4 flex items-center gap-3 text-xs text-[var(--wire-color-muted)]"><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span><span>or</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
|
||||
|
||||
@@ -7,6 +7,13 @@ component SignUp {
|
||||
description = "Use a strong password and verify your contact details."
|
||||
submitLabel = "Create account"
|
||||
signInHref = "/sign-in"
|
||||
termsHref = "/legal/terms"
|
||||
privacyHref = "/legal/privacy-policy"
|
||||
namePlaceholder = "Enter your full name"
|
||||
emailPlaceholder = "Enter your email address"
|
||||
phonePlaceholder = "Enter your mobile number"
|
||||
usernamePlaceholder = "Choose a username"
|
||||
passwordPlaceholder = "Create a strong password"
|
||||
showPhone = true
|
||||
showUsername = true
|
||||
requireConsent = true
|
||||
@@ -15,10 +22,24 @@ component SignUp {
|
||||
class = ""
|
||||
}
|
||||
view {
|
||||
<section {...attrs} data-wrnexus-runtime="auth" 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>
|
||||
<section
|
||||
{...attrs}
|
||||
data-wrnexus-runtime="auth"
|
||||
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
|
||||
@@ -29,62 +50,146 @@ component SignUp {
|
||||
novalidate
|
||||
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>
|
||||
<Input
|
||||
id="auth-sign-up-name"
|
||||
name="displayName"
|
||||
label="Full name"
|
||||
autocomplete="name"
|
||||
placeholder="{namePlaceholder}"
|
||||
icon="icon-[lucide--user]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
id="auth-sign-up-email"
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
placeholder="{emailPlaceholder}"
|
||||
icon="icon-[lucide--mail]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
/>
|
||||
|
||||
{#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>
|
||||
<Input
|
||||
id="auth-sign-up-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
label="Phone"
|
||||
autocomplete="tel"
|
||||
placeholder="{phonePlaceholder}"
|
||||
icon="icon-[lucide--phone]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
/>
|
||||
{/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>
|
||||
<Input
|
||||
id="auth-sign-up-username"
|
||||
name="username"
|
||||
label="Username"
|
||||
autocomplete="username"
|
||||
placeholder="{usernamePlaceholder}"
|
||||
icon="icon-[lucide--circle-user-round]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
{/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
|
||||
class="space-y-1.5 sm:col-span-2"
|
||||
>
|
||||
<StrongPassword
|
||||
label="Password"
|
||||
name="password"
|
||||
placeholder="{passwordPlaceholder}"
|
||||
autocomplete="new-password"
|
||||
minLength="12"
|
||||
requireLowercase="true"
|
||||
requireUppercase="true"
|
||||
requireNumber="true"
|
||||
requireSpecialCharacter="true"
|
||||
presentation="inline"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
/>
|
||||
</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>
|
||||
<Checkbox
|
||||
id="auth-sign-up-consent"
|
||||
name="consent"
|
||||
label="I agree to the"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
>
|
||||
<span
|
||||
class="flex items-center"
|
||||
>
|
||||
<a
|
||||
href="{termsHref}"
|
||||
>
|
||||
terms
|
||||
</a>
|
||||
and
|
||||
<a
|
||||
href="{privacyHref}"
|
||||
>
|
||||
privacy policy
|
||||
</a>.
|
||||
</span>
|
||||
</Checkbox>
|
||||
{:else}
|
||||
<input type="hidden" name="consent" value="true" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="consent"
|
||||
value="true"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="sm:col-span-2"><slot></slot></div>
|
||||
<div
|
||||
class="sm:col-span-2"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<Button
|
||||
type="submit"
|
||||
label="{submitLabel}"
|
||||
variant="solid"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
fullWidth="true"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ component TwoFactorChallenge {
|
||||
size = "md"
|
||||
class = ""
|
||||
}
|
||||
state methodOptions = [
|
||||
{"label":"Authenticator app","value":"totp"},
|
||||
{"label":"Recovery code","value":"recovery-code"},
|
||||
{"label":"Email code","value":"email-otp"},
|
||||
{"label":"SMS code","value":"sms-otp"}
|
||||
]
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
@@ -31,26 +37,13 @@ component TwoFactorChallenge {
|
||||
<input type="hidden" name="mfaToken" value='{mfaToken}' />
|
||||
<input type="hidden" name="challengeId" value='{challengeId}' />
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-mfa-method" class="block text-sm font-medium">Verification method</label>
|
||||
<select id="auth-mfa-method" name="method" data-auth-mfa-method class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3">
|
||||
<option value="totp">Authenticator app</option>
|
||||
<option value="recovery-code">Recovery code</option>
|
||||
<option value="email-otp">Email code</option>
|
||||
<option value="sms-otp">SMS code</option>
|
||||
</select>
|
||||
<p data-error="method" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="auth-mfa-code" class="block text-sm font-medium">Verification code</label>
|
||||
<input id="auth-mfa-code" name="code" inputmode="numeric" autocomplete="one-time-code" class="h-12 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-center text-xl tracking-[0.35em]" />
|
||||
<p data-error="code" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
</div>
|
||||
<Select id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" color="{color}" size="{size}" />
|
||||
<PinInput label="Verification code" name="code" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
|
||||
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wire-color-primary)]"></p>
|
||||
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<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)]"></p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
<a href='{recoveryHref}' class="mt-4 inline-flex text-sm text-[var(--wire-color-primary)] hover:underline">Account recovery options</a>
|
||||
</section>
|
||||
|
||||
@@ -20,17 +20,17 @@ component VerifyEmail {
|
||||
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
|
||||
<div class="space-y-1.5"><label for="auth-email-token" class="block text-sm font-medium">Verification token</label><input id="auth-email-token" name="token" value='{token}' autocomplete="one-time-code" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
|
||||
<Input id="auth-email-token" name="token" label="Verification token" value="{token}" autocomplete="one-time-code" placeholder="Enter your verification token" icon="icon-[lucide--badge-check]" color="{color}" size="{size}" />
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
|
||||
<input type="hidden" name="type" value='{kind}' />
|
||||
<input type="hidden" name="identifier" value='{identifier}' />
|
||||
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
|
||||
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@ component VerifyPhone {
|
||||
<h1 class="m-0 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mt-2 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<form method="post" action='{action}' data-schema='{schema}' novalidate class="mt-6 space-y-4">
|
||||
<div class="space-y-1.5"><label for="auth-phone-token" class="block text-sm font-medium">Verification token</label><input id="auth-phone-token" name="token" value='{token}' autocomplete="one-time-code" inputmode="numeric" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><p data-error="token" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p></div>
|
||||
<PinInput label="Verification token" name="token" value="{token}" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" />
|
||||
<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)]"></p>
|
||||
<p data-success='{successMessage}' 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)]">{successMessage}</p>
|
||||
<button type="submit" class="h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] font-semibold text-white disabled:cursor-wait disabled:opacity-60">{submitLabel}</button>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
</form>
|
||||
<form method="post" action='{resendAction}' data-schema='{resendSchema}' novalidate class="mt-3">
|
||||
<input type="hidden" name="type" value='{kind}' />
|
||||
<input type="hidden" name="identifier" value='{identifier}' />
|
||||
<p data-error="type" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<button type="submit" class="text-sm text-[var(--wire-color-primary)] hover:underline disabled:opacity-60">Send a new code</button>
|
||||
<Button type="submit" label="Send a new code" variant="ghost" color="{color}" size="sm" />
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -36,7 +36,7 @@
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck \u0026\u0026 bun run test"
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
@@ -47,6 +47,7 @@
|
||||
"@wrnexus/oauth": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/queue": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
getAuthUser,
|
||||
} from "../middleware.ts";
|
||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
||||
import type { AuthSignedInHandler, AuthSignedOutHandler } from "../types.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "../types.ts";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
@@ -53,6 +57,7 @@ export interface AuthHttpOptions {
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
@@ -83,6 +88,21 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
async verifySession(ctx: Context): Promise<Response> {
|
||||
const user = getAuthUser(ctx);
|
||||
if (options.onSessionVerification) {
|
||||
return options.onSessionVerification(ctx, user);
|
||||
}
|
||||
if (!user) return json({ ok: false, error: "Unauthorized" }, 401);
|
||||
if ((ctx.req.headers.get("accept") ?? "").includes("application/json")) {
|
||||
return json({ ok: true, user });
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
},
|
||||
|
||||
async register(ctx: Context): Promise<Response> {
|
||||
const validation = await parseBody(schemas.register, ctx.req);
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
@@ -12,9 +12,16 @@ function wantsJson(ctx: Context): boolean {
|
||||
return accept.includes("application/json") && !accept.includes("text/html");
|
||||
}
|
||||
|
||||
export function authSession(engine: AuthEngine): Middleware {
|
||||
export interface AuthSessionOptions {
|
||||
/** Optional shared SSO cookie containing an AuthEngine session id. */
|
||||
cookieName?: string;
|
||||
}
|
||||
|
||||
export function authSession(engine: AuthEngine, options: AuthSessionOptions = {}): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const sessionId = ctx.session.get<string>(AUTH_SESSION_KEY);
|
||||
const sessionId =
|
||||
(options.cookieName ? ctx.cookies.get(options.cookieName) : undefined) ??
|
||||
ctx.session.get<string>(AUTH_SESSION_KEY);
|
||||
if (!sessionId) {
|
||||
ctx.user = null;
|
||||
ctx.locals.authUser = null;
|
||||
|
||||
@@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url";
|
||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type { AuthSignedInHandler, AuthSignedOutHandler } from "./types.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "./types.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
||||
import {
|
||||
clearDefaultAuthEngine,
|
||||
@@ -52,6 +56,10 @@ export interface AuthConfig {
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
||||
sessionCookieName?: string;
|
||||
/** Customize the package forward-auth verification response. */
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
/** Explicit plugin options remain supported for compatibility. Prefer config.auth. */
|
||||
@@ -88,14 +96,22 @@ interface ResolvedAuthConfig {
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const moduleRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = dirname(moduleRoot);
|
||||
const compiledPackage = moduleRoot === join(packageRoot, "dist");
|
||||
const clientRuntime = join(packageRoot, "assets", "client", "auth.js");
|
||||
const migrationsFile = join(packageRoot, "migrations", "001_auth.sql");
|
||||
const otpPurposeMigrationFile = join(packageRoot, "migrations", "002_auth_otp_purpose.sql");
|
||||
const apiRoutesDir = join(packageRoot, "src", "routes", "api");
|
||||
const middlewareFile = join(packageRoot, "src", "routes", "middleware.ts");
|
||||
const apiRoutesDir = compiledPackage
|
||||
? join(moduleRoot, "routes", "api")
|
||||
: join(packageRoot, "src", "routes", "api");
|
||||
const middlewareFile = compiledPackage
|
||||
? join(moduleRoot, "routes", "middleware.js")
|
||||
: join(packageRoot, "src", "routes", "middleware.ts");
|
||||
const resolvedConfigKey = "@wrnexus/auth:resolved-config";
|
||||
|
||||
export function authComponentsDir(): string {
|
||||
@@ -104,7 +120,7 @@ export function authComponentsDir(): string {
|
||||
|
||||
function authApiRouteEntry(path: string): string {
|
||||
const routeName = path.replace(/^\/api\/auth\/?/, "").replace(/\//g, "-") || "index";
|
||||
return join(apiRoutesDir, routeName + ".ts");
|
||||
return join(apiRoutesDir, routeName + (compiledPackage ? ".js" : ".ts"));
|
||||
}
|
||||
|
||||
function resolveConfig(
|
||||
@@ -142,6 +158,8 @@ function resolveConfig(
|
||||
passkey: raw.passkey,
|
||||
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
|
||||
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
|
||||
sessionCookieName: raw.sessionCookieName,
|
||||
onSessionVerification: raw.onSessionVerification,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,6 +395,10 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
||||
onSignedIn: value.onSignedIn,
|
||||
|
||||
onSignedOut: value.onSignedOut,
|
||||
|
||||
sessionCookieName: value.sessionCookieName,
|
||||
|
||||
onSessionVerification: value.onSessionVerification,
|
||||
});
|
||||
|
||||
context.metadata.set("@wrnexus/auth:component-dir", value.componentDir);
|
||||
|
||||
@@ -62,6 +62,7 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
|
||||
passkey: routeOptions.passkey,
|
||||
onSignedIn: routeOptions.onSignedIn,
|
||||
onSignedOut: routeOptions.onSignedOut,
|
||||
onSessionVerification: routeOptions.onSessionVerification,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { invokeAuthHandler } from "../api.ts";
|
||||
|
||||
export function GET(ctx: Context): Promise<Response> {
|
||||
return invokeAuthHandler("verifySession", ctx);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ export interface AuthRouteDefinition {
|
||||
|
||||
/** Single source of truth for package-contributed auth endpoints. */
|
||||
export const AUTH_ROUTE_DEFINITIONS = [
|
||||
{
|
||||
path: "/api/auth/session/verify",
|
||||
group: "sessions",
|
||||
handler: "verifySession",
|
||||
methods: ["GET"],
|
||||
},
|
||||
{ path: "/api/auth/register", group: "registration", handler: "register", methods: ["POST"] },
|
||||
{ path: "/api/auth/login", group: "login", handler: "login", methods: ["POST"] },
|
||||
{ path: "/api/auth/logout", group: "login", handler: "logout", methods: ["POST"] },
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { Middleware } from "@wrnexus/core";
|
||||
import { authSession } from "../middleware.ts";
|
||||
import { getDefaultAuthEngine, hasDefaultAuthEngine } from "../runtime.ts";
|
||||
import {
|
||||
getDefaultAuthEngine,
|
||||
getDefaultAuthRouteOptions,
|
||||
hasDefaultAuthEngine,
|
||||
} from "../runtime.ts";
|
||||
|
||||
/** Package middleware: hydrates auth state when a default engine is configured. */
|
||||
const middleware: Middleware = async (ctx, next) => {
|
||||
if (!hasDefaultAuthEngine()) return next();
|
||||
return authSession(getDefaultAuthEngine())(ctx, next);
|
||||
return authSession(getDefaultAuthEngine(), {
|
||||
cookieName: getDefaultAuthRouteOptions().sessionCookieName,
|
||||
})(ctx, next);
|
||||
};
|
||||
|
||||
export default middleware;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "./validation.ts";
|
||||
|
||||
export interface DefaultAuthRouteOptions {
|
||||
@@ -11,6 +12,8 @@ export interface DefaultAuthRouteOptions {
|
||||
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
|
||||
|
||||
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
interface AuthRuntimeState {
|
||||
|
||||
@@ -241,6 +241,12 @@ export type AuthSignedInHandler = (ctx: Context, returnTo?: string) => Response
|
||||
/** HTTP response hook invoked after the auth engine clears a signed-in session. */
|
||||
export type AuthSignedOutHandler = (ctx: Context) => Response | Promise<Response>;
|
||||
|
||||
/** Forward-auth response hook. A null user means the request is unauthenticated. */
|
||||
export type AuthSessionVerificationHandler = (
|
||||
ctx: Context,
|
||||
user: AuthPublicUser | null,
|
||||
) => Response | Promise<Response>;
|
||||
|
||||
export interface AuthSuccessfulSignUpAction {
|
||||
/**
|
||||
* Run the normal login policy with the newly registered credentials and
|
||||
|
||||
@@ -51,6 +51,48 @@ test("passwordless and passkey components preserve MFA continuation metadata", (
|
||||
expect(signIn).toContain('name="deviceName"');
|
||||
});
|
||||
|
||||
test("recovery forms expose a form slot for CAPTCHA and policy controls", () => {
|
||||
const source = readFileSync(join(directory, "ForgotPassword.wrn"), "utf8");
|
||||
const formStart = source.indexOf("<form");
|
||||
const formEnd = source.indexOf("</form>");
|
||||
const slot = source.indexOf("<slot></slot>");
|
||||
|
||||
expect(formStart).toBeGreaterThanOrEqual(0);
|
||||
expect(slot).toBeGreaterThan(formStart);
|
||||
expect(slot).toBeLessThan(formEnd);
|
||||
});
|
||||
|
||||
test("account forms use the dedicated password components", () => {
|
||||
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
|
||||
const signUp = readFileSync(join(directory, "SignUp.wrn"), "utf8");
|
||||
|
||||
expect(signIn).toContain("<TogglePassword");
|
||||
expect(signIn).not.toContain('id="auth-sign-in-password"');
|
||||
expect(signUp).toContain("<StrongPassword");
|
||||
expect(signUp).not.toContain('id="auth-sign-up-password"');
|
||||
});
|
||||
|
||||
test("authentication journeys use themed UI form controls", () => {
|
||||
const expected = {
|
||||
"SignIn.wrn": ["<Input", "<TogglePassword", "<Checkbox", "<Button"],
|
||||
"SignUp.wrn": ["<Input", "<StrongPassword", "<Checkbox", "<Button"],
|
||||
"ForgotPassword.wrn": ["<Input", "<Button"],
|
||||
"ResetPassword.wrn": ["<StrongPassword", "<Button"],
|
||||
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
|
||||
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
|
||||
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
|
||||
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"VerifyEmail.wrn": ["<Input", "<Button"],
|
||||
"VerifyPhone.wrn": ["<PinInput", "<Button"],
|
||||
};
|
||||
|
||||
for (const [file, controls] of Object.entries(expected)) {
|
||||
const source = readFileSync(join(directory, file), "utf8");
|
||||
for (const control of controls) expect(source).toContain(control);
|
||||
}
|
||||
});
|
||||
|
||||
test("verification components can resend without application-owned schema files", () => {
|
||||
const email = readFileSync(join(directory, "VerifyEmail.wrn"), "utf8");
|
||||
const phone = readFileSync(join(directory, "VerifyPhone.wrn"), "utf8");
|
||||
|
||||
@@ -178,6 +178,26 @@ test("config.auth registers package routes", async () => {
|
||||
|
||||
expect(contributions.migrations).toHaveLength(0);
|
||||
});
|
||||
test("config.auth contributes a reusable forward-auth verification route", async () => {
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map<string, unknown>(),
|
||||
warn() {},
|
||||
});
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: {} as never,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/session/verify")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("config.auth can disable route groups", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -415,7 +415,12 @@
|
||||
}
|
||||
|
||||
function toggleItem(state, itemId, button) {
|
||||
if (state.config.disabled || state.status !== "ready") return;
|
||||
if (state.config.disabled || (state.status !== "ready" && state.status !== "incorrect")) return;
|
||||
|
||||
// An incorrect answer is still the same usable challenge until the server
|
||||
// expires it or exhausts its attempts. Let the user amend the selection
|
||||
// immediately and clear the stale error/locked control state.
|
||||
if (state.status === "incorrect") setStatus(state, "ready", "");
|
||||
|
||||
var index = state.selections.indexOf(itemId);
|
||||
var selected = index >= 0;
|
||||
@@ -867,10 +872,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
if (!state.config.required || state.responseToken) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
function passesSubmitGuard(state) {
|
||||
if (!state.config.required || state.responseToken) return true;
|
||||
setStatus(state, "incorrect", state.config.requiredMessage);
|
||||
emit(state, "failure", { code: "missing-input" });
|
||||
var target =
|
||||
@@ -878,6 +881,15 @@
|
||||
state.root.querySelector("[data-captcha-not-robot-button]") ||
|
||||
state.root.querySelector("[data-captcha-verify]");
|
||||
if (target && typeof target.focus === "function") target.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
// Schema-backed forms run this guard after their ordinary fields validate.
|
||||
if (state.form && state.form.__wireValidateBound) return;
|
||||
if (passesSubmitGuard(state)) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function initialize(root) {
|
||||
@@ -963,6 +975,11 @@
|
||||
});
|
||||
|
||||
if (state.form) {
|
||||
state.submitGuard = function () {
|
||||
return passesSubmitGuard(state);
|
||||
};
|
||||
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards || [];
|
||||
state.form.__wireSubmitGuards.push(state.submitGuard);
|
||||
state.formSubmitListener = function (event) {
|
||||
onFormSubmit(state, event);
|
||||
};
|
||||
@@ -1018,6 +1035,11 @@
|
||||
if (state.form && state.formSubmitListener) {
|
||||
state.form.removeEventListener("submit", state.formSubmitListener, true);
|
||||
}
|
||||
if (state.form && state.form.__wireSubmitGuards && state.submitGuard) {
|
||||
state.form.__wireSubmitGuards = state.form.__wireSubmitGuards.filter(function (guard) {
|
||||
return guard !== state.submitGuard;
|
||||
});
|
||||
}
|
||||
if (state.form && state.formSuccessListener) {
|
||||
state.form.removeEventListener("wire:success", state.formSuccessListener);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ component Captcha {
|
||||
required = true
|
||||
resetOnError = true
|
||||
compact = false
|
||||
layout = "vertical"
|
||||
|
||||
@event ready = function
|
||||
@event challenge = function
|
||||
@@ -90,6 +91,7 @@ component Captcha {
|
||||
data-captcha-required='{required}'
|
||||
data-captcha-reset-on-error='{resetOnError}'
|
||||
data-captcha-compact='{compact}'
|
||||
data-captcha-layout='{layout}'
|
||||
data-captcha-required-message='{requiredMessage}'
|
||||
data-captcha-incorrect-message='{incorrectMessage}'
|
||||
data-captcha-expired-message='{expiredMessage}'
|
||||
@@ -97,9 +99,9 @@ component Captcha {
|
||||
data-captcha-status="idle"
|
||||
data-server-verification-required="true"
|
||||
aria-busy="false"
|
||||
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
|
||||
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-layout=horizontal]:max-w-2xl data-[captcha-layout=horizontal]:grid data-[captcha-layout=horizontal]:grid-cols-1 sm:data-[captcha-layout=horizontal]:grid-cols-[minmax(0,1fr)_minmax(12rem,0.8fr)] sm:data-[captcha-layout=horizontal]:items-start data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
|
||||
>
|
||||
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-layout=horizontal]/captcha:col-span-full group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
<span
|
||||
@@ -140,7 +142,7 @@ component Captcha {
|
||||
Loading challenge…
|
||||
</div>
|
||||
|
||||
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
|
||||
<div data-captcha-challenge hidden class='flex min-w-0 flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
|
||||
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
|
||||
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
|
||||
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
|
||||
@@ -219,20 +221,6 @@ component Captcha {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
|
||||
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
|
||||
<input
|
||||
data-captcha-answer
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
|
||||
placeholder="Enter the answer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<input
|
||||
data-captcha-honeypot
|
||||
type="text"
|
||||
@@ -245,6 +233,21 @@ component Captcha {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-captcha-response-panel class='flex min-w-0 flex-col gap-2 group-data-[captcha-layout=horizontal]/captcha:self-stretch'>
|
||||
<label data-captcha-answer-wrap hidden class='flex min-w-0 flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
|
||||
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
|
||||
<input
|
||||
data-captcha-answer
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
|
||||
placeholder="Enter the answer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
|
||||
<span data-captcha-success-message>Verification completed.</span>
|
||||
@@ -312,6 +315,7 @@ component Captcha {
|
||||
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
Protected by WRNexus CAPTCHA. Server-side verification is required.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -38,7 +38,7 @@
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck \u0026\u0026 bun run test"
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
|
||||
@@ -38,6 +38,13 @@ const DEFAULT_TOKEN_TTL_MS = 300_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_MIN_COMPLETION_MS = 800;
|
||||
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
|
||||
const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [
|
||||
"number",
|
||||
"alpha",
|
||||
"alphanumeric",
|
||||
"calculation",
|
||||
"image",
|
||||
];
|
||||
|
||||
function failure(
|
||||
action: string,
|
||||
@@ -150,13 +157,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators())
|
||||
this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType))
|
||||
if (this.defaultType !== "random" && !this.generators.has(this.defaultType))
|
||||
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
}
|
||||
|
||||
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
const action = assertAction(options.action);
|
||||
const requestedType = options.type ?? this.defaultType;
|
||||
const configuredType = options.type ?? this.defaultType;
|
||||
const randomInt = (min: number, max: number): number =>
|
||||
randomInteger(this.randomBytes, min, max);
|
||||
const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type));
|
||||
if (configuredType === "random" && randomTypes.length === 0)
|
||||
throw new Error("No interactive CAPTCHA generators are registered");
|
||||
const requestedType =
|
||||
configuredType === "random"
|
||||
? randomTypes[randomInt(0, randomTypes.length - 1)]
|
||||
: configuredType;
|
||||
const requestedPresentation =
|
||||
options.presentation ??
|
||||
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
@@ -169,8 +185,6 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const now = this.now();
|
||||
const difficulty = options.difficulty ?? this.defaultDifficulty;
|
||||
const disturbance = normalizeDisturbance(options.disturbance, difficulty);
|
||||
const randomInt = (min: number, max: number): number =>
|
||||
randomInteger(this.randomBytes, min, max);
|
||||
const imageStyle = resolveCaptchaImageStyle({
|
||||
imageStyle: options.imageStyle,
|
||||
allowedStyles: options.allowedStyles,
|
||||
|
||||
@@ -4,6 +4,7 @@ export type CaptchaProviderName =
|
||||
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
|
||||
|
||||
export type CaptchaChallengeType =
|
||||
| "random"
|
||||
| "number"
|
||||
| "alpha"
|
||||
| "alphanumeric"
|
||||
|
||||
@@ -23,9 +23,14 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
|
||||
expect(source).toContain("normalizeSize");
|
||||
expect(source).toContain("verifyNotRobot");
|
||||
expect(source).toContain("stopImmediatePropagation");
|
||||
expect(source).toContain("__wireSubmitGuards");
|
||||
expect(source).toContain("__wireValidateBound");
|
||||
expect(source).toContain("passesSubmitGuard");
|
||||
expect(source).toContain('addEventListener("submit"');
|
||||
expect(source).toContain('addEventListener("wire:error"');
|
||||
expect(source).toContain("state.config.resetOnError || captchaFailed");
|
||||
expect(source).toContain('state.status !== "ready" && state.status !== "incorrect"');
|
||||
expect(source).toContain('state.status === "incorrect") setStatus(state, "ready", "")');
|
||||
expect(source).toContain("Verified response tokens are single-use");
|
||||
expect(source).toContain("MutationObserver");
|
||||
expect(source).toContain("new CustomEvent(name");
|
||||
|
||||
@@ -49,6 +49,22 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("self-hosted CAPTCHA engine", () => {
|
||||
test("random challenges resolve to an interactive challenge type", async () => {
|
||||
const engine = createCaptchaEngine({
|
||||
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
|
||||
store: new MemoryCaptchaStore(),
|
||||
defaultType: "random",
|
||||
minCompletionMs: 0,
|
||||
});
|
||||
const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const challenge = await engine.create({ action: "random-check" });
|
||||
expect(supported.has(challenge.type)).toBe(true);
|
||||
expect(challenge.type).not.toBe("random");
|
||||
}
|
||||
});
|
||||
|
||||
test("creates, solves, and consumes a challenge and response token", async () => {
|
||||
const { engine } = fixture();
|
||||
const challenge = await engine.create({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -419,6 +419,7 @@ await createProductionServer(
|
||||
security: ${JSON.stringify(config.security ?? {})},
|
||||
observability: ${JSON.stringify(config.observability ?? {})},
|
||||
tenancy: ${JSON.stringify(config.tenancy ?? {})},
|
||||
navigation: ${JSON.stringify(config.navigation ?? {})},
|
||||
},
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -263,16 +263,14 @@ const config: AppConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Fonts — the framework emits optimized <head> markup (preconnect, subsetted
|
||||
// Google Fonts with font-display, self-hosted @font-face with preload) and
|
||||
// auto-extends the CSP for Google Fonts. Uncomment to use a custom font:
|
||||
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
|
||||
fonts: {
|
||||
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
||||
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
|
||||
},
|
||||
//
|
||||
// fonts: {
|
||||
// sans: '"Inter", ui-sans-serif, system-ui, sans-serif',
|
||||
// google: [{ family: "Inter", weights: [400, 500, 600, 700] }],
|
||||
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||
// },
|
||||
|
||||
// security: {
|
||||
// cors: {
|
||||
@@ -304,13 +302,7 @@ Allow: /
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
body {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
|
||||
}
|
||||
`,
|
||||
"app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected
|
||||
|
||||
@@ -1103,6 +1103,32 @@ const MIGRATIONS: Migration[] = [
|
||||
// Syntax and compiler support are package-managed; existing applications remain compatible.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.5.2",
|
||||
id: "multiline-wrn-json-and-stable-gateway-runtime",
|
||||
description:
|
||||
"Supports formatted multiline array and object state initializers and keeps gateway children on the matching framework runtime.",
|
||||
apply() {
|
||||
// Syntax, compiler, and gateway fixes are package-managed; no application rewrite is required.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.5.3",
|
||||
id: "public-workspace-origins-and-schema-backed-auth-forms",
|
||||
description:
|
||||
"Keeps cross-app redirects on configured public origins and enables schema-backed auth forms with active navigation states.",
|
||||
apply() {
|
||||
// Gateway, helper, validation, and UI improvements are package-managed.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.5.10",
|
||||
id: "latest-release-after-upgrade",
|
||||
description: "Latest New Packages and release ready packages",
|
||||
apply() {
|
||||
// Gateway, helper, validation, and UI improvements are package-managed.
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -69,9 +69,13 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
|
||||
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
|
||||
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
|
||||
expect(readFileSync(join(root, "app", "styles", "global.css"), "utf8")).toContain(
|
||||
'@plugin "@iconify/tailwind4";',
|
||||
);
|
||||
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
|
||||
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
|
||||
expect(globalCss).toContain('"Plus Jakarta Sans"');
|
||||
expect(globalCss).toContain("--wire-font-sans");
|
||||
const appConfig = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
expect(appConfig).toContain('family: "Plus Jakarta Sans"');
|
||||
expect(appConfig).toContain("weights: [400, 500, 600, 700]");
|
||||
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
|
||||
expect(existsSync(join(root, "llms.txt"))).toBe(false);
|
||||
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1437,6 +1437,10 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
}
|
||||
}
|
||||
|
||||
const isExplicitComponentMount = node.attrs.some(
|
||||
(attribute) => attribute.name === "data-component",
|
||||
);
|
||||
|
||||
const attrs = node.attrs
|
||||
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
||||
.map((a) => {
|
||||
@@ -1476,7 +1480,12 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
if (a.value === "true" || a.value === "") return ` ${a.name}`;
|
||||
}
|
||||
|
||||
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
|
||||
const wholeExpression = wholeAttributeExpression(a.value);
|
||||
const compiledValue =
|
||||
isExplicitComponentMount && wholeExpression
|
||||
? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(a.value, elementContext);
|
||||
const rendered = ` ${a.name}="${compiledValue}"`;
|
||||
|
||||
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
|
||||
|
||||
|
||||
@@ -98,6 +98,20 @@ test("component event declarations compile to public root metadata", async () =>
|
||||
expect(render({})).toContain('data-wrn-events="complete"');
|
||||
});
|
||||
|
||||
test("explicit nested component mounts serialize structured expression props", () => {
|
||||
const output = compileWireFile(`component Shell {
|
||||
state brand = { "label": "WRNexus" }
|
||||
state items = [{ "label": "Home", "href": "/" }]
|
||||
view {
|
||||
<div data-component="Navbar" brand={brand} items={items}></div>
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(output).toContain('data-component="Navbar"');
|
||||
expect(output).toContain('brand="${__wireProp(brand)}"');
|
||||
expect(output).toContain('items="${__wireProp(items)}"');
|
||||
});
|
||||
|
||||
test("typed props, required props, state, custom types, and function parameters compile", () => {
|
||||
const source = `component TypedPicker {
|
||||
types {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -2011,10 +2011,234 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
ensureBehaviorObserver();
|
||||
hydrateNavbarControllers(host);
|
||||
hydratePreferenceControllers(host);
|
||||
hydrateSidebarControllers(host);
|
||||
hydrateDropdownControllers(host);
|
||||
hydrateSelectControllers(host);
|
||||
hydratePinInputControllers(host);
|
||||
}
|
||||
|
||||
var navbarOutsideClickBound = false;
|
||||
var preferenceOutsideClickBound = false;
|
||||
var dropdownOutsideClickBound = false;
|
||||
|
||||
function hydrateSidebarControllers(root) {
|
||||
var host = root || document;
|
||||
var sidebars = [];
|
||||
if (host.nodeType === 1 && host.matches && host.matches(".wire-sidebar-shell")) {
|
||||
sidebars.push(host);
|
||||
}
|
||||
if (host.querySelectorAll) {
|
||||
Array.prototype.push.apply(sidebars, host.querySelectorAll(".wire-sidebar-shell"));
|
||||
}
|
||||
sidebars.forEach(function (sidebar) {
|
||||
var current = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
var best = null;
|
||||
var bestLength = -1;
|
||||
sidebar.querySelectorAll(".wire-sidebar-items a[href]").forEach(function (link) {
|
||||
var url;
|
||||
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
|
||||
var path = url.pathname.replace(/\/+$/, "") || "/";
|
||||
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
|
||||
link.classList.remove("is-active");
|
||||
if (!matches || path.length <= bestLength) return;
|
||||
best = link;
|
||||
bestLength = path.length;
|
||||
});
|
||||
sidebar.querySelectorAll(".wire-sidebar-items a[aria-current='page']").forEach(function (link) {
|
||||
if (link !== best) link.removeAttribute("aria-current");
|
||||
});
|
||||
sidebar.querySelectorAll(".wire-sidebar-group.has-active").forEach(function (group) {
|
||||
group.classList.remove("has-active");
|
||||
});
|
||||
if (best) {
|
||||
best.classList.add("is-active");
|
||||
best.setAttribute("aria-current", "page");
|
||||
var group = best.closest(".wire-sidebar-group");
|
||||
if (group) {
|
||||
group.setAttribute("open", "");
|
||||
group.classList.add("has-active");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateDropdownControllers(root) {
|
||||
var host = root || document;
|
||||
var dropdowns = [];
|
||||
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-dropdown]")) {
|
||||
dropdowns.push(host);
|
||||
}
|
||||
if (host.querySelectorAll) {
|
||||
Array.prototype.push.apply(dropdowns, host.querySelectorAll("[data-wrn-dropdown]"));
|
||||
}
|
||||
dropdowns.forEach(function (dropdown) {
|
||||
if (dropdown.dataset.wrnDropdownBound === "true") return;
|
||||
dropdown.dataset.wrnDropdownBound = "true";
|
||||
dropdown.addEventListener("toggle", function () {
|
||||
if (!dropdown.open) return;
|
||||
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (candidate) {
|
||||
if (candidate !== dropdown) candidate.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!dropdownOutsideClickBound) {
|
||||
dropdownOutsideClickBound = true;
|
||||
document.addEventListener("pointerdown", function (event) {
|
||||
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
|
||||
if (!dropdown.contains(event.target)) dropdown.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
|
||||
dropdown.removeAttribute("open");
|
||||
var summary = dropdown.querySelector(":scope > summary");
|
||||
if (summary) summary.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hydratePreferenceControllers(root) {
|
||||
var host = root || document;
|
||||
var switchers = [];
|
||||
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-preferences]")) {
|
||||
switchers.push(host);
|
||||
}
|
||||
if (host.querySelectorAll) {
|
||||
Array.prototype.push.apply(
|
||||
switchers,
|
||||
host.querySelectorAll("[data-wrn-preferences]"),
|
||||
);
|
||||
}
|
||||
switchers.forEach(function (switcher) {
|
||||
if (switcher.dataset.wrnPreferencesBound === "true") return;
|
||||
switcher.dataset.wrnPreferencesBound = "true";
|
||||
switcher.addEventListener("toggle", function (event) {
|
||||
var opened = event.target;
|
||||
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
|
||||
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
|
||||
if (menu !== opened) menu.removeAttribute("open");
|
||||
});
|
||||
}, true);
|
||||
});
|
||||
if (!preferenceOutsideClickBound) {
|
||||
preferenceOutsideClickBound = true;
|
||||
document.addEventListener("pointerdown", function (event) {
|
||||
document.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
|
||||
if (switcher.contains(event.target)) return;
|
||||
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("[data-wrn-preferences] .wire-preferences__menu[open]").forEach(function (menu) {
|
||||
menu.removeAttribute("open");
|
||||
var summary = menu.querySelector(":scope > summary");
|
||||
if (summary) summary.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateNavbarControllers(root) {
|
||||
var host = root || document;
|
||||
var navbars = [];
|
||||
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-navbar]")) {
|
||||
navbars.push(host);
|
||||
}
|
||||
if (host.querySelectorAll) {
|
||||
Array.prototype.push.apply(
|
||||
navbars,
|
||||
host.querySelectorAll("[data-wrn-navbar]"),
|
||||
);
|
||||
}
|
||||
|
||||
navbars.forEach(function (navbar) {
|
||||
var current = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
var best = null;
|
||||
var bestLength = -1;
|
||||
navbar.querySelectorAll(".wire-navbar__menus a[href]").forEach(function (link) {
|
||||
var url;
|
||||
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
|
||||
if (url.origin !== window.location.origin) return;
|
||||
var path = url.pathname.replace(/\/+$/, "") || "/";
|
||||
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
|
||||
if (!matches || path.length <= bestLength) return;
|
||||
best = link;
|
||||
bestLength = path.length;
|
||||
});
|
||||
navbar.querySelectorAll(".wire-navbar__menus [aria-current='page']").forEach(function (item) {
|
||||
item.removeAttribute("aria-current");
|
||||
});
|
||||
if (best) {
|
||||
best.setAttribute("aria-current", "page");
|
||||
var parentMenu = best.closest(".wire-navbar__dropdown");
|
||||
var parentSummary = parentMenu && parentMenu.querySelector(":scope > summary");
|
||||
if (parentSummary) parentSummary.setAttribute("aria-current", "page");
|
||||
}
|
||||
|
||||
if (navbar.dataset.wrnNavbarBound === "true") return;
|
||||
navbar.dataset.wrnNavbarBound = "true";
|
||||
var hoverEnabled = navbar.getAttribute("data-open-on-hover") === "true";
|
||||
var hoverCapable =
|
||||
typeof window.matchMedia !== "function" ||
|
||||
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
if (hoverEnabled && hoverCapable) {
|
||||
navbar.querySelectorAll(".wire-navbar__dropdown").forEach(function (menu) {
|
||||
menu.addEventListener("pointerenter", function () {
|
||||
if (menu.__wrnNavbarCloseTimer) {
|
||||
clearTimeout(menu.__wrnNavbarCloseTimer);
|
||||
menu.__wrnNavbarCloseTimer = null;
|
||||
}
|
||||
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (candidate) {
|
||||
if (candidate !== menu) candidate.removeAttribute("open");
|
||||
});
|
||||
menu.setAttribute("open", "");
|
||||
});
|
||||
menu.addEventListener("pointerleave", function () {
|
||||
if (menu.__wrnNavbarCloseTimer) clearTimeout(menu.__wrnNavbarCloseTimer);
|
||||
menu.__wrnNavbarCloseTimer = setTimeout(function () {
|
||||
menu.removeAttribute("open");
|
||||
menu.__wrnNavbarCloseTimer = null;
|
||||
}, 240);
|
||||
});
|
||||
});
|
||||
}
|
||||
navbar.addEventListener("toggle", function (event) {
|
||||
var opened = event.target;
|
||||
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
|
||||
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
|
||||
if (menu !== opened) menu.removeAttribute("open");
|
||||
});
|
||||
}, true);
|
||||
});
|
||||
|
||||
if (!navbarOutsideClickBound) {
|
||||
navbarOutsideClickBound = true;
|
||||
document.addEventListener("pointerdown", function (event) {
|
||||
document.querySelectorAll("[data-wrn-navbar]").forEach(function (navbar) {
|
||||
if (navbar.contains(event.target)) return;
|
||||
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("[data-wrn-navbar] .wire-navbar__dropdown[open]").forEach(function (menu) {
|
||||
menu.removeAttribute("open");
|
||||
var summary = menu.querySelector(":scope > summary");
|
||||
if (summary) summary.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var selectOutsideClickBound = false;
|
||||
|
||||
function selectScopeApi(root) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -23,14 +23,21 @@ interface ExecMeta {
|
||||
insertId?: number | bigint;
|
||||
}
|
||||
|
||||
function runnerFor(client: BunSqlClient): TxHandle {
|
||||
/** Convert the framework's portable positional placeholders for PostgreSQL. */
|
||||
export function sqlForDialect(sql: string, dialect: Dialect): string {
|
||||
if (dialect !== "postgres" || !sql.includes("?")) return sql;
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle {
|
||||
return {
|
||||
async query(sql, params = []): Promise<Row[]> {
|
||||
const rows = (await client.unsafe(sql, params)) as Iterable<Row>;
|
||||
const rows = (await client.unsafe(sqlForDialect(sql, dialect), params)) as Iterable<Row>;
|
||||
return Array.from(rows);
|
||||
},
|
||||
async exec(sql, params = []) {
|
||||
const meta = (await client.unsafe(sql, params)) as ExecMeta;
|
||||
const meta = (await client.unsafe(sqlForDialect(sql, dialect), params)) as ExecMeta;
|
||||
const id = meta.lastInsertRowid ?? meta.insertId;
|
||||
return {
|
||||
changes: Number(meta.affectedRows ?? meta.count ?? 0),
|
||||
@@ -44,13 +51,13 @@ function runnerFor(client: BunSqlClient): TxHandle {
|
||||
export function bunSql(url: string, dialect: Dialect): Driver {
|
||||
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
|
||||
const client = new Ctor(url);
|
||||
const runner = runnerFor(client);
|
||||
const runner = runnerFor(client, dialect);
|
||||
return {
|
||||
dialect,
|
||||
query: runner.query,
|
||||
exec: runner.exec,
|
||||
transaction(fn) {
|
||||
return client.begin((tx) => fn(runnerFor(tx)));
|
||||
return client.begin((tx) => fn(runnerFor(tx, dialect)));
|
||||
},
|
||||
close() {
|
||||
return client.close();
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
import type { Db } from "./driver.ts";
|
||||
|
||||
const DEFAULT = "default";
|
||||
const registry = new Map<string, Db>();
|
||||
type DbFactory = () => Db;
|
||||
type RegistryEntry = { db?: Db; factory?: DbFactory };
|
||||
const registry = new Map<string, RegistryEntry>();
|
||||
|
||||
/** Set the default database (called by the runtime at startup). */
|
||||
export function setDb(db: Db): Db;
|
||||
@@ -21,7 +23,7 @@ export function setDb(name: string, db: Db): Db;
|
||||
export function setDb(a: string | Db, b?: Db): Db {
|
||||
const name = typeof a === "string" ? a : DEFAULT;
|
||||
const db = typeof a === "string" ? b! : a;
|
||||
registry.set(name, db);
|
||||
registry.set(name, { db });
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -30,10 +32,18 @@ export function registerDb(name: string, db: Db): Db {
|
||||
return setDb(name, db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a named database without opening its connection pool. The first
|
||||
* `getDb(name)` call creates and caches the connection.
|
||||
*/
|
||||
export function registerLazyDb(name: string, factory: DbFactory): void {
|
||||
registry.set(name, { factory });
|
||||
}
|
||||
|
||||
/** The default database, or a named one. Throws if it isn't configured. */
|
||||
export function getDb(name = DEFAULT): Db {
|
||||
const db = registry.get(name);
|
||||
if (!db) {
|
||||
const entry = registry.get(name);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
name === DEFAULT
|
||||
? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts."
|
||||
@@ -41,7 +51,11 @@ export function getDb(name = DEFAULT): Db {
|
||||
`(e.g. databases: { ${name}: { driver, url } }).`,
|
||||
);
|
||||
}
|
||||
return db;
|
||||
if (!entry.db) {
|
||||
if (!entry.factory) throw new Error(`Database '${name}' has no connection factory.`);
|
||||
entry.db = entry.factory();
|
||||
}
|
||||
return entry.db;
|
||||
}
|
||||
|
||||
/** Whether the default (or a named) database has been configured. */
|
||||
@@ -56,6 +70,8 @@ export function databaseNames(): string[] {
|
||||
|
||||
/** Close every configured database and clear the registry. */
|
||||
export async function closeDatabases(): Promise<void> {
|
||||
for (const db of registry.values()) await db.close();
|
||||
for (const entry of registry.values()) {
|
||||
if (entry.db) await entry.db.close();
|
||||
}
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,15 @@ export { v, table, Column } from "./schema.ts";
|
||||
export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts";
|
||||
export { createDb } from "./driver.ts";
|
||||
export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts";
|
||||
export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts";
|
||||
export {
|
||||
setDb,
|
||||
getDb,
|
||||
hasDb,
|
||||
registerDb,
|
||||
registerLazyDb,
|
||||
databaseNames,
|
||||
closeDatabases,
|
||||
} from "./client.ts";
|
||||
export { createTableSql } from "./sql.ts";
|
||||
export type { Dialect } from "./sql.ts";
|
||||
export {
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
|
||||
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
|
||||
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
|
||||
if (migrations.length === 0) return [];
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.name));
|
||||
const done: string[] = [];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { sqlForDialect } from "../src/adapters/bunsql.ts";
|
||||
|
||||
test("PostgreSQL converts portable placeholders to numbered parameters", () => {
|
||||
expect(sqlForDialect("SELECT * FROM users WHERE id = ? AND status = ?", "postgres")).toBe(
|
||||
"SELECT * FROM users WHERE id = $1 AND status = $2",
|
||||
);
|
||||
});
|
||||
|
||||
test("MySQL and parameter-free PostgreSQL statements remain unchanged", () => {
|
||||
expect(sqlForDialect("SELECT * FROM users WHERE id = ?", "mysql")).toBe(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
);
|
||||
expect(sqlForDialect("CREATE TABLE users (id TEXT)", "postgres")).toBe(
|
||||
"CREATE TABLE users (id TEXT)",
|
||||
);
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
createDb,
|
||||
createTableSql,
|
||||
parseMigration,
|
||||
applyMigrations,
|
||||
migrate,
|
||||
status,
|
||||
rollback,
|
||||
parseQueries,
|
||||
generateQueriesFile,
|
||||
} from "../src/index.ts";
|
||||
import type { Db } from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
import { bunSql } from "../src/adapters/bunsql.ts";
|
||||
|
||||
@@ -95,6 +97,20 @@ test("migration runner: parse, migrate, status, rollback", async () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("an empty migration set does not touch the database", async () => {
|
||||
const unreachable = async () => {
|
||||
throw new Error("database should remain lazy");
|
||||
};
|
||||
const db = {
|
||||
all: unreachable,
|
||||
one: unreachable,
|
||||
exec: unreachable,
|
||||
tx: unreachable,
|
||||
} as unknown as Db;
|
||||
|
||||
expect(await applyMigrations(db, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("query generator infers params and result types", () => {
|
||||
const q = parseQueries(
|
||||
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getDb,
|
||||
hasDb,
|
||||
registerDb,
|
||||
registerLazyDb,
|
||||
databaseNames,
|
||||
closeDatabases,
|
||||
} from "../src/index.ts";
|
||||
@@ -45,3 +46,35 @@ test("getDb throws a helpful error for an unknown named database", async () => {
|
||||
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
|
||||
await closeDatabases();
|
||||
});
|
||||
|
||||
test("named databases can be registered without opening a connection", async () => {
|
||||
await closeDatabases();
|
||||
let calls = 0;
|
||||
const analytics = createDb(sqlite(":memory:"));
|
||||
|
||||
registerLazyDb("analytics", () => {
|
||||
calls++;
|
||||
return analytics;
|
||||
});
|
||||
|
||||
expect(hasDb("analytics")).toBe(true);
|
||||
expect(databaseNames()).toEqual(["analytics"]);
|
||||
expect(calls).toBe(0);
|
||||
expect(getDb("analytics")).toBe(analytics);
|
||||
expect(getDb("analytics")).toBe(analytics);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
await closeDatabases();
|
||||
});
|
||||
|
||||
test("closing the registry does not instantiate unused lazy databases", async () => {
|
||||
await closeDatabases();
|
||||
let calls = 0;
|
||||
registerLazyDb("unused", () => {
|
||||
calls++;
|
||||
return createDb(sqlite(":memory:"));
|
||||
});
|
||||
|
||||
await closeDatabases();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
@@ -150,11 +151,15 @@ export function stripInternalError(res: Response): Response {
|
||||
}
|
||||
|
||||
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
|
||||
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
|
||||
export function forwardAuthFailure(
|
||||
res: Response,
|
||||
verifierUrl: string,
|
||||
verifierPublicOrigin = verifierUrl,
|
||||
): Response {
|
||||
const location = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && location) {
|
||||
try {
|
||||
const redirect = new URL(location, verifierUrl);
|
||||
const redirect = new URL(location, verifierPublicOrigin);
|
||||
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
|
||||
return new Response(null, { status: res.status, headers: { location: redirect.href } });
|
||||
}
|
||||
@@ -197,6 +202,7 @@ async function checkAuth(
|
||||
req: Request,
|
||||
ip: string,
|
||||
internalOrigins: Readonly<Record<string, string>>,
|
||||
publicOrigins: Readonly<Record<string, string>>,
|
||||
publicOrigin?: string,
|
||||
): Promise<Response | null> {
|
||||
if (!auth) return null;
|
||||
@@ -226,6 +232,10 @@ async function checkAuth(
|
||||
|
||||
if (auth.forward) {
|
||||
const verifyUrl = resolveForwardAuthUrl(auth.forward, internalOrigins);
|
||||
const verifierPublicOrigin =
|
||||
typeof auth.forward.app === "string"
|
||||
? publicOrigins[auth.forward.app]
|
||||
: new URL(auth.forward.url).origin;
|
||||
|
||||
try {
|
||||
const res = await fetch(verifyUrl, {
|
||||
@@ -240,7 +250,7 @@ async function checkAuth(
|
||||
`[wrnexus] forward-auth verifier error: ${req.method} ${requestUrl.pathname} via ${verifyUrl} returned ${res.status}${diagnostic ? ` — ${diagnostic}` : ""}`,
|
||||
);
|
||||
}
|
||||
return forwardAuthFailure(res, verifyUrl);
|
||||
return forwardAuthFailure(res, verifyUrl, verifierPublicOrigin);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -351,11 +361,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
const hostname = opts.hostname ?? defaultGatewayHostname(mode);
|
||||
const environment = opts.environment ?? (mode === "production" ? "production" : "development");
|
||||
|
||||
const workspaceOrigins = Object.fromEntries(
|
||||
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
|
||||
const workspaceOrigins: Readonly<Record<string, string>> = Object.freeze(
|
||||
Object.fromEntries(
|
||||
opts.apps.map((app) => [app.name, app.publicOrigin ?? `http://${app.domains[0]}:${port}`]),
|
||||
),
|
||||
);
|
||||
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
|
||||
const serveEntry = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
// When the CLI is executed directly from a framework checkout, keep child
|
||||
// apps on that same source tree. Resolving the package name from an external
|
||||
// workspace can otherwise select its older installed release on restart.
|
||||
const sourceServeEntry = fileURLToPath(new URL("./serve-entry.ts", import.meta.url));
|
||||
const usesSourceServeEntry = existsSync(sourceServeEntry);
|
||||
const serveEntry = usesSourceServeEntry
|
||||
? sourceServeEntry
|
||||
: fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
const frameworkRoot = resolve(dirname(sourceServeEntry), "../../..");
|
||||
|
||||
let stopping = false;
|
||||
const targets: Target[] = opts.apps.map((app, i) => {
|
||||
@@ -397,6 +417,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
// Bun can otherwise resolve bare @wrnexus imports against the
|
||||
// external application's node_modules after an HMR restart.
|
||||
// Keep source-checkout children anchored to the same framework
|
||||
// checkout as the gateway command.
|
||||
cwd: usesSourceServeEntry ? frameworkRoot : dir,
|
||||
env: {
|
||||
...process.env,
|
||||
WRNEXUS_ENV: environment,
|
||||
@@ -496,7 +521,14 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
}
|
||||
|
||||
// Per-app access control (basic auth / IP allowlist / forward-auth).
|
||||
const denied = await checkAuth(target.auth, req, ip, internalOrigins, target.publicOrigin);
|
||||
const denied = await checkAuth(
|
||||
target.auth,
|
||||
req,
|
||||
ip,
|
||||
internalOrigins,
|
||||
workspaceOrigins,
|
||||
target.publicOrigin,
|
||||
);
|
||||
if (denied) {
|
||||
if (sec.accessLog)
|
||||
console.log(
|
||||
|
||||
@@ -16,11 +16,19 @@ import {
|
||||
type ThemeConfig,
|
||||
type MobileConfig,
|
||||
type PwaConfig,
|
||||
type NavigationConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui";
|
||||
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
|
||||
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
|
||||
import { applyMigrations, migrate, setDb, registerDb } from "@wrnexus/db";
|
||||
import {
|
||||
applyMigrations,
|
||||
loadMigrations,
|
||||
migrate,
|
||||
setDb,
|
||||
registerDb,
|
||||
registerLazyDb,
|
||||
} from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
|
||||
import { realtimeBusFromConfig } from "./realtime-bus.ts";
|
||||
@@ -76,6 +84,7 @@ export interface ServeOptions {
|
||||
plugins?: PluginInput;
|
||||
observability?: ObservabilityConfig;
|
||||
tenancy?: TenancyConfig;
|
||||
navigation?: NavigationConfig;
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
@@ -217,6 +226,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
plugins: configuredPlugins,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
navigation: opts.navigation,
|
||||
};
|
||||
|
||||
await pluginRunner.configure(pluginConfig);
|
||||
@@ -304,8 +314,14 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
}
|
||||
};
|
||||
if (opts.db) await connectAndMigrate(null, opts.db);
|
||||
for (const [name, cfg] of Object.entries(opts.databases ?? {}))
|
||||
await connectAndMigrate(name, cfg);
|
||||
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
|
||||
const migrationDir = join(appDir, "db", name, "migrations");
|
||||
const hasMigrations =
|
||||
loadMigrations(migrationDir).length > 0 ||
|
||||
resolvePackageMigrations(pluginContributions.migrations, name).length > 0;
|
||||
if (hasMigrations) await connectAndMigrate(name, cfg);
|
||||
else registerLazyDb(name, () => connectFromConfig(cfg, appRoot));
|
||||
}
|
||||
|
||||
// File-upload storage: build a driver per configured store (local dir / S3).
|
||||
// Relative local dirs resolve against the app root; served/served-back below.
|
||||
@@ -358,6 +374,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
security: opts.security,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
navigation: opts.navigation,
|
||||
clientRuntimes: pluginContributions.clientRuntimes,
|
||||
hub,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
type PwaConfig,
|
||||
type ObservabilityConfig,
|
||||
type TenancyConfig,
|
||||
type NavigationConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
|
||||
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
|
||||
import { setDb, registerDb, getDb, hasDb, migrate } from "@wrnexus/db";
|
||||
import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import {
|
||||
configureStorage,
|
||||
@@ -133,6 +134,8 @@ export interface ProdOptions {
|
||||
observability?: ObservabilityConfig;
|
||||
/** Built-in tenant identity resolution. */
|
||||
tenancy?: TenancyConfig;
|
||||
/** Page navigation strategy. */
|
||||
navigation?: NavigationConfig;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
maxBodyBytes?: number;
|
||||
@@ -287,14 +290,7 @@ export function createProductionHandlers(
|
||||
}
|
||||
}
|
||||
for (const [name, cfg] of Object.entries(opts.databases ?? {})) {
|
||||
try {
|
||||
registerDb(name, connectFromConfig(cfg));
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[wrnexus] database '${name}' setup failed:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
registerLazyDb(name, () => connectFromConfig(cfg));
|
||||
}
|
||||
|
||||
// File-upload storage. Relative local dirs resolve against the deployment cwd
|
||||
@@ -334,6 +330,7 @@ export function createProductionHandlers(
|
||||
security: opts.security,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
navigation: opts.navigation,
|
||||
maxBodyBytes: opts.maxBodyBytes,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
});
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface RuntimeDeps {
|
||||
assetVersion?: string;
|
||||
/** Package browser runtimes resolved by the plugin system. */
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
/** Page navigation strategy. `document` disables same-origin link interception. */
|
||||
navigation?: { mode?: "client" | "document" };
|
||||
/** Raw HTML appended to every page head (e.g. CDN framework links). */
|
||||
head?: string;
|
||||
/** Global SEO defaults. */
|
||||
@@ -1140,9 +1142,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
|
||||
i = end;
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
const normalizedName = normalizeComponentName(name);
|
||||
const component = router.components.find(
|
||||
(candidate) => candidate.name.toLowerCase() === normalizedName,
|
||||
(candidate) => normalizeComponentName(candidate.name) === normalizedName,
|
||||
);
|
||||
if (!component) {
|
||||
console.warn(`[wrnexus] no component registered for '${name}'`);
|
||||
@@ -1312,7 +1314,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
if (deps.i18n) body = translateHtml(body, ctx.t);
|
||||
|
||||
// Point 3: only ship the JS this page actually uses.
|
||||
const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
|
||||
const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) =>
|
||||
versionRenderScript(script, deps.assetVersion),
|
||||
);
|
||||
if (pwaServiceWorkerEnabled)
|
||||
@@ -1669,6 +1671,17 @@ export function resolveTProps(
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize component identifiers for registry lookup.
|
||||
*
|
||||
* WRN declarations use PascalCase (`PublicHeader`) while explicit
|
||||
* `data-component` mounts commonly use kebab-case (`public-header`) or
|
||||
* snake_case (`public_header`). All three forms identify the same component.
|
||||
*/
|
||||
export function normalizeComponentName(name: string): string {
|
||||
return name.toLowerCase().replace(/[-_]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which framework scripts a rendered page needs. Components are already
|
||||
* server-rendered into the HTML; the only script is the reactive runtime, and
|
||||
@@ -1677,10 +1690,11 @@ export function resolveTProps(
|
||||
export function collectScripts(
|
||||
body: string,
|
||||
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
|
||||
navigation: { mode?: "client" | "document" } = {},
|
||||
): RenderScript[] {
|
||||
// Client-side navigation is an app-wide progressive enhancement: it must load
|
||||
// on every page (you navigate *from* any page), and degrades to full loads.
|
||||
const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
|
||||
// Client navigation is optional. In document mode, links retain native browser
|
||||
// behavior and each route receives a fresh server-rendered HTML document.
|
||||
const scripts: RenderScript[] = navigation.mode === "document" ? [] : ["/__wrnexus/nav.js"];
|
||||
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
|
||||
scripts.push("/__wrnexus/reactive.js");
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const server = await startServer({
|
||||
plugins: config.plugins,
|
||||
observability: config.observability,
|
||||
tenancy: config.tenancy,
|
||||
navigation: config.navigation,
|
||||
});
|
||||
|
||||
const router = server.router;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { normalizeComponentName } from "../src/runtime.ts";
|
||||
|
||||
test("component lookup treats PascalCase, kebab-case, and snake_case as equivalent", () => {
|
||||
expect(normalizeComponentName("PublicHeader")).toBe("publicheader");
|
||||
expect(normalizeComponentName("public-header")).toBe("publicheader");
|
||||
expect(normalizeComponentName("public_header")).toBe("publicheader");
|
||||
});
|
||||
|
||||
test("component lookup preserves meaningful alphanumeric differences", () => {
|
||||
expect(normalizeComponentName("Card2")).not.toBe(normalizeComponentName("Card"));
|
||||
expect(normalizeComponentName("PublicFooter")).not.toBe(normalizeComponentName("PublicHeader"));
|
||||
});
|
||||
@@ -49,6 +49,19 @@ test("forward auth preserves intentional verifier redirects", () => {
|
||||
expect(denied.headers.has("location")).toBe(false);
|
||||
});
|
||||
|
||||
test("forward auth exposes the verifier public origin instead of its internal app port", () => {
|
||||
const redirected = forwardAuthFailure(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "/sign-in?returnTo=%2Fadmin" },
|
||||
}),
|
||||
"http://127.0.0.1:85/api/verify",
|
||||
"http://sso.localhost",
|
||||
);
|
||||
|
||||
expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin");
|
||||
});
|
||||
|
||||
test("gateway reads and strips internal app diagnostics", async () => {
|
||||
const response = new Response("safe public error", {
|
||||
status: 500,
|
||||
|
||||
@@ -65,3 +65,11 @@ test("collectScripts automatically adds a referenced package runtime once", () =
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("collectScripts omits client navigation in document mode", () => {
|
||||
const scripts = collectScripts("<main>Server rendered</main>", [], {
|
||||
mode: "document",
|
||||
});
|
||||
|
||||
expect(scripts).not.toContain("/__wrnexus/nav.js");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -16,7 +16,7 @@
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck \u0026\u0026 bun run test"
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -144,6 +144,7 @@ export {
|
||||
currentAppName,
|
||||
currentAppOrigin,
|
||||
workspaceAppOrigins,
|
||||
workspaceRootDomain,
|
||||
} from "./workspace.ts";
|
||||
export {
|
||||
backoffDelay,
|
||||
|
||||
@@ -56,3 +56,26 @@ export function currentAppOrigin(): string | undefined {
|
||||
export function workspaceAppOrigins(): Readonly<Record<string, string>> {
|
||||
return Object.freeze({ ...workspaceOrigins() });
|
||||
}
|
||||
|
||||
/** Shared DNS suffix for configured workspace apps (for example `staging.example.com`). */
|
||||
export function workspaceRootDomain(): string {
|
||||
const hosts = Object.values(workspaceOrigins()).map((origin) =>
|
||||
new URL(origin).hostname.toLowerCase(),
|
||||
);
|
||||
if (!hosts.length) {
|
||||
throw new Error("Workspace origins are unavailable.");
|
||||
}
|
||||
|
||||
const labels = hosts.map((host) => host.split(".").reverse());
|
||||
const common: string[] = [];
|
||||
for (let index = 0; index < labels[0]!.length; index++) {
|
||||
const label = labels[0]![index];
|
||||
if (!labels.every((parts) => parts[index] === label)) break;
|
||||
common.push(label!);
|
||||
}
|
||||
|
||||
if (!common.length) {
|
||||
throw new Error(`Workspace apps do not share a root domain: ${hosts.join(", ")}.`);
|
||||
}
|
||||
return common.reverse().join(".");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,22 @@ import {
|
||||
stableStringify,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("derives the shared workspace root domain from configured public app origins", async () => {
|
||||
const previous = process.env.WRNEXUS_WORKSPACE_ORIGINS;
|
||||
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
||||
admin: "https://admin.staging.example.com",
|
||||
citizen: "https://citizen.staging.example.com",
|
||||
sso: "https://sso.staging.example.com",
|
||||
});
|
||||
try {
|
||||
const { workspaceRootDomain } = await import("../src/workspace.ts");
|
||||
expect(workspaceRootDomain()).toBe("staging.example.com");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS;
|
||||
else process.env.WRNEXUS_WORKSPACE_ORIGINS = previous;
|
||||
}
|
||||
});
|
||||
|
||||
function context(url: string, headers: HeadersInit = {}) {
|
||||
const parsed = new URL(url);
|
||||
return createContext(new Request(parsed, { headers }), parsed);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -156,7 +156,14 @@ export const I18N_RUNTIME = String.raw`
|
||||
location.reload();
|
||||
}
|
||||
function bind(root) {
|
||||
var currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang;
|
||||
(root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
|
||||
switcher.setAttribute("data-current-language", currentLang);
|
||||
var currentLabel = switcher.querySelector(".wire-preferences__current-language");
|
||||
if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase();
|
||||
});
|
||||
(root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) {
|
||||
n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang));
|
||||
if (n.__wireLangBound) return; n.__wireLangBound = 1;
|
||||
n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -182,6 +182,15 @@ export interface BuildConfig {
|
||||
adapter?: "bun" | "node" | "static" | "serverless" | "edge" | string;
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
/**
|
||||
* `client` progressively enhances same-origin links with in-place page swaps.
|
||||
* `document` keeps normal browser navigation so every route performs a fresh
|
||||
* server-rendered document request.
|
||||
*/
|
||||
mode?: "client" | "document";
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
/** Compiler/dev/build plugins, resolved in deterministic pre/normal/post order. */
|
||||
plugins?: PluginInput;
|
||||
@@ -195,6 +204,8 @@ export interface AppConfig {
|
||||
tenancy?: TenancyConfig;
|
||||
/** Build cache, source map, report, and deployment adapter settings. */
|
||||
build?: BuildConfig;
|
||||
/** Page navigation strategy. Defaults to progressive client navigation. */
|
||||
navigation?: NavigationConfig;
|
||||
/** Development-only page diagnostics toolbar. Enabled by default in development. */
|
||||
devToolbar?: boolean | DevToolbarConfig;
|
||||
/** Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet links). */
|
||||
@@ -294,8 +305,12 @@ export async function loadRawConfig(appRoot: string): Promise<AppConfig> {
|
||||
|
||||
/** Load `wrnexus.config.*`, applying the active profile's overrides. */
|
||||
export async function loadAppConfig(appRoot: string, profile?: string): Promise<AppConfig> {
|
||||
const base = await loadRawConfig(appRoot);
|
||||
const active = profile ?? resolveProfile();
|
||||
// Configuration modules commonly read DATABASE_URL and other settings during
|
||||
// module evaluation. Load the profile cascade before importing the module so
|
||||
// development and production behave consistently.
|
||||
loadEnv(appRoot, active);
|
||||
const base = await loadRawConfig(appRoot);
|
||||
const override = base.profiles?.[active];
|
||||
const merged: AppConfig = override ? deepMerge(base, override) : { ...base };
|
||||
delete merged.profiles;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
ExplainedConfig,
|
||||
ExperimentalConfig,
|
||||
MobileConfig,
|
||||
NavigationConfig,
|
||||
ObservabilityConfig,
|
||||
PerformanceConfig,
|
||||
TenancyConfig,
|
||||
|
||||
@@ -243,18 +243,18 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
|
||||
"color-error": "#dc2626",
|
||||
radius: "8px",
|
||||
"radius-sm": "5px",
|
||||
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
|
||||
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
|
||||
},
|
||||
dark: {
|
||||
"color-scheme": "dark",
|
||||
"color-bg": "#0b1020",
|
||||
"color-background": "#0b1020",
|
||||
"color-foreground": "#e7ecff",
|
||||
"color-surface-raised": "#1a223b",
|
||||
"color-surface-muted": "#1c243f",
|
||||
"color-text-muted": "#aab4d8",
|
||||
"color-text-subtle": "#8995bd",
|
||||
"color-bg": "#000000",
|
||||
"color-background": "#000000",
|
||||
"color-foreground": "#f5f5f5",
|
||||
"color-surface-raised": "#111111",
|
||||
"color-surface-muted": "#181818",
|
||||
"color-text-muted": "#b3b3b3",
|
||||
"color-text-subtle": "#8a8a8a",
|
||||
"color-border-strong": "#ffffff38",
|
||||
"color-primary-soft": "#6c8cff24",
|
||||
"color-secondary-soft": "#a78bfa24",
|
||||
@@ -262,10 +262,10 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
|
||||
"color-success-soft": "#4ade8024",
|
||||
"color-warning-soft": "#fbbf2424",
|
||||
"color-danger-soft": "#f8717124",
|
||||
"color-code-background": "#060a15",
|
||||
"color-code-surface": "#10172a",
|
||||
"color-code-text": "#e7ecff",
|
||||
"color-code-muted": "#9aa6d0",
|
||||
"color-code-background": "#050505",
|
||||
"color-code-surface": "#101010",
|
||||
"color-code-text": "#f5f5f5",
|
||||
"color-code-muted": "#a3a3a3",
|
||||
"color-code-border": "#ffffff24",
|
||||
"shadow-sm": "0 1px 2px rgba(0,0,0,0.28)",
|
||||
"shadow-md": "0 10px 30px rgba(0,0,0,0.35)",
|
||||
@@ -273,17 +273,17 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
|
||||
"space-section": "5rem",
|
||||
"space-section-sm": "3.5rem",
|
||||
"container-max": "80rem",
|
||||
"color-surface": "#141a30",
|
||||
"color-surface-2": "#1c243f",
|
||||
"color-text": "#e7ecff",
|
||||
"color-muted": "#9aa6d0",
|
||||
"color-surface": "#0a0a0a",
|
||||
"color-surface-2": "#171717",
|
||||
"color-text": "#f5f5f5",
|
||||
"color-muted": "#a3a3a3",
|
||||
"color-border": "#ffffff1f",
|
||||
"color-primary": "#6c8cff",
|
||||
"color-primary-hover": "#8aa2ff",
|
||||
"color-primary-contrast": "#0b1020",
|
||||
"color-primary-contrast": "#000000",
|
||||
"color-secondary": "#a78bfa",
|
||||
"color-secondary-hover": "#c4b5fd",
|
||||
"color-secondary-contrast": "#0b1020",
|
||||
"color-secondary-contrast": "#000000",
|
||||
"color-info": "#38bdf8",
|
||||
"color-danger": "#f87171",
|
||||
"color-success": "#4ade80",
|
||||
@@ -291,7 +291,7 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
|
||||
"color-error": "#f87171",
|
||||
radius: "10px",
|
||||
"radius-sm": "6px",
|
||||
"font-sans": "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
|
||||
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
|
||||
},
|
||||
};
|
||||
@@ -338,7 +338,10 @@ export function renderThemeCss(theme: ResolvedTheme): string {
|
||||
for (const name of theme.names) {
|
||||
blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`);
|
||||
}
|
||||
return blocks.join("\n") + "\n";
|
||||
return (
|
||||
blocks.join("\n") +
|
||||
'\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,14 +354,59 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
return `(function(){
|
||||
var COOKIE=${JSON.stringify(THEME_COOKIE)};
|
||||
var THEMES=${names};
|
||||
var ACCENT_COOKIE="wire-accent";
|
||||
var COOKIE_MAX_AGE=31536000;
|
||||
var ACCENTS={
|
||||
blue:["#2563eb","#1d4ed8","#ffffff"],
|
||||
cyan:["#0891b2","#0e7490","#ffffff"],
|
||||
emerald:["#059669","#047857","#ffffff"],
|
||||
violet:["#7c3aed","#6d28d9","#ffffff"],
|
||||
rose:["#e11d48","#be123c","#ffffff"],
|
||||
amber:["#d97706","#b45309","#ffffff"]
|
||||
};
|
||||
var el=document.documentElement;
|
||||
function readCookie(name){
|
||||
var prefix=encodeURIComponent(name)+"=";
|
||||
var parts=document.cookie?document.cookie.split(";"):[];
|
||||
for(var i=0;i<parts.length;i++){
|
||||
var part=parts[i].trim();
|
||||
if(part.indexOf(prefix)===0){
|
||||
try{return decodeURIComponent(part.slice(prefix.length));}catch(_){return part.slice(prefix.length);}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function writeCookie(name,value){
|
||||
var secure=location.protocol==="https:"?";secure":"";
|
||||
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+";path=/;max-age="+COOKIE_MAX_AGE+";samesite=lax"+secure;
|
||||
}
|
||||
function get(){return el.getAttribute("data-theme")||${JSON.stringify(theme.default)};}
|
||||
function syncThemeButtons(root){
|
||||
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
|
||||
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-theme-set")===get()));
|
||||
});
|
||||
}
|
||||
function syncAccentButtons(root,name){
|
||||
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
|
||||
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-accent-set")===name));
|
||||
});
|
||||
}
|
||||
function set(name){
|
||||
if(THEMES.indexOf(name)<0)return;
|
||||
el.setAttribute("data-theme",name);
|
||||
document.cookie=COOKIE+"="+encodeURIComponent(name)+";path=/;max-age=31536000;samesite=lax";
|
||||
writeCookie(COOKIE,name);
|
||||
syncThemeButtons(document);
|
||||
}
|
||||
function toggle(){var i=THEMES.indexOf(get());set(THEMES[(i+1)%THEMES.length]);}
|
||||
function setAccent(name){
|
||||
var value=ACCENTS[name];if(!value)return;
|
||||
el.style.setProperty("--wire-color-primary",value[0]);
|
||||
el.style.setProperty("--wire-color-primary-hover",value[1]);
|
||||
el.style.setProperty("--wire-color-primary-contrast",value[2]);
|
||||
el.setAttribute("data-accent",name);
|
||||
writeCookie(ACCENT_COOKIE,name);
|
||||
syncAccentButtons(document,name);
|
||||
}
|
||||
function bind(root){
|
||||
(root||document).querySelectorAll("[data-wire-theme-toggle]").forEach(function(n){
|
||||
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
|
||||
@@ -368,8 +416,20 @@ export function renderThemeRuntime(theme: ResolvedTheme): string {
|
||||
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
|
||||
n.addEventListener("click",function(){set(n.getAttribute("data-wire-theme-set"));});
|
||||
});
|
||||
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
|
||||
if(n.__wireAccentBound)return;n.__wireAccentBound=1;
|
||||
n.addEventListener("click",function(){setAccent(n.getAttribute("data-wire-accent-set"));});
|
||||
});
|
||||
syncThemeButtons(root);
|
||||
syncAccentButtons(root,el.getAttribute("data-accent")||"blue");
|
||||
}
|
||||
function getAccent(){
|
||||
var current=el.getAttribute("data-accent")||readCookie(ACCENT_COOKIE)||"blue";
|
||||
return ACCENTS[current]?current:"blue";
|
||||
}
|
||||
window.wireTheme={get:get,set:set,toggle:toggle,bind:bind,themes:THEMES};
|
||||
window.wireAccent={get:getAccent,set:setAccent,colors:Object.keys(ACCENTS)};
|
||||
setAccent(getAccent());
|
||||
window.addEventListener("wrnexus:navigated",function(){bind(document);});
|
||||
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){bind(document);});
|
||||
else bind(document);
|
||||
|
||||
@@ -67,6 +67,26 @@ test("loadEnv layers .env files by precedence; real env always wins", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("loadAppConfig loads env before evaluating the config module", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-config-env-"));
|
||||
writeFileSync(join(dir, ".env"), "DATABASE_URL=postgres://pms-from-env\n");
|
||||
writeFileSync(
|
||||
join(dir, "wrnexus.config.mjs"),
|
||||
`export default {
|
||||
db: { driver: "postgres", url: process.env.DATABASE_URL },
|
||||
};`,
|
||||
);
|
||||
|
||||
try {
|
||||
delete process.env.DATABASE_URL;
|
||||
const config = await loadAppConfig(dir, "development");
|
||||
expect(config.db?.url).toBe("postgres://pms-from-env");
|
||||
} finally {
|
||||
delete process.env.DATABASE_URL;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("package-aware styles add relative imports and Tailwind scan sources", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
|
||||
try {
|
||||
|
||||
@@ -54,6 +54,17 @@ test("resolveThemeConfig merges user tokens over built-in light/dark", () => {
|
||||
expect(t.themes.dark["color-bg"]).toBeDefined(); // built-in kept
|
||||
});
|
||||
|
||||
test("built-in themes use Plus Jakarta Sans and neutral black dark surfaces", () => {
|
||||
const t = resolveThemeConfig();
|
||||
expect(t.themes.light["font-sans"]).toBe(
|
||||
'"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
|
||||
);
|
||||
expect(t.themes.dark["font-sans"]).toBe('"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif');
|
||||
expect(t.themes.dark["color-bg"]).toBe("#000000");
|
||||
expect(t.themes.dark["color-surface"]).toBe("#0a0a0a");
|
||||
expect(t.themes.dark["color-surface-2"]).toBe("#171717");
|
||||
});
|
||||
|
||||
test("resolveThemeName validates against configured names", () => {
|
||||
const t = resolveThemeConfig();
|
||||
expect(resolveThemeName("light", t)).toBe("light");
|
||||
@@ -71,6 +82,7 @@ test("renderThemeCss emits :root + per-theme blocks and --wire-* vars", () => {
|
||||
expect(css).toContain('[data-theme="dark"]{');
|
||||
expect(css).toContain("--wire-color-primary:#6c8cff");
|
||||
expect(css).toContain("color-scheme:dark"); // reserved token → native property
|
||||
expect(css).toContain("font-family:var(--wire-font-sans");
|
||||
});
|
||||
|
||||
test("renderThemeRuntime bakes the theme names for cycling", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -409,7 +409,11 @@ export function parse(source: string): PageAst {
|
||||
} else {
|
||||
expect("eq");
|
||||
}
|
||||
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
|
||||
// State values may be multiline structured expressions. Use the same
|
||||
// balanced initializer reader as props so formatted arrays/objects
|
||||
// remain one declaration instead of exposing their inner braces as
|
||||
// component members on the next line.
|
||||
states.push({ name: sName, valueType, expr: lx.readPropInitializer() });
|
||||
break;
|
||||
}
|
||||
case "computed": {
|
||||
|
||||
@@ -30,6 +30,32 @@ component Navigation {
|
||||
expect(sidebar.attrs.find((attr) => attr.name === "options")?.value).toBe('{{"dense":true}}');
|
||||
});
|
||||
|
||||
test("parses formatted multiline arrays and objects in state", () => {
|
||||
const ast = parse(`component Navigation {
|
||||
state items = [
|
||||
{
|
||||
"label": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"label": "Services",
|
||||
"children": [
|
||||
{ "label": "Reports", "href": "/reports" }
|
||||
]
|
||||
}
|
||||
]
|
||||
state options = {
|
||||
"dense": true,
|
||||
"columns": 4
|
||||
}
|
||||
view { <nav></nav> }
|
||||
}`);
|
||||
|
||||
expect(ast.states[0]?.expr).toContain('"Services"');
|
||||
expect(ast.states[0]?.expr).toContain('"Reports"');
|
||||
expect(ast.states[1]?.expr).toContain('"columns": 4');
|
||||
});
|
||||
|
||||
test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => {
|
||||
const ast = parse(`page Dashboard {
|
||||
runtime = "universal"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
+57
-10
@@ -1,6 +1,6 @@
|
||||
# WRNexus UI component reference
|
||||
|
||||
This reference is generated from the 85 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
|
||||
This reference is generated from the 90 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
|
||||
|
||||
## Advanced-forms
|
||||
|
||||
@@ -330,15 +330,35 @@ Theme-aware, responsive tree view component.
|
||||
- Slots: `default`
|
||||
- Events: `select`, `toggle`, `expand`, `collapse`
|
||||
|
||||
## Blocks
|
||||
|
||||
### PortalDashboard
|
||||
|
||||
Responsive portal dashboard with metrics, actions, tasks, and updates.
|
||||
|
||||
- Mount: `data-component="PortalDashboard"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: string = []`, `actions: string = []`, `updates: string = []`, `tasks: string = []`, `class: string = ""`
|
||||
- Slots: `hero-action`
|
||||
- Events: `action`, `navigate`
|
||||
|
||||
## Forms
|
||||
|
||||
### AuthForm
|
||||
|
||||
Reusable authentication form for sign-in, registration, recovery, reset, and MFA.
|
||||
|
||||
- Mount: `data-component="AuthForm"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: `submit`, `change`, `input`, `focus`, `blur`
|
||||
|
||||
### Checkbox
|
||||
|
||||
Theme-aware, responsive checkbox component.
|
||||
|
||||
- Mount: `data-component="Checkbox"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: string = []`, `options: string = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Slots: `default`
|
||||
- Events: `input`, `change`, `focus`, `blur`, `invalid`
|
||||
|
||||
### ColorPicker
|
||||
@@ -543,6 +563,15 @@ Theme-aware, responsive wysiwyg editor component.
|
||||
|
||||
## Layout
|
||||
|
||||
### AuthSplitLayout
|
||||
|
||||
Responsive 50/50 authentication layout with content and form regions.
|
||||
|
||||
- Mount: `data-component="AuthSplitLayout"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: string = []`, `class: string = ""`
|
||||
- Slots: `aside-extra`, `form`
|
||||
- Events: None
|
||||
|
||||
### Columns
|
||||
|
||||
Theme-aware, responsive columns component.
|
||||
@@ -644,6 +673,15 @@ Theme-aware, responsive breadcrumb component.
|
||||
- Slots: `default`
|
||||
- Events: `navigate`, `click`
|
||||
|
||||
### Footer
|
||||
|
||||
Responsive application footer with structured links and pre/post content slots.
|
||||
|
||||
- Mount: `data-component="Footer"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: string = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""`
|
||||
- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right`
|
||||
- Events: `select`, `action`
|
||||
|
||||
### MegaMenu
|
||||
|
||||
Theme-aware, responsive mega menu component.
|
||||
@@ -667,9 +705,9 @@ Theme-aware, responsive nav component.
|
||||
Theme-aware, responsive navbar component.
|
||||
|
||||
- Mount: `data-component="Navbar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Navbar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: `toggle`, `open`, `close`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: string = {}`, `items: string = []`, `actions: string = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""`
|
||||
- Slots: `topbar`, `actions`
|
||||
- Events: `toggle`, `open`, `close`, `select`, `action`
|
||||
|
||||
### Pagination
|
||||
|
||||
@@ -680,6 +718,15 @@ Theme-aware, responsive pagination component.
|
||||
- Slots: `default`
|
||||
- Events: `change`, `previous`, `next`
|
||||
|
||||
### PreferenceSwitcher
|
||||
|
||||
Accessible theme, accent color, and language preference controls.
|
||||
|
||||
- Mount: `data-component="PreferenceSwitcher"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Events: `theme`, `color`, `language`
|
||||
|
||||
### Scrollspy
|
||||
|
||||
Theme-aware, responsive scrollspy component.
|
||||
@@ -694,9 +741,9 @@ Theme-aware, responsive scrollspy component.
|
||||
Theme-aware, responsive sidebar component.
|
||||
|
||||
- Mount: `data-component="Sidebar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: string = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: `toggle`, `open`, `close`
|
||||
- Events: `toggle`, `open`, `close`, `select`
|
||||
|
||||
### Stepper
|
||||
|
||||
@@ -741,9 +788,9 @@ Theme-aware, responsive drawer component.
|
||||
Theme-aware, responsive dropdown component.
|
||||
|
||||
- Mount: `data-component="Dropdown"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Dropdown"`, `description: string = ""`, `open: boolean = false`, `placement: string = "bottom"`, `closeLabel: string = "Close"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Events: `open`, `close`, `select`, `change`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Open menu"`, `items: string = []`, `placement: string = "end"`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `footer`
|
||||
- Events: `toggle`, `open`, `close`, `select`
|
||||
|
||||
### Modal
|
||||
|
||||
|
||||
@@ -191,6 +191,11 @@
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive navbar component."
|
||||
},
|
||||
{
|
||||
"name": "Footer",
|
||||
"category": "navigation",
|
||||
"purpose": "Responsive application footer with structured links and pre/post content slots."
|
||||
},
|
||||
{
|
||||
"name": "MegaMenu",
|
||||
"category": "navigation",
|
||||
@@ -429,6 +434,26 @@
|
||||
"name": "WysiwygEditor",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive wysiwyg editor component."
|
||||
},
|
||||
{
|
||||
"name": "PreferenceSwitcher",
|
||||
"category": "navigation",
|
||||
"purpose": "Accessible theme, accent color, and language preference controls."
|
||||
},
|
||||
{
|
||||
"name": "PortalDashboard",
|
||||
"category": "blocks",
|
||||
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates."
|
||||
},
|
||||
{
|
||||
"name": "AuthSplitLayout",
|
||||
"category": "layout",
|
||||
"purpose": "Responsive 50/50 authentication layout with content and form regions."
|
||||
},
|
||||
{
|
||||
"name": "AuthForm",
|
||||
"category": "forms",
|
||||
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generatedFrom": "packages/ui/components/*.wrn",
|
||||
"count": 85,
|
||||
"count": 90,
|
||||
"components": [
|
||||
{
|
||||
"name": "Accordion",
|
||||
@@ -675,6 +675,154 @@
|
||||
"events": ["dismiss", "action"],
|
||||
"source": "components/alert.wrn"
|
||||
},
|
||||
{
|
||||
"name": "AuthForm",
|
||||
"mount": "AuthForm",
|
||||
"category": "forms",
|
||||
"purpose": "Reusable authentication form for sign-in, registration, recovery, reset, and MFA.",
|
||||
"props": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"sign-in\""
|
||||
},
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"/api/auth/login\""
|
||||
},
|
||||
{
|
||||
"name": "method",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"post\""
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Sign in\""
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "returnTo",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "schema",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "showRemember",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "showName",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "submitLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Continue\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": ["submit", "change", "input", "focus", "blur"],
|
||||
"source": "components/AuthForm.wrn"
|
||||
},
|
||||
{
|
||||
"name": "AuthSplitLayout",
|
||||
"mount": "AuthSplitLayout",
|
||||
"category": "layout",
|
||||
"purpose": "Responsive 50/50 authentication layout with content and form regions.",
|
||||
"props": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "eyebrow",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Secure identity\""
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Welcome back\""
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "brand",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Police Management System\""
|
||||
},
|
||||
{
|
||||
"name": "features",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["aside-extra", "form"],
|
||||
"events": [],
|
||||
"source": "components/AuthSplitLayout.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Avatar",
|
||||
"mount": "Avatar",
|
||||
@@ -2062,7 +2210,7 @@
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": [],
|
||||
"slots": ["default"],
|
||||
"events": ["input", "change", "focus", "blur", "invalid"],
|
||||
"source": "components/Checkbox.wrn"
|
||||
},
|
||||
@@ -3436,34 +3584,22 @@
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"name": "label",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Dropdown\""
|
||||
"default": "\"Open menu\""
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"name": "items",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "open",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "placement",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"bottom\""
|
||||
},
|
||||
{
|
||||
"name": "closeLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Close\""
|
||||
"default": "\"end\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
@@ -3472,8 +3608,8 @@
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": ["open", "close", "select", "change"],
|
||||
"slots": ["trigger", "header", "footer"],
|
||||
"events": ["toggle", "open", "close", "select"],
|
||||
"source": "components/Dropdown.wrn"
|
||||
},
|
||||
{
|
||||
@@ -3755,6 +3891,65 @@
|
||||
"events": ["cancel", "retry", "complete"],
|
||||
"source": "components/FileUploadProgress.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Footer",
|
||||
"mount": "Footer",
|
||||
"category": "navigation",
|
||||
"purpose": "Responsive application footer with structured links and pre/post content slots.",
|
||||
"props": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "label",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Footer navigation\""
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "columns",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"default": "3"
|
||||
},
|
||||
{
|
||||
"name": "maxWidth",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"compact\""
|
||||
},
|
||||
{
|
||||
"name": "copyright",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["pre-footer", "post-footer", "copyright-left", "copyright-right"],
|
||||
"events": ["select", "action"],
|
||||
"source": "components/Footer.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Grid",
|
||||
"mount": "Grid",
|
||||
@@ -5039,7 +5234,19 @@
|
||||
"name": "label",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Navbar\""
|
||||
"default": "\"Primary navigation\""
|
||||
},
|
||||
{
|
||||
"name": "topbarLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Utility navigation\""
|
||||
},
|
||||
{
|
||||
"name": "brand",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "{}"
|
||||
},
|
||||
{
|
||||
"name": "items",
|
||||
@@ -5047,6 +5254,12 @@
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "actions",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "active",
|
||||
"type": "string",
|
||||
@@ -5054,10 +5267,28 @@
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "orientation",
|
||||
"name": "sticky",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "openOnHover",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "maxWidth",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"horizontal\""
|
||||
"default": "\"full\""
|
||||
},
|
||||
{
|
||||
"name": "mobileLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Toggle navigation\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
@@ -5066,8 +5297,8 @@
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": ["toggle", "open", "close"],
|
||||
"slots": ["topbar", "actions"],
|
||||
"events": ["toggle", "open", "close", "select", "action"],
|
||||
"source": "components/Navbar.wrn"
|
||||
},
|
||||
{
|
||||
@@ -5349,6 +5580,166 @@
|
||||
"events": ["open", "close"],
|
||||
"source": "components/Popover.wrn"
|
||||
},
|
||||
{
|
||||
"name": "PortalDashboard",
|
||||
"mount": "PortalDashboard",
|
||||
"category": "blocks",
|
||||
"purpose": "Responsive portal dashboard with metrics, actions, tasks, and updates.",
|
||||
"props": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "eyebrow",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Overview\""
|
||||
},
|
||||
{
|
||||
"name": "eyebrowKey",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Dashboard\""
|
||||
},
|
||||
{
|
||||
"name": "titleKey",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "descriptionKey",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "userName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
},
|
||||
{
|
||||
"name": "metrics",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "actions",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "updates",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "tasks",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "[]"
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": ["hero-action"],
|
||||
"events": ["action", "navigate"],
|
||||
"source": "components/PortalDashboard.wrn"
|
||||
},
|
||||
{
|
||||
"name": "PreferenceSwitcher",
|
||||
"mount": "PreferenceSwitcher",
|
||||
"category": "navigation",
|
||||
"purpose": "Accessible theme, accent color, and language preference controls.",
|
||||
"props": [
|
||||
{
|
||||
"name": "size",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"default\""
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"primary\""
|
||||
},
|
||||
{
|
||||
"name": "themeLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Theme\""
|
||||
},
|
||||
{
|
||||
"name": "colorLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Accent color\""
|
||||
},
|
||||
{
|
||||
"name": "languageLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Language\""
|
||||
},
|
||||
{
|
||||
"name": "languages",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "["
|
||||
},
|
||||
{
|
||||
"name": "colors",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "["
|
||||
},
|
||||
{
|
||||
"name": "compact",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\""
|
||||
}
|
||||
],
|
||||
"slots": [],
|
||||
"events": ["theme", "color", "language"],
|
||||
"source": "components/PreferenceSwitcher.wrn"
|
||||
},
|
||||
{
|
||||
"name": "Progress",
|
||||
"mount": "Progress",
|
||||
@@ -6110,6 +6501,12 @@
|
||||
"required": false,
|
||||
"default": "\"horizontal\""
|
||||
},
|
||||
{
|
||||
"name": "mobileLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"Open navigation\""
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
@@ -6118,7 +6515,7 @@
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"events": ["toggle", "open", "close"],
|
||||
"events": ["toggle", "open", "close", "select"],
|
||||
"source": "components/Sidebar.wrn"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
component AuthForm {
|
||||
props {
|
||||
@event submit = function
|
||||
@event change = function
|
||||
@event input = function
|
||||
@event focus = function
|
||||
@event blur = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
mode = "sign-in"
|
||||
action = "/api/auth/login"
|
||||
method = "post"
|
||||
title = "Sign in"
|
||||
description = ""
|
||||
returnTo = ""
|
||||
schema = ""
|
||||
showRemember = true
|
||||
showName = true
|
||||
submitLabel = "Continue"
|
||||
class = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
function schemaName() {
|
||||
if (schema) return schema
|
||||
if (mode === "sign-in") return "auth-login"
|
||||
if (mode === "register") return "auth-register"
|
||||
if (mode === "recover") return "auth-password-request"
|
||||
if (mode === "reset") return "auth-password-reset"
|
||||
if (mode === "mfa") return "auth-mfa"
|
||||
return "auth-empty"
|
||||
}
|
||||
|
||||
function submitForm(event) {
|
||||
$emit("submit", { event: event, mode: mode, action: action })
|
||||
}
|
||||
function fieldEvent(type, event) {
|
||||
const detail = event.detail || {}
|
||||
$emit(type, { event: event, name: detail.name || "", value: detail.value || "" })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section class="wire-auth-form wire-next--color-{color} wire-next--size-{size} {class}">
|
||||
<header>
|
||||
<span class="wire-auth-form__icon icon-[lucide--fingerprint]" aria-hidden="true"></span>
|
||||
<div><h2>{title}</h2>{#if description}<p>{description}</p>{/if}</div>
|
||||
</header>
|
||||
<form
|
||||
action="{action}"
|
||||
method="{method}"
|
||||
data-schema="{schemaName()}"
|
||||
data-wrnexus-runtime="auth"
|
||||
novalidate="true"
|
||||
@submit="submitForm($event)"
|
||||
>
|
||||
{#if returnTo}<input type="hidden" name="returnTo" value="{returnTo}" />{/if}
|
||||
{#if mode === "register" && showName}
|
||||
<Input label="Full name" name="displayName" autocomplete="name" placeholder="Enter your full name" required="true" icon="icon-[lucide--user-round]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
|
||||
{/if}
|
||||
{#if mode === "sign-in" || mode === "register" || mode === "recover"}
|
||||
<Input label="{mode === 'recover' ? 'Registered email or mobile' : 'Email, mobile, or username'}" name="{mode === 'recover' ? 'identifier' : mode === 'register' ? 'email' : 'identifier'}" type="{mode === 'register' ? 'email' : 'text'}" autocomplete="{mode === 'register' ? 'email' : 'username'}" placeholder="{mode === 'recover' ? 'Enter your registered identity' : 'Enter your identity'}" required="true" icon="icon-[lucide--at-sign]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
|
||||
{/if}
|
||||
{#if mode === "sign-in" || mode === "register" || mode === "reset"}
|
||||
<Input label="{mode === 'reset' ? 'New password' : 'Password'}" name="password" type="password" autocomplete="{mode === 'sign-in' ? 'current-password' : 'new-password'}" placeholder="Enter your password" required="true" icon="icon-[lucide--lock-keyhole]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
|
||||
{/if}
|
||||
{#if mode === "register" || mode === "reset"}
|
||||
<Input label="Confirm password" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Enter the password again" required="true" icon="icon-[lucide--shield-check]" iconPosition="start" @change="fieldEvent('change', $event)" @input="fieldEvent('input', $event)" @focus="fieldEvent('focus', $event)" @blur="fieldEvent('blur', $event)" />
|
||||
{/if}
|
||||
{#if mode === "mfa"}
|
||||
<PinInput label="Verification code" name="code" length={6} inputMode="numeric" />
|
||||
{/if}
|
||||
{#if mode === "sign-in" && showRemember}
|
||||
<div class="wire-auth-form__options">
|
||||
<Checkbox label="Remember this device" name="rememberDevice" value="on" />
|
||||
<a href="/forgot-password">Forgot password?</a>
|
||||
</div>
|
||||
{/if}
|
||||
<Button type="submit" label="{submitLabel}" variant="default" color="{color}" size="lg" fullWidth="true" icon="icon-[lucide--arrow-right]" iconPosition="end" class="wire-auth-form__submit" />
|
||||
</form>
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
component AuthSplitLayout {
|
||||
props {
|
||||
size = "default"
|
||||
color = "primary"
|
||||
eyebrow = "Secure identity"
|
||||
title = "Welcome back"
|
||||
description = ""
|
||||
brand = "Police Management System"
|
||||
features = []
|
||||
class = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<main
|
||||
class="wire-auth-split wire-next--color-{color} wire-next--size-{size} {class}"
|
||||
>
|
||||
<section
|
||||
class="wire-auth-split__content"
|
||||
>
|
||||
<div
|
||||
class="wire-auth-split__brand"
|
||||
>
|
||||
<span
|
||||
class="icon-[lucide--shield-check]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<span><strong>{brand}</strong><small>Unified identity and access</small></span>
|
||||
</div>
|
||||
<div
|
||||
class="wire-auth-split__message"
|
||||
>
|
||||
<span
|
||||
class="wire-auth-split__eyebrow"
|
||||
>
|
||||
{eyebrow}
|
||||
</span>
|
||||
<h1>{title}</h1>
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
<div
|
||||
class="wire-auth-split__features"
|
||||
>
|
||||
{#each features as item}
|
||||
<article>
|
||||
<span
|
||||
class="{item.icon || 'icon-[lucide--check-circle-2]'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<span><strong>{item.label}</strong>
|
||||
{#if item.description}
|
||||
<small>{item.description}</small>
|
||||
{/if}
|
||||
</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
<slot
|
||||
name="aside-extra"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
class="wire-auth-split__legal"
|
||||
>
|
||||
Authorised access only · Activity may be monitored and audited.
|
||||
</small>
|
||||
</section>
|
||||
<section
|
||||
class="wire-auth-split__form"
|
||||
>
|
||||
<div
|
||||
class="wire-auth-split__form-inner"
|
||||
>
|
||||
<slot
|
||||
name="form"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,9 @@ component AvatarGroup {
|
||||
role="group"
|
||||
aria-label="Avatar group"
|
||||
>
|
||||
<div class="wire-next__avatar-group-members">
|
||||
<div
|
||||
class="wire-next__avatar-group-members"
|
||||
>
|
||||
{#each visibleMembers() as item}
|
||||
<span
|
||||
class="wire-next__avatar-group-member"
|
||||
@@ -64,18 +66,35 @@ component AvatarGroup {
|
||||
tabindex="{showTooltips && (item.tooltip || item.name) ? '0' : '-1'}"
|
||||
aria-label="{item.name || item.alt || item.initials || 'Group member'}"
|
||||
>
|
||||
<span class="wire-next__avatar-group-avatar">
|
||||
<span
|
||||
class="wire-next__avatar-group-avatar"
|
||||
>
|
||||
{#if item.src}
|
||||
<img src="{item.src}" alt="{item.alt || item.name || ''}" loading="lazy" />
|
||||
<img
|
||||
src="{item.src}"
|
||||
alt="{item.alt || item.name || ''}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else if item.initials}
|
||||
<span aria-hidden="true">{item.initials}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.initials}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="icon-[lucide--user-round]" aria-hidden="true"></span>
|
||||
<span
|
||||
class="icon-[lucide--user-round]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showTooltips && (item.tooltip || item.name)}
|
||||
<span class="wire-next__avatar-group-tooltip" role="tooltip">
|
||||
<span
|
||||
class="wire-next__avatar-group-tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
{item.tooltip || item.name}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -83,7 +102,9 @@ component AvatarGroup {
|
||||
{/each}
|
||||
|
||||
{#if hiddenMembers().length > 0}
|
||||
<span class="wire-next__avatar-group-overflow">
|
||||
<span
|
||||
class="wire-next__avatar-group-overflow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-next__avatar-group-overflow-button"
|
||||
@@ -101,10 +122,19 @@ component AvatarGroup {
|
||||
aria-hidden="{overflowOpen ? 'false' : 'true'}"
|
||||
>
|
||||
{#each hiddenMembers() as item}
|
||||
<span class="wire-next__avatar-group-menu-item" role="menuitem">
|
||||
<span class="wire-next__avatar-group-menu-avatar">
|
||||
<span
|
||||
class="wire-next__avatar-group-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<span
|
||||
class="wire-next__avatar-group-menu-avatar"
|
||||
>
|
||||
{#if item.src}
|
||||
<img src="{item.src}" alt="" loading="lazy" />
|
||||
<img
|
||||
src="{item.src}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span>{item.initials || '?'}</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
component Dropdown {
|
||||
props {
|
||||
@event toggle = function
|
||||
@event open = function
|
||||
@event close = function
|
||||
@event select = function
|
||||
@event change = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
title = "Dropdown"
|
||||
description = ""
|
||||
open = false
|
||||
placement = "bottom"
|
||||
closeLabel = "Close"
|
||||
label = "Open menu"
|
||||
items = []
|
||||
placement = "end"
|
||||
class = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
function toggleMenu(event) {
|
||||
$emit("toggle", { open: event.currentTarget.open })
|
||||
$emit(event.currentTarget.open ? "open" : "close", { source: "trigger" })
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
$emit("select", { item: item, value: item.value || "" })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--dropdown wire-next--placement-{placement} {class}" role="region" aria-modal="false" aria-label="{title}">
|
||||
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header>
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
<slot />
|
||||
</div>
|
||||
<details class="wire-dropdown wire-dropdown--{placement} wire-next--color-{color} wire-next--size-{size} {class}" data-wrn-dropdown @toggle="toggleMenu($event)">
|
||||
<summary aria-label="{label}">
|
||||
<slot name="trigger" />
|
||||
<span class="wire-dropdown__chevron icon-[lucide--chevron-down]" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="wire-dropdown__panel" role="menu" aria-label="{label}">
|
||||
<slot name="header" />
|
||||
{#each items as item}
|
||||
{#if item.type === "divider"}
|
||||
<hr />
|
||||
{:else}
|
||||
{#if item.type === "header"}
|
||||
<span class="wire-dropdown__heading">{item.label}</span>
|
||||
{:else}
|
||||
<a class="wire-dropdown__item {item.danger ? 'wire-dropdown__item--danger' : ''}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" role="menuitem" @click="selectItem(item)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
|
||||
{#if item.external}<span class="icon-[lucide--arrow-up-right]" aria-hidden="true"></span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
component Footer {
|
||||
props {
|
||||
@event select = function
|
||||
@event action = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
label = "Footer navigation"
|
||||
items = []
|
||||
columns = 3
|
||||
maxWidth = "compact"
|
||||
copyright = ""
|
||||
class = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
function groupedItems() {
|
||||
const groups = []
|
||||
let group = null
|
||||
items.forEach((item) => {
|
||||
if (item.type === "header") {
|
||||
group = {
|
||||
heading: item,
|
||||
links: []
|
||||
}
|
||||
groups.push(group)
|
||||
return
|
||||
}
|
||||
if (!group) {
|
||||
group = {
|
||||
heading: null,
|
||||
links: []
|
||||
}
|
||||
groups.push(group)
|
||||
}
|
||||
group.links.push(item)
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
$emit(item.type === "action" ? "action" : "select", {
|
||||
item: item,
|
||||
value: item.value || ""
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<footer class="wire-footer wire-footer--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} wire-footer--columns-{columns} {class}">
|
||||
<div class="wire-footer__grid">
|
||||
<div class="wire-footer__pre">
|
||||
<slot name="pre-footer" />
|
||||
</div>
|
||||
|
||||
<nav class="wire-footer__links" aria-label="{label}">
|
||||
{#each groupedItems() as group}
|
||||
<section class="wire-footer__column">
|
||||
{#if group.heading}
|
||||
<strong class="wire-footer__heading">{group.heading.label}</strong>
|
||||
{/if}
|
||||
<div class="wire-footer__column-links">
|
||||
{#each group.links as item}
|
||||
<a class="wire-footer__link wire-footer__link--{item.type || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectItem(item)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
{#if item.external}<span aria-hidden="true">↗</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="wire-footer__post">
|
||||
<slot name="post-footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if copyright}
|
||||
<div class="wire-footer__bottom">
|
||||
<div class="wire-footer__copyright-left">
|
||||
<slot name="copyright-left" />
|
||||
</div>
|
||||
<div class="wire-footer__copyright">{copyright}</div>
|
||||
<div class="wire-footer__copyright-right">
|
||||
<slot name="copyright-right" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="wire-footer__bottom wire-footer__bottom--slots">
|
||||
<div class="wire-footer__copyright-left">
|
||||
<slot name="copyright-left" />
|
||||
</div>
|
||||
<div class="wire-footer__copyright-right">
|
||||
<slot name="copyright-right" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,138 @@ component Navbar {
|
||||
@event toggle = function
|
||||
@event open = function
|
||||
@event close = function
|
||||
@event select = function
|
||||
@event action = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
label = "Navbar"
|
||||
label = "Primary navigation"
|
||||
topbarLabel = "Utility navigation"
|
||||
brand = {}
|
||||
items = []
|
||||
actions = []
|
||||
active = ""
|
||||
orientation = "horizontal"
|
||||
sticky = false
|
||||
openOnHover = false
|
||||
maxWidth = "full"
|
||||
mobileLabel = "Toggle navigation"
|
||||
class = ""
|
||||
}
|
||||
|
||||
state mobileOpen = false
|
||||
|
||||
functions {
|
||||
function toggleNavigation() {
|
||||
mobileOpen = !mobileOpen
|
||||
$emit("toggle", { open: mobileOpen })
|
||||
$emit(mobileOpen ? "open" : "close", { source: "mobile" })
|
||||
}
|
||||
|
||||
function selectItem(item, level) {
|
||||
mobileOpen = false
|
||||
$emit("select", { item: item, value: item.value || "", level: level })
|
||||
}
|
||||
|
||||
function selectAction(item) {
|
||||
mobileOpen = false
|
||||
$emit("action", { item: item, value: item.value || "" })
|
||||
}
|
||||
|
||||
function isItemActive(item) {
|
||||
if (!item) return false
|
||||
if (item.value && item.value === active) return true
|
||||
if (!item.children || !item.children.length) return false
|
||||
return item.children.some(function (child) {
|
||||
return isItemActive(child)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDropdown(event, item) {
|
||||
$emit(event.currentTarget.open ? "open" : "close", {
|
||||
source: "menu",
|
||||
item: item
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--navbar wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<slot />
|
||||
</nav>
|
||||
<header class="wire-navbar wire-navbar--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} {sticky ? 'wire-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
|
||||
<div class="wire-navbar__topbar" aria-label="{topbarLabel}">
|
||||
<slot name="topbar" />
|
||||
</div>
|
||||
|
||||
<div class="wire-navbar__main">
|
||||
<a class="wire-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
|
||||
{#if brand.logo}
|
||||
<img class="wire-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
|
||||
{/if}
|
||||
{#if brand.icon}
|
||||
<span class="wire-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if brand.label || brand.description}
|
||||
<span class="wire-navbar__brand-copy">
|
||||
{#if brand.label}<strong>{brand.label}</strong>{/if}
|
||||
{#if brand.description}<small>{brand.description}</small>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<button type="button" class="wire-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
|
||||
<nav class="wire-navbar__menus" aria-label="{label}">
|
||||
{#each items as item}
|
||||
{#if item.children && item.children.length}
|
||||
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown($event, item)">
|
||||
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span class="wire-navbar__chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="wire-navbar__panel wire-navbar__panel--columns-{item.columns || 1}">
|
||||
{#if item.description}<p class="wire-navbar__panel-intro">{item.description}</p>{/if}
|
||||
{#each item.children as child}
|
||||
<div class="wire-navbar__group">
|
||||
{#if child.children && child.children.length}
|
||||
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
|
||||
{#if child.description}<small>{child.description}</small>{/if}
|
||||
{#each child.children as nested}
|
||||
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
|
||||
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
|
||||
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="wire-navbar__actions">
|
||||
{#each actions as item}
|
||||
<a class="wire-navbar__action wire-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
component PortalDashboard {
|
||||
props {
|
||||
@event action = function
|
||||
@event navigate = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
eyebrow = "Overview"
|
||||
eyebrowKey = ""
|
||||
title = "Dashboard"
|
||||
titleKey = ""
|
||||
description = ""
|
||||
descriptionKey = ""
|
||||
userName = ""
|
||||
metrics = []
|
||||
actions = []
|
||||
updates = []
|
||||
tasks = []
|
||||
class = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
function chooseAction(item, index) {
|
||||
$emit("action", { item: item, value: item.value || "", index: index })
|
||||
}
|
||||
|
||||
function chooseNavigation(item, index) {
|
||||
$emit("navigate", { item: item, value: item.value || "", index: index })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="wire-portal-dashboard wire-next--color-{color} wire-next--size-{size} {class}">
|
||||
<header class="wire-portal-dashboard__hero">
|
||||
<div>
|
||||
{#if eyebrowKey}<span class="wire-portal-dashboard__eyebrow" data-t="{eyebrowKey}">{eyebrow}</span>{/if}
|
||||
{#if !eyebrowKey}<span class="wire-portal-dashboard__eyebrow">{eyebrow}</span>{/if}
|
||||
<h1>
|
||||
{#if titleKey}<span data-t="{titleKey}">{title}</span>{/if}
|
||||
{#if !titleKey}<span>{title}</span>{/if}
|
||||
{#if userName}, {userName}{/if}
|
||||
</h1>
|
||||
{#if descriptionKey}<p data-t="{descriptionKey}">{description}</p>{/if}
|
||||
{#if !descriptionKey && description}<p>{description}</p>{/if}
|
||||
</div>
|
||||
<div class="wire-portal-dashboard__hero-slot"><slot name="hero-action" /></div>
|
||||
</header>
|
||||
|
||||
<section class="wire-portal-dashboard__metrics" aria-label="Summary">
|
||||
{#each metrics as item}
|
||||
<article class="wire-portal-dashboard__metric">
|
||||
<span class="wire-portal-dashboard__metric-icon {item.icon || 'icon-[lucide--activity]'}" aria-hidden="true"></span>
|
||||
<div><small>{item.label}</small><strong>{item.value}</strong>{#if item.detail}<span>{item.detail}</span>{/if}</div>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<div class="wire-portal-dashboard__grid">
|
||||
<section class="wire-portal-dashboard__panel wire-portal-dashboard__panel--actions">
|
||||
<div class="wire-portal-dashboard__panel-heading"><div><small>Shortcuts</small><h2>Quick actions</h2></div></div>
|
||||
<div class="wire-portal-dashboard__actions">
|
||||
{#each actions as item, index}
|
||||
<a href="{item.href || '#'}" @click="chooseAction(item, index)">
|
||||
<span class="{item.icon || 'icon-[lucide--arrow-up-right]'}" aria-hidden="true"></span>
|
||||
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
|
||||
<span class="icon-[lucide--chevron-right]" aria-hidden="true"></span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wire-portal-dashboard__panel">
|
||||
<div class="wire-portal-dashboard__panel-heading"><div><small>Priority</small><h2>Tasks requiring attention</h2></div></div>
|
||||
<div class="wire-portal-dashboard__list">
|
||||
{#each tasks as item, index}
|
||||
<a href="{item.href || '#'}" @click="chooseNavigation(item, index)">
|
||||
<span class="wire-portal-dashboard__status wire-portal-dashboard__status--{item.status || 'default'}"></span>
|
||||
<span><strong>{item.label}</strong>{#if item.detail}<small>{item.detail}</small>{/if}</span>
|
||||
{#if item.meta}<em>{item.meta}</em>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wire-portal-dashboard__panel wire-portal-dashboard__panel--updates">
|
||||
<div class="wire-portal-dashboard__panel-heading"><div><small>Latest</small><h2>Recent updates</h2></div></div>
|
||||
<div class="wire-portal-dashboard__timeline">
|
||||
{#each updates as item}
|
||||
<article>
|
||||
<span class="{item.icon || 'icon-[lucide--bell]'}" aria-hidden="true"></span>
|
||||
<div><strong>{item.label}</strong>{#if item.detail}<p>{item.detail}</p>{/if}<small>{item.time || ''}</small></div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
component PreferenceSwitcher {
|
||||
props {
|
||||
@event theme = function
|
||||
@event color = function
|
||||
@event language = function
|
||||
size = "default"
|
||||
color = "primary"
|
||||
themeLabel = "Theme"
|
||||
colorLabel = "Accent color"
|
||||
languageLabel = "Language"
|
||||
languages = [
|
||||
{"label": "English", "shortLabel": "EN", "value": "en"},
|
||||
{"label": "हिन्दी", "shortLabel": "हि", "value": "hi"},
|
||||
{"label": "मराठी", "shortLabel": "म", "value": "mr"}
|
||||
]
|
||||
colors = [
|
||||
{"label": "Blue", "value": "blue", "hex": "#2563eb"},
|
||||
{"label": "Cyan", "value": "cyan", "hex": "#0891b2"},
|
||||
{"label": "Emerald", "value": "emerald", "hex": "#059669"},
|
||||
{"label": "Violet", "value": "violet", "hex": "#7c3aed"},
|
||||
{"label": "Rose", "value": "rose", "hex": "#e11d48"},
|
||||
{"label": "Amber", "value": "amber", "hex": "#d97706"}
|
||||
]
|
||||
compact = true
|
||||
class = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
function choose(type, value) {
|
||||
$emit(type, { value: value })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-preferences wire-next--color-{color} wire-next--size-{size} {compact ? 'wire-preferences--compact' : ''} {class}" data-wrn-preferences aria-label="Display and language preferences">
|
||||
<details class="wire-preferences__menu" name="wire-preference-menu">
|
||||
<summary aria-label="{themeLabel}" title="{themeLabel}">
|
||||
<span class="icon-[lucide--sun-moon]" aria-hidden="true"></span>
|
||||
{#if !compact}<span>{themeLabel}</span>{/if}
|
||||
</summary>
|
||||
<div class="wire-preferences__panel">
|
||||
<strong>{themeLabel}</strong>
|
||||
<div class="wire-preferences__options">
|
||||
<button type="button" data-wire-theme-set="light" @click="choose('theme', 'light')"><span class="icon-[lucide--sun]" aria-hidden="true"></span>Light</button>
|
||||
<button type="button" data-wire-theme-set="dark" @click="choose('theme', 'dark')"><span class="icon-[lucide--moon]" aria-hidden="true"></span>Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wire-preferences__menu" name="wire-preference-menu">
|
||||
<summary aria-label="{colorLabel}" title="{colorLabel}">
|
||||
<span class="icon-[lucide--palette]" aria-hidden="true"></span>
|
||||
{#if !compact}<span>{colorLabel}</span>{/if}
|
||||
</summary>
|
||||
<div class="wire-preferences__panel wire-preferences__panel--colors">
|
||||
<strong>{colorLabel}</strong>
|
||||
<div class="wire-preferences__swatches">
|
||||
{#each colors as item}
|
||||
<button type="button" data-wire-accent-set="{item.value}" aria-label="{item.label}" title="{item.label}" style="--wire-preference-swatch: {item.hex}" @click="choose('color', item.value)"></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wire-preferences__menu wire-preferences__menu--language" name="wire-preference-menu">
|
||||
<summary aria-label="{languageLabel}" title="{languageLabel}">
|
||||
<span class="icon-[lucide--languages]" aria-hidden="true"></span>
|
||||
<span class="wire-preferences__current-language" aria-hidden="true">EN</span>
|
||||
{#if !compact}<span>{languageLabel}</span>{/if}
|
||||
</summary>
|
||||
<div class="wire-preferences__panel wire-preferences__panel--language">
|
||||
<strong>{languageLabel}</strong>
|
||||
{#each languages as item}
|
||||
<button type="button" data-wire-lang-set="{item.value}" lang="{item.value}" @click="choose('language', item.value)">
|
||||
<span>{item.shortLabel}</span>{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user