New Captcha Package added
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { captchaHandlers } from "../../lib/captcha.ts";
|
||||
|
||||
async function handle(ctx: Context): Promise<Response> {
|
||||
return (await captchaHandlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const HEAD = handle;
|
||||
export const POST = handle;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { captchaGuard } from "@wrnexus/captcha/server";
|
||||
import { parseBody } from "@wrnexus/validation";
|
||||
import contactSchema from "../schemas/contact.ts";
|
||||
import { captchaEngine } from "../lib/captcha.ts";
|
||||
|
||||
const protectContact = captchaGuard({
|
||||
action: "contact-submit",
|
||||
engine: captchaEngine,
|
||||
responseField: "wrn-captcha-response",
|
||||
bindHostname: true,
|
||||
bindSession: true,
|
||||
});
|
||||
|
||||
interface ContactInput {
|
||||
name: string;
|
||||
email: string;
|
||||
topic: "general" | "security" | "billing" | "integration";
|
||||
message: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
const validation = await parseBody<ContactInput>(contactSchema, ctx.req.clone());
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
return protectContact(ctx, async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
message: "Validated CAPTCHA form accepted.",
|
||||
submission: {
|
||||
name: validation.value.name,
|
||||
email: validation.value.email,
|
||||
topic: validation.value.topic,
|
||||
messageLength: validation.value.message.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
function safeReturnPath(value: string | null, baseUrl: URL): string {
|
||||
if (!value) return "/protected";
|
||||
|
||||
try {
|
||||
const destination = new URL(value, baseUrl);
|
||||
if (destination.origin !== baseUrl.origin) return "/protected";
|
||||
if (!destination.pathname.startsWith("/") || destination.pathname.startsWith("//")) {
|
||||
return "/protected";
|
||||
}
|
||||
return `${destination.pathname}${destination.search}`;
|
||||
} catch {
|
||||
return "/protected";
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
const contentType = ctx.req.headers.get("content-type") ?? "";
|
||||
let submittedReturnTo: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
submittedReturnTo = typeof body.returnTo === "string" ? body.returnTo : null;
|
||||
} else {
|
||||
const form = await ctx.req.formData();
|
||||
const value = form.get("returnTo");
|
||||
submittedReturnTo = typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
const returnTo = submittedReturnTo ?? ctx.url.searchParams.get("returnTo");
|
||||
const safePath = safeReturnPath(returnTo, ctx.url);
|
||||
return Response.redirect(new URL(safePath, ctx.url), 303);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
component Captcha {
|
||||
props {
|
||||
provider = "self-hosted"
|
||||
siteKey = ""
|
||||
type = "alphanumeric"
|
||||
action = "form-submit"
|
||||
presentation = "visual"
|
||||
difficulty = "normal"
|
||||
disturbance = 50
|
||||
imageStyle = "random"
|
||||
allowedStyles = ""
|
||||
excludedStyles = ""
|
||||
randomizeStyle = false
|
||||
locale = "en"
|
||||
size = "normal"
|
||||
color = "primary"
|
||||
class = ""
|
||||
|
||||
id = ""
|
||||
name = "wrn-captcha-response"
|
||||
endpoint = "/__wrnexus/captcha/challenge"
|
||||
verifyEndpoint = "/__wrnexus/captcha/verify"
|
||||
responseField = "wrn-captcha-response"
|
||||
|
||||
label = "Security verification"
|
||||
description = "Complete the challenge to continue."
|
||||
helpText = ""
|
||||
requiredMessage = "Please complete the security check."
|
||||
incorrectMessage = "That answer was not correct. Try again."
|
||||
expiredMessage = "This challenge expired. Load a new one."
|
||||
networkMessage = "The verification service is unavailable. Try again."
|
||||
|
||||
autoLoad = true
|
||||
autoVerify = false
|
||||
showVerify = true
|
||||
showRefresh = true
|
||||
showAudio = true
|
||||
showListen = true
|
||||
showStatus = true
|
||||
disabled = false
|
||||
required = true
|
||||
compact = false
|
||||
|
||||
@event ready = function
|
||||
@event challenge = function
|
||||
@event input = function
|
||||
@event verify = function
|
||||
@event success = function
|
||||
@event failure = function
|
||||
@event expired = function
|
||||
@event refresh = function
|
||||
@event audioStart = function
|
||||
@event audioEnd = function
|
||||
@event error = function
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
id='{id}'
|
||||
data-wrn-captcha
|
||||
data-captcha-provider='{provider}'
|
||||
data-captcha-site-key='{siteKey}'
|
||||
data-captcha-type='{type}'
|
||||
data-captcha-action='{action}'
|
||||
data-captcha-presentation='{presentation}'
|
||||
data-captcha-difficulty='{difficulty}'
|
||||
data-captcha-disturbance='{disturbance}'
|
||||
data-captcha-image-style='{imageStyle}'
|
||||
data-captcha-allowed-styles='{allowedStyles}'
|
||||
data-captcha-excluded-styles='{excludedStyles}'
|
||||
data-captcha-randomize-style='{randomizeStyle}'
|
||||
data-captcha-locale='{locale}'
|
||||
data-captcha-size='{size}'
|
||||
data-captcha-color='{color}'
|
||||
data-captcha-endpoint='{endpoint}'
|
||||
data-captcha-verify-endpoint='{verifyEndpoint}'
|
||||
data-captcha-response-field='{responseField}'
|
||||
data-captcha-name='{name}'
|
||||
data-captcha-auto-load='{autoLoad}'
|
||||
data-captcha-auto-verify='{autoVerify}'
|
||||
data-captcha-show-verify='{showVerify}'
|
||||
data-captcha-show-refresh='{showRefresh}'
|
||||
data-captcha-show-audio='{showAudio}'
|
||||
data-captcha-show-listen='{showListen}'
|
||||
data-captcha-show-status='{showStatus}'
|
||||
data-captcha-disabled='{disabled}'
|
||||
data-captcha-required='{required}'
|
||||
data-captcha-compact='{compact}'
|
||||
data-captcha-required-message='{requiredMessage}'
|
||||
data-captcha-incorrect-message='{incorrectMessage}'
|
||||
data-captcha-expired-message='{expiredMessage}'
|
||||
data-captcha-network-message='{networkMessage}'
|
||||
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}'
|
||||
>
|
||||
<script
|
||||
src="/assets/wrnexus/captcha.js"
|
||||
defer
|
||||
data-wrn-captcha-runtime
|
||||
></script>
|
||||
|
||||
<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'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
|
||||
></span>
|
||||
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
{label}
|
||||
</h3>
|
||||
</div>
|
||||
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
data-captcha-verified-badge
|
||||
hidden
|
||||
class='shrink-0 items-center gap-1 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)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
|
||||
Verified
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
data-captcha-provider-mount
|
||||
hidden
|
||||
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
|
||||
></div>
|
||||
|
||||
<div
|
||||
data-captcha-loading
|
||||
hidden
|
||||
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
|
||||
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-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>
|
||||
</div>
|
||||
|
||||
<div data-captcha-not-robot hidden class='w-full'>
|
||||
<button
|
||||
data-captcha-not-robot-button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
|
||||
>
|
||||
<span
|
||||
data-captcha-not-robot-control
|
||||
aria-hidden="true"
|
||||
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
|
||||
>
|
||||
<span data-captcha-not-robot-empty class='size-full'></span>
|
||||
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
|
||||
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
|
||||
</span>
|
||||
|
||||
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
I'm not a robot
|
||||
</span>
|
||||
|
||||
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
|
||||
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
|
||||
<span>WRNexus</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-image-wrap
|
||||
hidden
|
||||
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
|
||||
>
|
||||
<img
|
||||
data-captcha-image
|
||||
src=""
|
||||
alt="CAPTCHA challenge"
|
||||
draggable="false"
|
||||
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-items
|
||||
hidden
|
||||
role="group"
|
||||
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
|
||||
></div>
|
||||
|
||||
<template data-captcha-item-template>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
|
||||
>
|
||||
<img
|
||||
data-captcha-item-image
|
||||
src=""
|
||||
alt=""
|
||||
draggable="false"
|
||||
class='aspect-[4/3] w-full select-none object-cover'
|
||||
/>
|
||||
<span
|
||||
data-captcha-item-check
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
|
||||
>
|
||||
<span class='icon-[lucide--check] size-3.5'></span>
|
||||
</span>
|
||||
</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"
|
||||
name=""
|
||||
value=""
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] 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:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
|
||||
<span data-captcha-error-message></span>
|
||||
</div>
|
||||
|
||||
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
{helpText}
|
||||
</p>
|
||||
|
||||
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
|
||||
<button
|
||||
data-captcha-audio
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Listen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-audio-alternative
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Use audio
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-refresh
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
New challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-captcha-verify
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-label>Verify</span>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<input
|
||||
data-captcha-response
|
||||
type="hidden"
|
||||
name='{responseField}'
|
||||
value=""
|
||||
/>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
|
||||
|
||||
export const captchaEngine = createCaptchaEngine({
|
||||
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
|
||||
store: new MemoryCaptchaStore(),
|
||||
basePath: "/api/captcha",
|
||||
challengeTtlMs: 2 * 60_000,
|
||||
responseTokenTtlMs: 5 * 60_000,
|
||||
maxAttempts: 3,
|
||||
minCompletionMs: 800,
|
||||
});
|
||||
|
||||
export const captchaHandlers = createCaptchaHttpHandlers(captchaEngine, {
|
||||
createLimit: 120,
|
||||
verifyLimit: 60,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Context, Next } from "@wrnexus/core";
|
||||
import { captchaPageGate } from "@wrnexus/captcha/server";
|
||||
import { captchaEngine } from "../lib/captcha.ts";
|
||||
|
||||
const pageGate = captchaPageGate({
|
||||
action: "protected-page-access",
|
||||
engine: captchaEngine,
|
||||
challengePath: "/captcha",
|
||||
policy: {
|
||||
mode: "session",
|
||||
verifiedForMs: 15 * 60_000,
|
||||
routeGroups: ["/protected"],
|
||||
},
|
||||
});
|
||||
|
||||
export default function captchaProtectedPages(ctx: Context, next: Next) {
|
||||
if (ctx.url.pathname === "/protected" || ctx.url.pathname === "/api/page-grant") {
|
||||
return pageGate(ctx, next);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
page CaptchaGate {
|
||||
state returnTo = ctx.url.searchParams.get("returnTo") || "/protected"
|
||||
|
||||
seo {
|
||||
title = "Complete security verification"
|
||||
robots = "noindex,nofollow"
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="grid min-h-screen place-items-center bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)]">
|
||||
<form
|
||||
method="post"
|
||||
action="/api/page-grant"
|
||||
class="w-full max-w-xl rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 shadow-[var(--wire-shadow-1)] sm:p-7"
|
||||
>
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
|
||||
<p class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]">Protected page</p>
|
||||
<h1 class="m-0 text-2xl font-bold">Confirm you are human</h1>
|
||||
<p class="mb-5 mt-2 text-sm leading-6 text-[var(--wire-color-muted)]">Complete this once to access protected showcase pages for 15 minutes.</p>
|
||||
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="alphanumeric"
|
||||
action="protected-page-access"
|
||||
disturbance="50"
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<button type="submit" class="mt-4 h-11 w-full rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]">
|
||||
Continue to protected page
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
page CaptchaShowcase {
|
||||
seo {
|
||||
title = "CAPTCHA Component Showcase"
|
||||
description = "Test every WRNexus CAPTCHA mode, validation flow, page gate, and provider integration."
|
||||
}
|
||||
|
||||
view {
|
||||
<main
|
||||
class="min-h-screen bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)] sm:px-6 lg:px-8"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col gap-10"
|
||||
>
|
||||
<header
|
||||
class="max-w-3xl"
|
||||
>
|
||||
<p
|
||||
class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]"
|
||||
>
|
||||
@wrnexus/captcha
|
||||
</p>
|
||||
<h1
|
||||
class="m-0 text-3xl font-bold tracking-tight sm:text-5xl"
|
||||
>
|
||||
CAPTCHA system showcase
|
||||
</h1>
|
||||
<p
|
||||
class="mt-4 text-base leading-7 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
Self-hosted number, alphabet, alphanumeric, calculation, image, audio, not-robot, honeypot, timing, validated form-submit, and page-gate protection.
|
||||
</p>
|
||||
<a
|
||||
href="/styles"
|
||||
class="mt-4 inline-flex h-10 items-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:border-[var(--wire-color-primary)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--images] size-4 text-[var(--wire-color-primary)]"
|
||||
>
|
||||
</span>
|
||||
Open all 18 image styles
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="grid gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Number CAPTCHA
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
action="demo-number"
|
||||
imageStyle="random"
|
||||
disturbance="35"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Alphabet CAPTCHA
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="alpha"
|
||||
action="demo-alpha"
|
||||
imageStyle="random"
|
||||
disturbance="40"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Alphanumeric CAPTCHA
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="alphanumeric"
|
||||
action="demo-alphanumeric"
|
||||
imageStyle="random"
|
||||
difficulty="hard"
|
||||
disturbance="70"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Calculation CAPTCHA
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="calculation"
|
||||
action="demo-calculation"
|
||||
imageStyle="random"
|
||||
disturbance="50"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
|
||||
>
|
||||
<div
|
||||
class="max-w-3xl"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-xl font-semibold"
|
||||
>
|
||||
Compact, normal, and big sizes
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
Use the size prop to fit dense forms, standard pages, or large security flows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-5 grid items-start gap-5 xl:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<h3
|
||||
class="mt-0 text-base font-semibold"
|
||||
>
|
||||
Compact
|
||||
</h3>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
action="demo-size-compact"
|
||||
size="compact"
|
||||
disturbance="30"
|
||||
showListen="false"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<h3
|
||||
class="mt-0 text-base font-semibold"
|
||||
>
|
||||
Normal
|
||||
</h3>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
action="demo-size-normal"
|
||||
size="normal"
|
||||
disturbance="40"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<h3
|
||||
class="mt-0 text-base font-semibold"
|
||||
>
|
||||
Big
|
||||
</h3>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
action="demo-size-big"
|
||||
size="big"
|
||||
disturbance="50"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="grid gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
I'm not a robot
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
A low-friction, one-time checkbox challenge backed by timing, honeypot, expiry, action, session, and server-side token checks.
|
||||
</p>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="not-robot"
|
||||
action="demo-not-robot"
|
||||
size="compact"
|
||||
showAudio="false"
|
||||
showListen="false"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Listen button hidden
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
Set showListen to false when a separate accessible alternative is provided elsewhere.
|
||||
</p>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="alphanumeric"
|
||||
action="demo-listen-hidden"
|
||||
size="compact"
|
||||
showListen="false"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
|
||||
>
|
||||
<div
|
||||
class="max-w-3xl"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-xl font-semibold"
|
||||
>
|
||||
Generated image disturbance
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
Use the disturbance prop from 25 to 75. Lower values are easier to read; higher values add more dots and crossing lines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-5 grid gap-5 xl:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<h3
|
||||
class="m-0 text-base font-semibold"
|
||||
>
|
||||
Easy image
|
||||
</h3>
|
||||
<code
|
||||
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
|
||||
>
|
||||
25%
|
||||
</code>
|
||||
</div>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="image"
|
||||
action="demo-image-easy"
|
||||
disturbance="25"
|
||||
size="big"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<h3
|
||||
class="m-0 text-base font-semibold"
|
||||
>
|
||||
Normal image
|
||||
</h3>
|
||||
<code
|
||||
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
|
||||
>
|
||||
50%
|
||||
</code>
|
||||
</div>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="image"
|
||||
action="demo-image-normal"
|
||||
disturbance="50"
|
||||
size="big"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<h3
|
||||
class="m-0 text-base font-semibold"
|
||||
>
|
||||
Hard image
|
||||
</h3>
|
||||
<code
|
||||
class="rounded-md bg-[var(--wire-color-surface)] px-2 py-1 text-xs"
|
||||
>
|
||||
75%
|
||||
</code>
|
||||
</div>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="image"
|
||||
action="demo-image-hard"
|
||||
disturbance="75"
|
||||
difficulty="hard"
|
||||
size="big"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="grid gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Audio-first challenge
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="number"
|
||||
presentation="audio"
|
||||
action="demo-audio"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-lg font-semibold"
|
||||
>
|
||||
Invisible timing and honeypot
|
||||
</h2>
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="timing"
|
||||
presentation="invisible"
|
||||
action="demo-invisible"
|
||||
compact="true"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
|
||||
>
|
||||
<div
|
||||
class="max-w-3xl"
|
||||
>
|
||||
<p
|
||||
class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]"
|
||||
>
|
||||
Complete integration
|
||||
</p>
|
||||
<h2
|
||||
class="mt-0 text-xl font-semibold"
|
||||
>
|
||||
Validated contact form with CAPTCHA
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-[var(--wire-color-muted)]"
|
||||
>
|
||||
The same contact schema validates in the browser and API. CAPTCHA verification remains mandatory on the server before the form is accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
data-schema="contact"
|
||||
method="post"
|
||||
action="/api/contact"
|
||||
class="mt-6 grid max-w-3xl gap-5"
|
||||
novalidate
|
||||
>
|
||||
<div
|
||||
data-success="Validated form submitted successfully."
|
||||
hidden
|
||||
class="rounded-xl border border-[var(--wire-color-success)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-4 text-sm font-medium text-[var(--wire-color-success)]"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-5 sm:grid-cols-2"
|
||||
>
|
||||
<label
|
||||
class="grid gap-2 text-sm font-medium"
|
||||
>
|
||||
Full name
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="Ajay Ghanwat"
|
||||
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
|
||||
/>
|
||||
<span
|
||||
data-error="name"
|
||||
role="alert"
|
||||
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="grid gap-2 text-sm font-medium"
|
||||
>
|
||||
Email address
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
|
||||
/>
|
||||
<span
|
||||
data-error="email"
|
||||
role="alert"
|
||||
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="grid gap-2 text-sm font-medium"
|
||||
>
|
||||
Topic
|
||||
<select
|
||||
name="topic"
|
||||
class="h-11 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
Choose a topic
|
||||
</option>
|
||||
<option
|
||||
value="general"
|
||||
>
|
||||
General question
|
||||
</option>
|
||||
<option
|
||||
value="security"
|
||||
>
|
||||
Security and CAPTCHA
|
||||
</option>
|
||||
<option
|
||||
value="billing"
|
||||
>
|
||||
Billing
|
||||
</option>
|
||||
<option
|
||||
value="integration"
|
||||
>
|
||||
Integration support
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
data-error="topic"
|
||||
role="alert"
|
||||
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="grid gap-2 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
<textarea
|
||||
name="message"
|
||||
rows="5"
|
||||
placeholder="Tell us how we can help..."
|
||||
class="min-h-32 resize-y rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 py-3 text-[var(--wire-color-text)] outline-none focus:border-[var(--wire-color-primary)] focus:ring-2 focus:ring-[var(--wire-color-primary)] aria-[invalid=true]:border-[var(--wire-color-danger)]"
|
||||
>
|
||||
</textarea>
|
||||
<span
|
||||
data-error="message"
|
||||
role="alert"
|
||||
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="flex items-start gap-3 text-sm text-[var(--wire-color-muted)]"
|
||||
>
|
||||
<input
|
||||
name="consent"
|
||||
type="checkbox"
|
||||
class="mt-0.5 size-4 rounded border-[var(--wire-color-border)] text-[var(--wire-color-primary)] focus:ring-[var(--wire-color-primary)]"
|
||||
/>
|
||||
<span
|
||||
>
|
||||
I confirm that this information may be processed for this showcase request.
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
data-error="consent"
|
||||
role="alert"
|
||||
class="min-h-5 text-xs text-[var(--wire-color-danger)]"
|
||||
>
|
||||
</span>
|
||||
|
||||
<Captcha
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
type="alphanumeric"
|
||||
action="contact-submit"
|
||||
disturbance="45"
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Validate and submit form
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-xl font-semibold"
|
||||
>
|
||||
Page-open protection
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm text-[var(--wire-color-muted)]"
|
||||
>
|
||||
This route uses a once-per-session page grant. After successful CAPTCHA verification, the form posts the response token and safely redirects to the requested protected route.
|
||||
</p>
|
||||
<a
|
||||
href="/protected"
|
||||
class="mt-3 inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]"
|
||||
>
|
||||
Open protected page
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7"
|
||||
>
|
||||
<h2
|
||||
class="mt-0 text-xl font-semibold"
|
||||
>
|
||||
External providers
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm text-[var(--wire-color-muted)]"
|
||||
>
|
||||
Add real public test keys before enabling these examples.
|
||||
</p>
|
||||
<div
|
||||
class="mt-5 grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
<Captcha
|
||||
provider="turnstile"
|
||||
siteKey=""
|
||||
action="external-turnstile"
|
||||
autoLoad="false"
|
||||
/>
|
||||
<Captcha
|
||||
provider="recaptcha"
|
||||
siteKey=""
|
||||
action="external-recaptcha"
|
||||
autoLoad="false"
|
||||
/>
|
||||
<Captcha
|
||||
provider="hcaptcha"
|
||||
siteKey=""
|
||||
action="external-hcaptcha"
|
||||
autoLoad="false"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
page ProtectedCaptchaPage {
|
||||
seo {
|
||||
title = "CAPTCHA protected page"
|
||||
robots = "noindex,nofollow"
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="grid min-h-screen place-items-center bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)]">
|
||||
<section class="w-full max-w-2xl rounded-2xl border border-[var(--wire-color-success)] bg-[var(--wire-color-surface)] p-7 text-center shadow-[var(--wire-shadow-1)]">
|
||||
<span aria-hidden="true" class="icon-[lucide--shield-check] mx-auto block size-12 text-[var(--wire-color-success)]"></span>
|
||||
<h1 class="mt-4 text-3xl font-bold">Protected page unlocked</h1>
|
||||
<p class="text-[var(--wire-color-muted)]">The session grant is valid for 15 minutes.</p>
|
||||
<a href="/" class="mt-5 inline-flex h-11 items-center rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-5 font-semibold text-[var(--wire-color-primary-contrast)]">Back to showcase</a>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
page CaptchaImageStyles {
|
||||
seo {
|
||||
title = "CAPTCHA Image Renderer Styles"
|
||||
description = "Preview every WRNexusJS CAPTCHA image renderer with easy, normal, hard, random, allowed, and excluded style controls."
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="min-h-screen bg-[var(--wire-color-bg)] px-4 py-10 text-[var(--wire-color-text)] sm:px-6 lg:px-8">
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-10">
|
||||
<header class="max-w-4xl">
|
||||
<a href="/" class="mb-4 inline-flex items-center gap-2 text-sm font-semibold text-[var(--wire-color-primary)] hover:underline">
|
||||
<span aria-hidden="true" class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to CAPTCHA showcase
|
||||
</a>
|
||||
<p class="mb-2 text-sm font-semibold text-[var(--wire-color-primary)]">Visual renderer catalog</p>
|
||||
<h1 class="m-0 text-3xl font-bold tracking-tight sm:text-5xl">18 randomized CAPTCHA image styles</h1>
|
||||
<p class="mt-4 text-base leading-7 text-[var(--wire-color-muted)]">
|
||||
Every renderer keeps the answer on the server, supports disturbance from 25 to 75, and generates a fresh cryptographically randomized image for each challenge.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7">
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="mt-0 text-xl font-semibold">Random style by difficulty</h2>
|
||||
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">
|
||||
Use <code>imageStyle="random"</code> for a different renderer whenever a challenge is refreshed. Difficulty controls length and default disturbance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-5 grid items-start gap-5 xl:grid-cols-3">
|
||||
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
|
||||
<h3 class="mt-0 text-base font-semibold">Easy random</h3>
|
||||
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-easy" imageStyle="random" difficulty="easy" disturbance="25" size="compact" showListen="false" />
|
||||
</article>
|
||||
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
|
||||
<h3 class="mt-0 text-base font-semibold">Normal random</h3>
|
||||
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-normal" imageStyle="random" difficulty="normal" disturbance="50" size="compact" showListen="false" />
|
||||
</article>
|
||||
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
|
||||
<h3 class="mt-0 text-base font-semibold">Hard random</h3>
|
||||
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-random-hard" imageStyle="random" difficulty="hard" disturbance="75" size="compact" showListen="false" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-5 sm:p-7">
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="mt-0 text-xl font-semibold">Controlled random pools</h2>
|
||||
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">
|
||||
Limit random selection with <code>allowedStyles</code>, remove renderers with <code>excludedStyles</code>, or force random selection with <code>randomizeStyle="true"</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-5 grid items-start gap-5 lg:grid-cols-2">
|
||||
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
|
||||
<h3 class="mt-0 text-base font-semibold">Allowed pool</h3>
|
||||
<p class="text-xs leading-5 text-[var(--wire-color-muted)]">classic, snow, distortion, wave</p>
|
||||
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="number" action="style-allowed-pool" imageStyle="random" allowedStyles="classic,snow,distortion,wave" disturbance="50" size="compact" showListen="false" />
|
||||
</article>
|
||||
<article class="rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] p-4">
|
||||
<h3 class="mt-0 text-base font-semibold">Excluded styles</h3>
|
||||
<p class="text-xs leading-5 text-[var(--wire-color-muted)]">Excludes collision, split, split2, and broken-lines</p>
|
||||
<Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="number" action="style-excluded-pool" imageStyle="random" excludedStyles="collision,split,split2,broken-lines" disturbance="50" size="compact" showListen="false" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="mt-0 text-2xl font-semibold">All concrete renderers</h2>
|
||||
<p class="text-sm leading-6 text-[var(--wire-color-muted)]">Refresh any card to see a new layout, noise pattern, color treatment, and character distortion while keeping the selected renderer.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Classic</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-classic" imageStyle="classic" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Collision</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-collision" imageStyle="collision" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Snow</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-snow" imageStyle="snow" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Corrosion</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-corrosion" imageStyle="corrosion" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Spiderweb</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-spiderweb" imageStyle="spiderweb" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Cross shadow</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-cross-shadow" imageStyle="cross-shadow" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Split</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-split" imageStyle="split" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Split 2</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-split2" imageStyle="split2" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Cut</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-cut" imageStyle="cut" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Darts</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-darts" imageStyle="darts" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Distortion</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-distortion" imageStyle="distortion" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Stitch</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-stitch" imageStyle="stitch" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Striped</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-striped" imageStyle="striped" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Wave</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-wave" imageStyle="wave" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Grid noise</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-grid-noise" imageStyle="grid-noise" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Scribble</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-scribble" imageStyle="scribble" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Pixel</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-pixel" imageStyle="pixel" disturbance="50" size="compact" showListen="false" /></article>
|
||||
<article class="rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4"><h3 class="mt-0 text-base font-semibold">Broken lines</h3><Captcha endpoint="/api/captcha/challenge" verifyEndpoint="/api/captcha/verify" type="alphanumeric" action="style-broken-lines" imageStyle="broken-lines" disturbance="50" size="compact" showListen="false" /></article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// AUTO-GENERATED by `wrnexus dev` - do not edit.
|
||||
// Typed routes support required, optional, and catch-all parameters.
|
||||
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/captcha": Record<string, never>;
|
||||
"/protected": Record<string, never>;
|
||||
"/styles": Record<string, never>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
if (value === undefined) return "";
|
||||
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
optional = true;
|
||||
name = segment.slice(2, -2);
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
name = segment.slice(1, -1);
|
||||
if (name.endsWith("?")) {
|
||||
optional = true;
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
output.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("...")) {
|
||||
catchAll = true;
|
||||
name = name.slice(3);
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined && optional) continue;
|
||||
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
|
||||
output.push(encodeRouteValue(value, catchAll));
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export default v.object({
|
||||
name: v
|
||||
.string()
|
||||
.required("Enter your name")
|
||||
.trim()
|
||||
.min(2, "Name must contain at least 2 characters")
|
||||
.max(80, "Name must contain at most 80 characters"),
|
||||
email: v
|
||||
.string()
|
||||
.required("Enter your email address")
|
||||
.trim()
|
||||
.email("Enter a valid email address")
|
||||
.max(160, "Email address is too long"),
|
||||
topic: v
|
||||
.string()
|
||||
.required("Choose a topic")
|
||||
.oneOf(["general", "security", "billing", "integration"], "Choose a valid topic"),
|
||||
message: v
|
||||
.string()
|
||||
.required("Enter your message")
|
||||
.trim()
|
||||
.min(10, "Message must contain at least 10 characters")
|
||||
.max(1000, "Message must contain at most 1000 characters"),
|
||||
consent: v.boolean().required("Confirm that we may process this request"),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source "../**/*.wrn";
|
||||
@source "../../../packages/captcha/components/*.wrn";
|
||||
|
||||
html { color-scheme: light dark; }
|
||||
body { margin: 0; background: var(--wire-color-bg); color: var(--wire-color-text); font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; }
|
||||
Reference in New Issue
Block a user