release: WRNexusJS 0.8.0
This commit is contained in:
@@ -368,3 +368,14 @@ bun run validate:auth
|
||||
```
|
||||
|
||||
Read [SECURITY.md](./SECURITY.md) before production deployment.
|
||||
|
||||
## Package-owned UI blocks and route helpers
|
||||
|
||||
Authentication forms continue to compose `@wrnexus/ui` inputs, buttons, cards, alerts, badges, avatars, and PIN controls. The package also provides:
|
||||
|
||||
- `<AuthShell />`
|
||||
- `<AuthProviderButtons />`
|
||||
- `<AuthSecurityNotice />`
|
||||
- complete sign-in, sign-up, MFA, passkey, recovery, account-status, session, and impersonation blocks
|
||||
|
||||
Server helpers include `authRoute`, `authSuccess`, `authFailure`, `requireAuthUser`, `optionalAuthUser`, `currentAuthSession`, and `authComponentProps`.
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
component AccountStatus {
|
||||
props { status = "active" title = "Account status" activeMessage = "Your account is active and ready to use." pendingMessage = "Verify your contact details to activate your account." lockedMessage = "Your account is temporarily locked for security." disabledMessage = "Your account has been disabled." supportHref = "/support" color = "primary" size = "md" class = "" }
|
||||
props {
|
||||
status: string = "active"
|
||||
title: string = "Account status"
|
||||
activeMessage: string = "Your account is active and ready to use."
|
||||
pendingMessage: string = "Verify your contact details to activate your account."
|
||||
lockedMessage: string = "Your account is temporarily locked for security."
|
||||
disabledMessage: string = "Your account has been disabled."
|
||||
supportHref: string = "/support"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section {...attrs} data-status='{status}' 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-center {class}'>
|
||||
<span class='mx-auto flex size-14 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)] text-[var(--wire-color-primary)] data-[status=locked]:text-[var(--wire-color-warning)] data-[status=disabled]:text-[var(--wire-color-danger)]'>{#if status == "active"}<span class="icon-[lucide--circle-check] size-7"></span>{:else if status == "pending"}<span class="icon-[lucide--clock-3] size-7"></span>{:else}<span class="icon-[lucide--shield-alert] size-7"></span>{/if}</span>
|
||||
<h1 class="mb-0 mt-4 text-2xl font-semibold">{title}</h1>
|
||||
<p class="mx-auto mt-2 max-w-md text-sm text-[var(--wire-color-muted)]">{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}</p>
|
||||
{#if status != "active"}<a href='{supportHref}' class="mt-4 inline-flex h-10 items-center justify-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white">Contact support</a>{/if}
|
||||
</section>
|
||||
<Card {...attrs} title='{title}' color='{color}' size='{size}' align="center" class='w-full max-w-lg {class}'>
|
||||
<div class="flex flex-col items-center text-center" data-status='{status}'>
|
||||
<span class='flex size-14 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)] text-[var(--wire-color-primary)]'>
|
||||
{#if status == "active"}<span class="icon-[lucide--circle-check] size-7"></span>{:else if status == "pending"}<span class="icon-[lucide--clock-3] size-7"></span>{:else}<span class="icon-[lucide--shield-alert] size-7"></span>{/if}
|
||||
</span>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-[var(--wire-color-muted)]">{status == "active" ? activeMessage : status == "pending" ? pendingMessage : status == "locked" ? lockedMessage : disabledMessage}</p>
|
||||
<Badge label='{status}' color='{status == "active" ? "success" : status == "pending" ? "warning" : "danger"}' variant="soft" size="sm" />
|
||||
{#if status != "active"}<Button href='{supportHref}' label="Contact support" color='{color}' size='{size}' class="mt-4" />{/if}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
component AuthProviderButtons {
|
||||
outputs {
|
||||
select(payload: { provider: string; href: string })
|
||||
}
|
||||
|
||||
props {
|
||||
providers: unknown[] = []
|
||||
title: string = "Continue with"
|
||||
dividerLabel: string = "or"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
client function choose(provider) {
|
||||
output.select({ provider: provider.id || provider.name || provider.label, href: provider.href || "" })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div {...attrs} class='space-y-3 {class}'>
|
||||
{#if title}<p class="m-0 text-sm font-semibold text-[var(--wire-color-text)]">{title}</p>{/if}
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each providers as provider}
|
||||
<Button
|
||||
label='{provider.label || provider.name || "Continue"}'
|
||||
href='{provider.href || ""}'
|
||||
icon='{provider.icon || "icon-[lucide--log-in]"}'
|
||||
variant='{provider.variant || "outline"}'
|
||||
color='{provider.color || color}'
|
||||
size='{size}'
|
||||
fullWidth="true"
|
||||
@click='choose(provider)'
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if dividerLabel}
|
||||
<div class="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>{dividerLabel}</span><span class="h-px flex-1 bg-[var(--wire-color-border)]"></span></div>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
component AuthSecurityNotice {
|
||||
props {
|
||||
title: string = "Security notice"
|
||||
description: string = "Your session and credentials are protected by WRNexusJS security controls."
|
||||
color: string = "info"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Alert
|
||||
{...attrs}
|
||||
title='{title}'
|
||||
description='{description}'
|
||||
icon="icon-[lucide--shield-check]"
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
variant="soft"
|
||||
class='text-[var(--wire-color-text)] {class}'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
component AuthShell {
|
||||
props {
|
||||
title: string = "Welcome"
|
||||
description: string = ""
|
||||
eyebrow: string = ""
|
||||
icon: string = "icon-[lucide--shield-check]"
|
||||
footer: string = ""
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
maxWidth: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Card
|
||||
{...attrs}
|
||||
title='{title}'
|
||||
description='{description}'
|
||||
header='{eyebrow}'
|
||||
footer='{footer}'
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
class='w-full {class}'
|
||||
class:max-w-sm='maxWidth === "sm"'
|
||||
class:max-w-md='maxWidth === "md"'
|
||||
class:max-w-lg='maxWidth === "lg"'
|
||||
class:max-w-xl='maxWidth === "xl"'
|
||||
class:max-w-2xl='maxWidth === "2xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
>
|
||||
<div class="mb-5 flex size-12 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--wire-color-primary)_12%,transparent)] text-[var(--wire-color-primary)]">
|
||||
<span class='{icon}' aria-hidden="true"></span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,39 @@
|
||||
component DeviceSessions {
|
||||
props {
|
||||
sessions = []
|
||||
currentSessionId = ""
|
||||
title = "Active sessions"
|
||||
description = "Review devices signed in to your account."
|
||||
revokeAction = "/api/auth/sessions/revoke"
|
||||
revokeSchema = "auth-session-revoke"
|
||||
revokeLabel = "Sign out"
|
||||
successMessage = "Session revoked."
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
sessions: unknown[] = []
|
||||
currentSessionId: string = ""
|
||||
title: string = "Active sessions"
|
||||
description: string = "Review devices signed in to your account."
|
||||
revokeAction: string = "/api/auth/sessions/revoke"
|
||||
revokeSchema: string = "auth-session-revoke"
|
||||
revokeLabel: string = "Sign out"
|
||||
successMessage: string = "Session revoked."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section {...attrs} data-wrnexus-runtime="auth" class='w-full max-w-2xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] {class}'>
|
||||
<h2 class="m-0 text-xl font-semibold">{title}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
<div class="mt-5 divide-y divide-[var(--wire-color-border)]">
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='w-full max-w-2xl {class}'>
|
||||
<div class="divide-y divide-[var(--wire-color-border)]">
|
||||
{#each sessions as session}
|
||||
<article class="flex items-center gap-3 py-4">
|
||||
<span class="flex size-10 items-center justify-center rounded-full bg-[var(--wire-color-surface-2)]"><span class="icon-[lucide--monitor-smartphone] size-5"></span></span>
|
||||
<Avatar fallback='{session.userAgent || "Device"}' icon="icon-[lucide--monitor-smartphone]" size="sm" color='{color}' />
|
||||
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm font-semibold">{session.userAgent || "Unknown device"}</p><p class="m-0 mt-1 text-xs text-[var(--wire-color-muted)]">{session.ip || "Unknown IP"} · Last active {session.lastSeenAt}</p></div>
|
||||
{#if session.id == currentSessionId}
|
||||
<span class="rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)]">Current</span>
|
||||
<Badge label="Current" color="success" variant="soft" size="sm" />
|
||||
{:else}
|
||||
<form method="post" action='{revokeAction}' data-schema='{revokeSchema}' novalidate>
|
||||
<input type="hidden" name="sessionId" value='{session.id}' />
|
||||
<p data-error="sessionId" 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>
|
||||
<p data-success='{successMessage}' role="status" hidden class="m-0 text-xs text-[var(--wire-color-success)]">{successMessage}</p>
|
||||
<button type="submit" class="text-sm font-semibold text-[var(--wire-color-danger)] disabled:opacity-60">{revokeLabel}</button>
|
||||
<Button type="submit" label='{revokeLabel}' color="danger" variant="ghost" size="sm" />
|
||||
</form>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
component ImpersonationBanner {
|
||||
props {
|
||||
visible = true
|
||||
targetName = "this user"
|
||||
stopAction = "/api/auth/impersonation/stop"
|
||||
stopSchema = "auth-empty"
|
||||
redirect = "/account"
|
||||
message = "You are viewing the application as"
|
||||
stopLabel = "Stop impersonating"
|
||||
color = "warning"
|
||||
size = "md"
|
||||
class = ""
|
||||
visible: boolean = true
|
||||
targetName: string = "this user"
|
||||
stopAction: string = "/api/auth/impersonation/stop"
|
||||
stopSchema: string = "auth-empty"
|
||||
redirect: string = "/account"
|
||||
message: string = "You are viewing the application as"
|
||||
stopLabel: string = "Stop impersonating"
|
||||
color: string = "warning"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
{#if visible}
|
||||
<aside {...attrs} data-wrnexus-runtime="auth" role="status" class='flex w-full flex-wrap items-center justify-between gap-3 border-b border-[var(--wire-color-warning)] bg-[color-mix(in_srgb,var(--wire-color-warning)_14%,var(--wire-color-surface))] px-4 py-2 text-sm text-[var(--wire-color-text)] {class}'>
|
||||
<span class="flex items-center gap-2"><span class="icon-[lucide--scan-face] size-4 text-[var(--wire-color-warning)]"></span><span>{message} <strong>{targetName}</strong>.</span></span>
|
||||
<form method="post" action='{stopAction}' data-schema='{stopSchema}' data-redirect='{redirect}' novalidate><p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p><button type="submit" class="inline-flex h-8 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-warning)] px-3 text-xs font-semibold text-black disabled:opacity-60">{stopLabel}</button></form>
|
||||
</aside>
|
||||
<Alert {...attrs} title='{message + " " + targetName}' icon="icon-[lucide--scan-face]" color='{color}' size='{size}' variant="soft" class='{class}'>
|
||||
<form method="post" action='{stopAction}' data-schema='{stopSchema}' data-redirect='{redirect}' novalidate class="mt-2">
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<Button type="submit" label='{stopLabel}' color='{color}' size="sm" />
|
||||
</form>
|
||||
</Alert>
|
||||
{/if}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
component PasskeyButton {
|
||||
props {
|
||||
mode = "authenticate"
|
||||
label = "Continue with a passkey"
|
||||
registerLabel = "Add a passkey"
|
||||
identifier = ""
|
||||
rpId = ""
|
||||
rpName = "WRNexusJS"
|
||||
passkeyName = "Passkey"
|
||||
optionsEndpoint = ""
|
||||
verifyEndpoint = ""
|
||||
redirect = ""
|
||||
mfaHref = "/two-factor"
|
||||
conditional = false
|
||||
fullWidth = false
|
||||
loadingMessage = "Waiting for your passkey…"
|
||||
successMessage = "Passkey verified."
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
@event passkeyRegistered = function
|
||||
@event passkeyAuthenticated = function
|
||||
@event error = function
|
||||
outputs {
|
||||
passkeyRegistered(payload: { credentialId: string; response?: object })
|
||||
passkeyAuthenticated(payload: { userId?: string; sessionId?: string; response?: object })
|
||||
error(payload: { code: string; message: string })
|
||||
}
|
||||
|
||||
props {
|
||||
mode: string = "authenticate"
|
||||
label: string = "Continue with a passkey"
|
||||
registerLabel: string = "Add a passkey"
|
||||
identifier: string = ""
|
||||
rpId: string = ""
|
||||
rpName: string = "WRNexusJS"
|
||||
passkeyName: string = "Passkey"
|
||||
optionsEndpoint: string = ""
|
||||
verifyEndpoint: string = ""
|
||||
redirect: string = ""
|
||||
mfaHref: string = "/two-factor"
|
||||
conditional: boolean = false
|
||||
fullWidth: boolean = false
|
||||
loadingMessage: string = "Waiting for your passkey…"
|
||||
successMessage: string = "Passkey verified."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<div {...attrs} data-wrnexus-runtime="auth" data-auth-passkey='{mode}' data-identifier='{identifier}' data-rp-id='{rpId}' data-rp-name='{rpName}' data-passkey-name='{passkeyName}' data-options-endpoint='{optionsEndpoint}' data-verify-endpoint='{verifyEndpoint}' data-redirect='{redirect}' data-mfa-href='{mfaHref}' data-conditional='{conditional}' data-loading-message='{loadingMessage}' data-success-message='{successMessage}' data-auth-state="idle" aria-busy="false" class='space-y-2 {class}'>
|
||||
<button type="button" class='inline-flex h-11 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-4 text-sm font-semibold text-[var(--wire-color-text)] hover:bg-[var(--wire-color-surface-2)] disabled:cursor-wait disabled:opacity-60 {fullWidth ? "w-full" : ""}'>
|
||||
<span aria-hidden="true" class="icon-[lucide--key-round] size-4"></span>
|
||||
{mode == "register" ? registerLabel : label}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
label='{mode == "register" ? registerLabel : label}'
|
||||
icon="icon-[lucide--key-round]"
|
||||
variant="outline"
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
fullWidth='{fullWidth}'
|
||||
/>
|
||||
<p data-auth-status hidden role="status" class="m-0 text-xs text-[var(--wire-color-muted)]"></p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,86 +1,35 @@
|
||||
component RecoveryCodes {
|
||||
props {
|
||||
codes = []
|
||||
schema = "auth-recovery-codes"
|
||||
title = "Recovery codes"
|
||||
description = "Store these codes somewhere safe. Each code can be used once."
|
||||
downloadLabel = "Download codes"
|
||||
regenerateLabel = "Generate new codes"
|
||||
regenerateAction = "/api/auth/recovery-codes"
|
||||
successMessage = "New recovery codes generated. Previous unused codes are no longer valid."
|
||||
count = 10
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
codes: unknown[] = []
|
||||
schema: string = "auth-recovery-codes"
|
||||
title: string = "Recovery codes"
|
||||
description: string = "Store these codes somewhere safe. Each code can be used once."
|
||||
downloadLabel: string = "Download codes"
|
||||
regenerateLabel: string = "Generate new codes"
|
||||
regenerateAction: string = "/api/auth/recovery-codes"
|
||||
successMessage: string = "New recovery codes generated. Previous unused codes are no longer valid."
|
||||
count: number = 10
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
data-wrnexus-runtime="auth"
|
||||
data-auth-recovery-codes
|
||||
data-recovery-filename="wrnexus-recovery-codes.txt"
|
||||
class='w-full max-w-xl rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 {class}'
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold">{title}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--wire-color-muted)]">{description}</p>
|
||||
</div>
|
||||
<span class="icon-[lucide--shield-keyhole] size-6 text-[var(--wire-color-primary)]"></span>
|
||||
<Card {...attrs} title='{title}' description='{description}' color='{color}' size='{size}' class='w-full max-w-xl {class}' data-wrnexus-runtime="auth" data-auth-recovery-codes data-recovery-filename="wrnexus-recovery-codes.txt">
|
||||
<Alert title="Keep these codes private" description="Anyone with a recovery code may be able to access your account." icon="icon-[lucide--shield-alert]" color="warning" variant="soft" size="sm" />
|
||||
<div data-recovery-code-list class="mt-4 grid grid-cols-2 gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4 font-mono text-sm sm:grid-cols-3">
|
||||
{#each codes as code}<code data-recovery-code class="rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center">{code}</code>{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-recovery-code-list
|
||||
class="mt-5 grid grid-cols-2 gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-4 font-mono text-sm sm:grid-cols-3"
|
||||
>
|
||||
{#each codes as code}
|
||||
<code
|
||||
data-recovery-code
|
||||
class="rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center"
|
||||
>{code}</code>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-recovery-download
|
||||
class="h-10 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold"
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action='{regenerateAction}'
|
||||
data-schema='{schema}'
|
||||
novalidate
|
||||
class="flex-1"
|
||||
>
|
||||
<Button type="button" label='{downloadLabel}' icon="icon-[lucide--download]" variant="outline" color='{color}' size='{size}' data-recovery-download />
|
||||
<form method="post" action='{regenerateAction}' data-schema='{schema}' novalidate class="min-w-48 flex-1">
|
||||
<input type="hidden" name="count" value='{count}' />
|
||||
<p data-error="count" 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>
|
||||
<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-xs text-[var(--wire-color-success)]"
|
||||
>
|
||||
{successMessage}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-10 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] px-4 text-sm font-semibold disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{regenerateLabel}
|
||||
</button>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden text-xs text-[var(--wire-color-danger)]"></p>
|
||||
<p data-success='{successMessage}' role="status" hidden class="m-0 text-xs text-[var(--wire-color-success)]">{successMessage}</p>
|
||||
<Button type="submit" label='{regenerateLabel}' icon="icon-[lucide--refresh-cw]" variant="outline" color='{color}' size='{size}' fullWidth="true" />
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -52,7 +52,7 @@
|
||||
"@wrnexus/db": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthSession, AuthUser } from "./types.ts";
|
||||
|
||||
export type AuthRouteName =
|
||||
| "signIn"
|
||||
| "signUp"
|
||||
| "signOut"
|
||||
| "forgotPassword"
|
||||
| "resetPassword"
|
||||
| "verifyEmail"
|
||||
| "verifyPhone"
|
||||
| "twoFactor"
|
||||
| "sessions"
|
||||
| "passkeys";
|
||||
|
||||
const DEFAULT_AUTH_ROUTES: Record<AuthRouteName, string> = {
|
||||
signIn: "/sign-in",
|
||||
signUp: "/sign-up",
|
||||
signOut: "/api/auth/logout",
|
||||
forgotPassword: "/forgot-password",
|
||||
resetPassword: "/reset-password",
|
||||
verifyEmail: "/verify-email",
|
||||
verifyPhone: "/verify-phone",
|
||||
twoFactor: "/two-factor",
|
||||
sessions: "/account/sessions",
|
||||
passkeys: "/account/passkeys",
|
||||
};
|
||||
|
||||
export function authRoute(
|
||||
name: AuthRouteName,
|
||||
options: { basePath?: string; overrides?: Partial<Record<AuthRouteName, string>> } = {},
|
||||
): string {
|
||||
const route = options.overrides?.[name] ?? DEFAULT_AUTH_ROUTES[name];
|
||||
if (!options.basePath || route.startsWith("http://") || route.startsWith("https://"))
|
||||
return route;
|
||||
return `${options.basePath.replace(/\/$/, "")}/${route.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
export function authSuccess<T extends Record<string, unknown>>(
|
||||
data: T,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
return Response.json({ ok: true, ...data }, { status: init.status ?? 200, ...init });
|
||||
}
|
||||
|
||||
export function authFailure(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 400,
|
||||
details?: Record<string, unknown>,
|
||||
): Response {
|
||||
return Response.json(
|
||||
{ ok: false, error: { code, message, ...(details ? { details } : {}) } },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
export function requireAuthUser(ctx: Context): AuthUser {
|
||||
if (!ctx.user || typeof ctx.user !== "object") {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
return ctx.user as AuthUser;
|
||||
}
|
||||
|
||||
export function optionalAuthUser(ctx: Context): AuthUser | null {
|
||||
return ctx.user && typeof ctx.user === "object" ? (ctx.user as AuthUser) : null;
|
||||
}
|
||||
|
||||
export async function currentAuthSession(
|
||||
engine: AuthEngine,
|
||||
sessionId: string | undefined,
|
||||
): Promise<AuthSession | null> {
|
||||
if (!sessionId) return null;
|
||||
const session = await engine.store.findSession(sessionId);
|
||||
return session ?? null;
|
||||
}
|
||||
|
||||
export function authComponentProps(
|
||||
input: Record<string, unknown>,
|
||||
defaults: { color?: string; size?: string; class?: string } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
color: defaults.color ?? "primary",
|
||||
size: defaults.size ?? "md",
|
||||
class: defaults.class ?? "",
|
||||
...input,
|
||||
};
|
||||
}
|
||||
@@ -101,3 +101,5 @@ export {
|
||||
type AuthSchemaSet,
|
||||
type AuthSchemaOverrides,
|
||||
} from "./validation.ts";
|
||||
|
||||
export * from "./helpers.ts";
|
||||
|
||||
@@ -297,7 +297,7 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
||||
|
||||
return definePlugin({
|
||||
name: "@wrnexus/auth",
|
||||
version: "0.5.0",
|
||||
version: "0.8.0",
|
||||
enforce: "post",
|
||||
|
||||
componentDirs(context) {
|
||||
|
||||
@@ -166,10 +166,55 @@ export interface LoginAttempt {
|
||||
riskLevel: AuthRiskLevel;
|
||||
}
|
||||
|
||||
export const AUTH_SECURITY_EVENT_TYPES = [
|
||||
"account.registered",
|
||||
"account.status-changed",
|
||||
"delivery.failed",
|
||||
"device.revoked",
|
||||
"device.trusted",
|
||||
"identity.email-verification-requested",
|
||||
"identity.email-verified",
|
||||
"identity.otp-verified",
|
||||
"identity.phone-verification-requested",
|
||||
"identity.phone-verified",
|
||||
"impersonation.denied",
|
||||
"impersonation.ended",
|
||||
"impersonation.started",
|
||||
"invitation.accepted",
|
||||
"invitation.created",
|
||||
"login.failed",
|
||||
"login.magic-link",
|
||||
"login.oauth",
|
||||
"login.otp",
|
||||
"login.otp-verified",
|
||||
"login.passkey",
|
||||
"login.succeeded",
|
||||
"mfa.failed",
|
||||
"mfa.otp-verified",
|
||||
"mfa.recovery-code-used",
|
||||
"mfa.recovery-codes-generated",
|
||||
"mfa.succeeded",
|
||||
"mfa.totp-disabled",
|
||||
"mfa.totp-enabled",
|
||||
"mfa.totp-verified",
|
||||
"oauth.linked",
|
||||
"oauth.unlinked",
|
||||
"passkey.registered",
|
||||
"password.changed",
|
||||
"password.reset",
|
||||
"password.reset-requested",
|
||||
"session.revoked",
|
||||
"session.revoked-all",
|
||||
] as const;
|
||||
|
||||
export type KnownAuthSecurityEventType = (typeof AUTH_SECURITY_EVENT_TYPES)[number];
|
||||
/** Known framework events plus application-defined extension events. */
|
||||
export type AuthSecurityEventType = KnownAuthSecurityEventType | (string & {});
|
||||
|
||||
export interface AuthSecurityEvent {
|
||||
id: string;
|
||||
userId?: string;
|
||||
type: string;
|
||||
type: AuthSecurityEventType;
|
||||
severity: "info" | "warning" | "critical";
|
||||
actorUserId?: string;
|
||||
sessionId?: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
const componentsDir = join(import.meta.dir, "..", "components");
|
||||
|
||||
test("all auth package components parse", () => {
|
||||
for (const file of readdirSync(componentsDir).filter((name) => name.endsWith(".wrn"))) {
|
||||
expect(() => parse(readFileSync(join(componentsDir, file), "utf8"))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth form blocks use WRNexus UI controls", () => {
|
||||
const formFiles = [
|
||||
"SignIn.wrn",
|
||||
"SignUp.wrn",
|
||||
"ForgotPassword.wrn",
|
||||
"ResetPassword.wrn",
|
||||
"OtpSignIn.wrn",
|
||||
"TwoFactorChallenge.wrn",
|
||||
"VerifyEmail.wrn",
|
||||
"VerifyPhone.wrn",
|
||||
"InvitationAccept.wrn",
|
||||
"MagicLinkSignIn.wrn",
|
||||
];
|
||||
for (const file of formFiles) {
|
||||
const source = readFileSync(join(componentsDir, file), "utf8");
|
||||
expect(source).toMatch(/<(?:Input|PinInput|StrongPassword|TogglePassword|Checkbox|Select)\b/);
|
||||
expect(source).toContain("<Button");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user