Compare commits
36
Commits
84e16ebc41
...
b807f7b5a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b807f7b5a7 | ||
|
|
d3f26ad16e | ||
|
|
1b470402c7 | ||
|
|
2e770f7e3e | ||
|
|
2d77f5457c | ||
|
|
dd8d6ecc3b | ||
|
|
95c291db64 | ||
|
|
c578bc0f92 | ||
|
|
10771f1a2b | ||
|
|
b1064d442a | ||
|
|
6dea64b84c | ||
|
|
b1db287f2a | ||
|
|
9d90418490 | ||
|
|
61a4f01321 | ||
|
|
bef67b0a24 | ||
|
|
6123a6865e | ||
|
|
18f1d08bc3 | ||
|
|
d0e0dfeb26 | ||
|
|
0c77de93b4 | ||
|
|
298e34ef2e | ||
|
|
65d63dfbe7 | ||
|
|
a077515336 | ||
|
|
24f15e8b78 | ||
|
|
43bc2330b2 | ||
|
|
9e0907ffb2 | ||
|
|
b1b84eb41c | ||
|
|
fe40cc56df | ||
|
|
1dd60ded0d | ||
|
|
25a421c727 | ||
|
|
681679483a | ||
|
|
a85cbf240d | ||
|
|
26af30cfe6 | ||
|
|
bfa72d5679 | ||
|
|
aec1dc6056 | ||
|
|
6acf78a72c | ||
|
|
f7fe3bb17b |
No files matched your search
@@ -235,7 +235,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.26",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
@@ -320,7 +320,7 @@
|
||||
},
|
||||
"packages/compiler": {
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.22",
|
||||
"version": "0.8.27",
|
||||
"dependencies": {
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
@@ -357,7 +357,7 @@
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.56",
|
||||
"version": "0.8.59",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -532,7 +532,7 @@
|
||||
},
|
||||
"packages/payment": {
|
||||
"name": "@wrnexus/payment",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.6",
|
||||
"dependencies": {
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
@@ -668,7 +668,7 @@
|
||||
},
|
||||
"packages/syntax": {
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
},
|
||||
"packages/test": {
|
||||
"name": "@wrnexus/test",
|
||||
@@ -680,7 +680,7 @@
|
||||
},
|
||||
"packages/typecheck": {
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -688,7 +688,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.56",
|
||||
"version": "0.8.70",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.12
|
||||
|
||||
- Fixed false TypeScript diagnostics for `{t:key}` translations in component props and native HTML attributes. Mixed translated/dynamic attributes still validate ordinary expressions.
|
||||
- Fixed inline event handlers containing multiple statements, and infer `event.currentTarget` from the bound HTML/SVG element.
|
||||
- Rebuilt the bundled language server from the shared framework typechecker; real unknown-name, invalid-property and nullable DOM diagnostics remain enabled.
|
||||
|
||||
## 0.8.9
|
||||
|
||||
- Added highlighting for the page-level `apis { }` block and its `<name> <METHOD> <path>` entries,
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "wrnexus",
|
||||
"dependencies": {
|
||||
"vscode-html-languageservice": "^5.6.2",
|
||||
"vscode-languageclient": "^10.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2",
|
||||
},
|
||||
@@ -88,6 +92,8 @@
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@vscode/vsce": ["@vscode/vsce@3.9.2", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA=="],
|
||||
|
||||
"@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="],
|
||||
@@ -542,6 +548,20 @@
|
||||
|
||||
"version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="],
|
||||
|
||||
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@9.0.1", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="],
|
||||
|
||||
"vscode-languageclient": ["vscode-languageclient@10.1.0", "", { "dependencies": { "minimatch": "^10.2.5", "semver": "^7.8.1", "vscode-languageserver-protocol": "3.18.2", "vscode-languageserver-textdocument": "1.0.13" } }, "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA=="],
|
||||
|
||||
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.2", "", { "dependencies": { "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg=="],
|
||||
|
||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||
|
||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
@@ -582,6 +602,8 @@
|
||||
|
||||
"table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"vscode-languageclient/vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.13", "", {}, "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw=="],
|
||||
|
||||
"@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete WRNexus v0.8.3 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.12",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: 3d346a264807ba7b107584dc9a7b505b1d14057b2ad1aa85c5ea890f9b09c272
|
||||
// WRN editor compiler source hash: 89199942be0196a3d06ea3d30dff80d361253192108bd978b5b80d921195c59f
|
||||
// WRN editor compiler generator hash: f8c1094b47e695151205899de28cd63d574356d26ac49219c4a43db0f2391ef5
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -1202,10 +1202,16 @@ function renderAttrs(attrs, csrId, reactive = null, dynamicExpressions) {
|
||||
if (expression &&
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
dynamicExpressions) {
|
||||
const marker = exprRefsState(expression, reactive.stateNames)
|
||||
? ` data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([attr.name, attr.value]))}"`
|
||||
: "";
|
||||
if (isHtmlBooleanAttribute(attr.name)) {
|
||||
dynamicExpressions.push(`\${(${expression}) ? ${JSON.stringify(` ${attr.name}`)} : ""}`);
|
||||
return `\x00WRNEACH${dynamicExpressions.length - 1}\x00${marker}`;
|
||||
}
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
return ` ${attr.name}="${sentinel}"${marker}`;
|
||||
}
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null)
|
||||
@@ -1253,17 +1259,20 @@ function evalStateSeeds(states) {
|
||||
*/
|
||||
function substituteReactiveText(raw, reactive, dynamicExpressions) {
|
||||
const text = substituteTMarkers(raw);
|
||||
if (!reactive || reactive.stateNames.size === 0)
|
||||
if (!reactive)
|
||||
return text;
|
||||
return text.replace(/\{([^{}]+)\}/g, (whole, inner) => {
|
||||
const expr = inner.trim();
|
||||
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames))
|
||||
const clientReactive = exprRefsState(expr, reactive.stateNames);
|
||||
if (expr.startsWith("t:"))
|
||||
return whole;
|
||||
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
|
||||
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
|
||||
return clientReactive ? `<span data-text="${attrEscape(expr)}">${sentinel}</span>` : sentinel;
|
||||
}
|
||||
if (!clientReactive)
|
||||
return whole;
|
||||
let value;
|
||||
try {
|
||||
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
@@ -1339,6 +1348,18 @@ function renderLoopBody(node, locals = []) {
|
||||
if (attr.boolean) {
|
||||
return escLit(` ${name}`);
|
||||
}
|
||||
// Native boolean attributes inside #if/#each must use presence semantics,
|
||||
// just like attributes on the page root. disabled="false" still disables
|
||||
// an HTML input. Component props retain their serialized boolean value.
|
||||
if (!componentTag &&
|
||||
!node.attrs.some((a) => a.name === "data-component") &&
|
||||
isHtmlBooleanAttribute(name)) {
|
||||
const expression = wholeAttributeExpression(attr.value);
|
||||
if (expression)
|
||||
return "${(" + expression + ") ? " + JSON.stringify(` ${name}`) + ' : ""}';
|
||||
if (attr.value === "false")
|
||||
return "";
|
||||
}
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
|
||||
})
|
||||
.join("");
|
||||
@@ -1458,6 +1479,16 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||
const conditions = node.type === "each"
|
||||
? [node.list]
|
||||
: node.branches.flatMap((branch) => (branch.cond ? [branch.cond] : []));
|
||||
// Server-only loader values are not serialized into browser state. Their
|
||||
// controls must not be re-evaluated by a surrounding component's hydration.
|
||||
if (reactive &&
|
||||
conditions.some((condition) => exprRefsState(condition, reactive.runtimeStateNames)) &&
|
||||
!conditions.some((condition) => exprRefsState(condition, reactive.stateNames))) {
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
const definition = node.type === "each"
|
||||
? {
|
||||
list: node.list,
|
||||
@@ -1621,7 +1652,7 @@ function renderNestedComponentInvocation(node, ctx) {
|
||||
const wholeExpression = wholeAttributeExpression(attr.value);
|
||||
const compiledValue = wholeExpression
|
||||
? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(attr.value, ctx);
|
||||
: compileAttrValue(attr.value, ctx, attr.name === "pattern");
|
||||
const rendered = ` ${attr.name}="${compiledValue}"`;
|
||||
if (!attr.value.includes("{") ||
|
||||
(!exprRefsState(attr.value, ctx.stateNames) && !exprRefsState(attr.value, ctx.propNames))) {
|
||||
@@ -2250,7 +2281,7 @@ function generateInner(ast) {
|
||||
.map((load) => load.name),
|
||||
];
|
||||
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
|
||||
const reactive = reactiveNames.length > 0
|
||||
const reactive = reactiveNames.length > 0 || runtimeStateNames.size > 0
|
||||
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
|
||||
: null;
|
||||
const loops = [];
|
||||
@@ -2739,7 +2770,7 @@ function compileText(raw, ctx) {
|
||||
return out + escLit(raw.slice(last));
|
||||
}
|
||||
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
|
||||
function compileAttrValue(raw, ctx) {
|
||||
function compileAttrValue(raw, ctx, regexPattern = false) {
|
||||
if (!raw.includes("{"))
|
||||
return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
@@ -2749,7 +2780,10 @@ function compileAttrValue(raw, ctx) {
|
||||
while ((m = INTERP_RE.exec(raw))) {
|
||||
out += escLit(attrEscape(raw.slice(last, m.index)));
|
||||
const expr = m[1].trim();
|
||||
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
if (regexPattern && /^\d+(?:,\d*)?$/.test(expr)) {
|
||||
out += escLit(attrEscape(m[0])); // Regex quantifiers are not template expressions.
|
||||
}
|
||||
else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
|
||||
}
|
||||
else {
|
||||
@@ -3047,7 +3081,7 @@ function renderComponentNode(node, ctx) {
|
||||
}
|
||||
const compiledValue = isExplicitComponentMount && wholeExpression
|
||||
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(a.value, elementContext);
|
||||
: compileAttrValue(a.value, elementContext, a.name === "pattern");
|
||||
/*
|
||||
* A constraint attribute whose value is only known at render time (a
|
||||
* component prop defaulting to "") must be decided at render time, so
|
||||
@@ -3528,7 +3562,7 @@ function __wrnProp(v) {
|
||||
const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v);
|
||||
return __wrnAttr(value);
|
||||
}
|
||||
function renderPageComponentAttr(attr, dynamicExpressions, _reactive) {
|
||||
function renderPageComponentAttr(attr, dynamicExpressions, reactive) {
|
||||
if (attr.event) {
|
||||
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
|
||||
}
|
||||
@@ -3541,6 +3575,11 @@ function renderPageComponentAttr(attr, dynamicExpressions, _reactive) {
|
||||
}
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
if (reactive &&
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
!exprRefsState(expression, reactive.stateNames)) {
|
||||
return ` ${attr.name}="${marker}"`;
|
||||
}
|
||||
// The mount disappears during SSR. Preserve every dynamic component prop
|
||||
// as a parent-owned binding; server-only expressions simply fail closed in
|
||||
// the browser, while page state can continue driving the mounted child.
|
||||
@@ -6229,7 +6268,7 @@ function expandStructuredStateDeclarations(lines, unit) {
|
||||
});
|
||||
}
|
||||
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(value);
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
|
||||
if (!match)
|
||||
return null;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: d07c535bd69332e90f207cbc254aae06982b40dd863fbc8de63fb386ecf31f50
|
||||
// WRN editor extension source hash: fd585db47099c8b781bf49e4ce7c86146bad583f781d0aa93196eb200a95b20f
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
@@ -10089,7 +10089,7 @@ var require_commonjs = __commonJS(function(exports2) {
|
||||
// editors/vscode/node_modules/brace-expansion/dist/commonjs/index.js
|
||||
var require_commonjs2 = __commonJS(function(exports2) {
|
||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||
exports2.EXPANSION_MAX_LENGTH = exports2.EXPANSION_MAX = undefined;
|
||||
exports2.EXPANSION_MAX = undefined;
|
||||
exports2.expand = expand;
|
||||
var balanced_match_1 = require_commonjs();
|
||||
var escSlash = "\x00SLASH" + Math.random() + "\x00";
|
||||
@@ -10108,7 +10108,6 @@ var require_commonjs2 = __commonJS(function(exports2) {
|
||||
var commaPattern = /\\,/g;
|
||||
var periodPattern = /\\\./g;
|
||||
exports2.EXPANSION_MAX = 1e5;
|
||||
exports2.EXPANSION_MAX_LENGTH = 4000000;
|
||||
function numeric(str) {
|
||||
return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0);
|
||||
}
|
||||
@@ -10142,11 +10141,11 @@ var require_commonjs2 = __commonJS(function(exports2) {
|
||||
if (!str) {
|
||||
return [];
|
||||
}
|
||||
const { max = exports2.EXPANSION_MAX, maxLength = exports2.EXPANSION_MAX_LENGTH } = options;
|
||||
const { max = exports2.EXPANSION_MAX } = options;
|
||||
if (str.slice(0, 2) === "{}") {
|
||||
str = "\\{\\}" + str.slice(2);
|
||||
}
|
||||
return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
|
||||
return expand_(escapeBraces(str), max, true).map(unescapeBraces);
|
||||
}
|
||||
function embrace(str) {
|
||||
return "{" + str + "}";
|
||||
@@ -10160,87 +10159,20 @@ var require_commonjs2 = __commonJS(function(exports2) {
|
||||
function gte(i, y) {
|
||||
return i >= y;
|
||||
}
|
||||
function combine(acc, pre, values, max, maxLength, dropEmpties) {
|
||||
const out = [];
|
||||
let length = 0;
|
||||
for (let a = 0;a < acc.length; a++) {
|
||||
for (let v = 0;v < values.length; v++) {
|
||||
if (out.length >= max)
|
||||
return out;
|
||||
const expansion = acc[a] + pre + values[v];
|
||||
if (dropEmpties && !expansion)
|
||||
continue;
|
||||
if (length + expansion.length > maxLength)
|
||||
return out;
|
||||
out.push(expansion);
|
||||
length += expansion.length;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function expandSequence(body, isAlphaSequence, max, maxLength) {
|
||||
const n = body.split(/\.\./);
|
||||
const N = [];
|
||||
if (n[0] === undefined || n[1] === undefined) {
|
||||
return N;
|
||||
}
|
||||
const x = numeric(n[0]);
|
||||
const y = numeric(n[1]);
|
||||
const width = Math.max(n[0].length, n[1].length);
|
||||
let incr = n.length === 3 && n[2] !== undefined ? Math.max(Math.abs(numeric(n[2])), 1) : 1;
|
||||
let test = lte;
|
||||
const reverse = y < x;
|
||||
if (reverse) {
|
||||
incr *= -1;
|
||||
test = gte;
|
||||
}
|
||||
const pad = n.some(isPadded);
|
||||
let length = 0;
|
||||
for (let i = x;test(i, y) && N.length < max; i += incr) {
|
||||
let c;
|
||||
if (isAlphaSequence) {
|
||||
c = String.fromCharCode(i);
|
||||
if (c === "\\") {
|
||||
c = "";
|
||||
}
|
||||
} else {
|
||||
c = String(i);
|
||||
if (pad) {
|
||||
const need = width - c.length;
|
||||
if (need > 0) {
|
||||
const z = new Array(need + 1).join("0");
|
||||
if (i < 0) {
|
||||
c = "-" + z + c.slice(1);
|
||||
} else {
|
||||
c = z + c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (length + c.length > maxLength)
|
||||
break;
|
||||
N.push(c);
|
||||
length += c.length;
|
||||
}
|
||||
return N;
|
||||
}
|
||||
function expand_(str, max, maxLength, isTop) {
|
||||
let acc = [""];
|
||||
let dropEmpties = false;
|
||||
let firstGroup = true;
|
||||
function expand_(str, max, isTop) {
|
||||
const expansions = [];
|
||||
for (;; ) {
|
||||
const m = (0, balanced_match_1.balanced)("{", "}", str);
|
||||
if (!m) {
|
||||
return combine(acc, str, [""], max, maxLength, dropEmpties);
|
||||
}
|
||||
if (!m)
|
||||
return [str];
|
||||
const pre = m.pre;
|
||||
if (/\$$/.test(pre)) {
|
||||
acc = combine(acc, pre + "{" + m.body + "}", [""], max, maxLength, dropEmpties && !m.post.length);
|
||||
firstGroup = false;
|
||||
if (!m.post.length)
|
||||
break;
|
||||
str = m.post;
|
||||
continue;
|
||||
if (/\$$/.test(m.pre)) {
|
||||
const post2 = m.post.length ? expand_(m.post, max, false) : [""];
|
||||
for (let k = 0;k < post2.length && k < max; k++) {
|
||||
const expansion = pre + "{" + m.body + "}" + post2[k];
|
||||
expansions.push(expansion);
|
||||
}
|
||||
return expansions;
|
||||
}
|
||||
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
|
||||
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
|
||||
@@ -10252,56 +10184,74 @@ var require_commonjs2 = __commonJS(function(exports2) {
|
||||
isTop = true;
|
||||
continue;
|
||||
}
|
||||
return combine(acc, pre + "{" + m.body + "}" + m.post, [""], max, maxLength, dropEmpties);
|
||||
return [str];
|
||||
}
|
||||
if (firstGroup) {
|
||||
dropEmpties = isTop && !isSequence;
|
||||
firstGroup = false;
|
||||
}
|
||||
let values;
|
||||
const post = m.post.length ? expand_(m.post, max, false) : [""];
|
||||
let n;
|
||||
if (isSequence) {
|
||||
values = expandSequence(m.body, isAlphaSequence, max, maxLength);
|
||||
n = m.body.split(/\.\./);
|
||||
} else {
|
||||
let n = parseCommaParts(m.body);
|
||||
n = parseCommaParts(m.body);
|
||||
if (n.length === 1 && n[0] !== undefined) {
|
||||
n = expand_(n[0], max, maxLength, false).map(embrace);
|
||||
n = expand_(n[0], max, false).map(embrace);
|
||||
if (n.length === 1) {
|
||||
acc = combine(acc, pre + n[0], [""], max, maxLength, dropEmpties && !m.post.length);
|
||||
if (!m.post.length)
|
||||
break;
|
||||
str = m.post;
|
||||
continue;
|
||||
return post.map((p) => m.pre + n[0] + p);
|
||||
}
|
||||
}
|
||||
let dropsEmpties = dropEmpties && !m.post.length && !pre;
|
||||
for (let d = 0;dropsEmpties && d < acc.length; d++) {
|
||||
if (acc[d]) {
|
||||
dropsEmpties = false;
|
||||
}
|
||||
}
|
||||
let N;
|
||||
if (isSequence && n[0] !== undefined && n[1] !== undefined) {
|
||||
const x = numeric(n[0]);
|
||||
const y = numeric(n[1]);
|
||||
const width = Math.max(n[0].length, n[1].length);
|
||||
let incr = n.length === 3 && n[2] !== undefined ? Math.max(Math.abs(numeric(n[2])), 1) : 1;
|
||||
let test = lte;
|
||||
const reverse = y < x;
|
||||
if (reverse) {
|
||||
incr *= -1;
|
||||
test = gte;
|
||||
}
|
||||
values = [];
|
||||
let valuesLength = 0;
|
||||
outer:
|
||||
for (let j = 0;j < n.length; j++) {
|
||||
const expanded = expand_(n[j], max, maxLength, false);
|
||||
for (let k = 0;k < expanded.length; k++) {
|
||||
const v = expanded[k];
|
||||
if (dropsEmpties && !v)
|
||||
continue;
|
||||
if (values.length >= max || valuesLength + v.length > maxLength) {
|
||||
break outer;
|
||||
const pad = n.some(isPadded);
|
||||
N = [];
|
||||
for (let i = x;test(i, y) && N.length < max; i += incr) {
|
||||
let c;
|
||||
if (isAlphaSequence) {
|
||||
c = String.fromCharCode(i);
|
||||
if (c === "\\") {
|
||||
c = "";
|
||||
}
|
||||
} else {
|
||||
c = String(i);
|
||||
if (pad) {
|
||||
const need = width - c.length;
|
||||
if (need > 0) {
|
||||
const z = new Array(need + 1).join("0");
|
||||
if (i < 0) {
|
||||
c = "-" + z + c.slice(1);
|
||||
} else {
|
||||
c = z + c;
|
||||
}
|
||||
}
|
||||
values.push(v);
|
||||
valuesLength += v.length;
|
||||
}
|
||||
}
|
||||
N.push(c);
|
||||
}
|
||||
} else {
|
||||
N = [];
|
||||
for (let j = 0;j < n.length; j++) {
|
||||
N.push.apply(N, expand_(n[j], max, false));
|
||||
}
|
||||
}
|
||||
acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length);
|
||||
if (!m.post.length)
|
||||
break;
|
||||
str = m.post;
|
||||
for (let j = 0;j < N.length; j++) {
|
||||
for (let k = 0;k < post.length && expansions.length < max; k++) {
|
||||
const expansion = pre + N[j] + post[k];
|
||||
if (!isTop || isSequence || expansion) {
|
||||
expansions.push(expansion);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expansions;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13933,7 +13883,7 @@ var require_configuration = __commonJS(function(exports2) {
|
||||
exports2.SyncConfigurationFeature = SyncConfigurationFeature;
|
||||
});
|
||||
|
||||
// editors/vscode/node_modules/vscode-languageserver-textdocument/lib/umd/main.js
|
||||
// editors/vscode/node_modules/vscode-languageclient/node_modules/vscode-languageserver-textdocument/lib/umd/main.js
|
||||
var require_main2 = __commonJS(function(exports2, module2) {
|
||||
var __spreadArray = exports2 && exports2.__spreadArray || function(to, from, pack) {
|
||||
if (pack || arguments.length === 2)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// WRN editor language server source hash: 5258b3e76cec47df752d4bb886fbd57a3f3c4af9ca9b6b2bf6742c8d78025ff1
|
||||
// WRN editor language server source hash: d192eefe86b41eaa54eb48a69202842dbeb8ec4ee4e962c912b288820a65b162
|
||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||
// @bun @bun-cjs
|
||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||
@@ -170723,7 +170723,7 @@ function expandStructuredStateDeclarations(lines, unit) {
|
||||
});
|
||||
}
|
||||
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(value);
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
|
||||
if (!match)
|
||||
return null;
|
||||
try {
|
||||
@@ -173217,12 +173217,13 @@ ${sharedAliases}`);
|
||||
append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`);
|
||||
}
|
||||
let bindingIndex = 0;
|
||||
append("type __WrnEventElement<Tag extends string> = Tag extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[Tag] : Tag extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[Tag] : HTMLElement;");
|
||||
const reservedProps = new Set(ast.props.map((prop) => prop.name).filter((name) => !safeBindingName(name)));
|
||||
const appendViewBindings = (nodes, locals) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
for (const attr of node.attrs) {
|
||||
const expressions = attr.event ? [attr.value] : extractInterpolations(attr.value).map((segment) => segment.expression);
|
||||
const expressions = attr.event ? [attr.value] : extractInterpolations(attr.value).filter((segment) => !/^t:[^{}]+$/.test(segment.expression)).map((segment) => segment.expression);
|
||||
for (const expression of expressions) {
|
||||
if (!expression.trim())
|
||||
continue;
|
||||
@@ -173230,10 +173231,10 @@ ${sharedAliases}`);
|
||||
...[...locals].map((name) => `declare const ${name}: any;`),
|
||||
...attr.event ? [
|
||||
"declare const payload: any;",
|
||||
"declare const event: Event & { target: HTMLElement };"
|
||||
`declare const event: Event & { target: HTMLElement; currentTarget: __WrnEventElement<${JSON.stringify(node.tag)}> };`
|
||||
] : []
|
||||
].join(" ");
|
||||
append(`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${safeViewExpression(expression, reservedProps)}); }`, attr.value);
|
||||
append(`namespace __wrn_view_${bindingIndex++} { ${declarations} ${attr.event ? `void (() => { ${safeViewExpression(expression, reservedProps)} });` : `void (${safeViewExpression(expression, reservedProps)});`} }`, attr.value);
|
||||
}
|
||||
}
|
||||
appendViewBindings(node.children, locals);
|
||||
|
||||
@@ -29,8 +29,8 @@ test("formats large typed JSON prop defaults with the bundled formatter", () =>
|
||||
}`;
|
||||
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2, printWidth: 70 });
|
||||
|
||||
assert.match(formatted, /categories: unknown\[\] = \[\n \{/);
|
||||
assert.match(formatted, /"apps": \[\n \{/);
|
||||
assert.match(formatted, /categories: unknown\[\] = \[\n {6}\{/);
|
||||
assert.match(formatted, /"apps": \[\n {10}\{/);
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2, printWidth: 70 }), formatted);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,51 @@ function run(messages) {
|
||||
return parseResponses(result.stdout || Buffer.alloc(0));
|
||||
}
|
||||
|
||||
test("bundled language server accepts translated attributes and form statement handlers", () => {
|
||||
const uri = "file:///tmp/wrnexus-translated-contact.wrn";
|
||||
const valid = `page Contact {
|
||||
view {
|
||||
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
|
||||
<form @wrn:success="event.currentTarget.reset(); toast.success('Sent')"></form>
|
||||
}
|
||||
}`;
|
||||
const responses = run([
|
||||
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { rootPath: root } },
|
||||
{ jsonrpc: "2.0", method: "initialized", params: {} },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/didOpen",
|
||||
params: { textDocument: { uri, languageId: "wrn", version: 1, text: valid } },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/didChange",
|
||||
params: {
|
||||
textDocument: { uri, version: 2 },
|
||||
contentChanges: [
|
||||
{ text: valid.replace("{t:contact.title}", "{t:contact.title} {missingValue}") },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "textDocument/didSave", params: { textDocument: { uri } } },
|
||||
{ jsonrpc: "2.0", id: 2, method: "shutdown", params: {} },
|
||||
{ jsonrpc: "2.0", method: "exit", params: {} },
|
||||
]);
|
||||
const publications = responses.filter(
|
||||
(item) => item.method === "textDocument/publishDiagnostics",
|
||||
);
|
||||
assert.equal(publications.length, 2);
|
||||
assert.deepEqual(
|
||||
publications[0].params.diagnostics.filter((item) => item.source === "wrnexus-types"),
|
||||
[],
|
||||
);
|
||||
const errors = publications[1].params.diagnostics.filter(
|
||||
(item) => item.source === "wrnexus-types",
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].message, /missingValue/);
|
||||
});
|
||||
|
||||
test("language server starts under Node and keeps official typed components clean", () => {
|
||||
const files = [
|
||||
path.join(root, "packages", "ui", "components", "AuthForm.wrn"),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.19
|
||||
|
||||
- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks.
|
||||
- Add `onSignUp` as an alias for `onSuccessfulSignUp`, rejecting ambiguous duplicate hooks.
|
||||
- Add a `verificationSession` signup action and database-backed, hashed, purpose-bound verification cookies. Sessions survive engine reloads, do not authenticate users, and expire after 30 minutes by default.
|
||||
- Verification resend resolves the pending cookie's user before considering an anonymous identifier.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Added `AuthEngineOptions.tokenUrl` for custom verification, reset, magic-link, and invitation page URLs without application-owned API handlers.
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# @wrnexus/auth
|
||||
|
||||
### New-browser verification and device trust
|
||||
|
||||
Enable `requireTrustedDeviceVerification: true` to require a verified email or
|
||||
SMS challenge before an untrusted browser receives a sign-in session. The HTTP
|
||||
handlers use a host-only HttpOnly random cookie, ignoring posted fingerprints.
|
||||
Direct engine callers must supply a trusted server-side browser fingerprint.
|
||||
`trustSignupDevice: true` grants the registration browser one-time trust after
|
||||
required account verification, at its first successful sign-in.
|
||||
|
||||
Use `trustedDeviceTtlMs: 30 * 86400_000` for 30-day trust. Ordinary logins do not
|
||||
renew it. The verification form offers explicit trust consent; expiry/revocation
|
||||
requires a new email/SMS challenge. Successful login alerts use both verified
|
||||
contact channels when configured. Delivery still requires an application adapter.
|
||||
|
||||
### Signup business logic and verification-only sessions
|
||||
|
||||
```ts
|
||||
const auth = createAuthEngine({
|
||||
store: () => new SqlAuthStore(getDb()),
|
||||
secret: process.env.AUTH_SECRET!,
|
||||
requireVerifiedEmail: true,
|
||||
onSignUp(ctx, user) {
|
||||
// Application business logic can run here after successful registration.
|
||||
return { verificationSession: true, redirectTo: "/verify-email" };
|
||||
},
|
||||
delivery: {
|
||||
async send(message) {
|
||||
// message.channel, template, destination, user, expiresAt
|
||||
// Verification uses message.token and message.url; OTP flows use message.code.
|
||||
// Render your branded content here before passing it to your provider.
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onSuccessfulSignUp` remains supported; configure only one signup hook. Hooks run
|
||||
from the HTTP registration handler and may return an action or a custom Response.
|
||||
The request origin (or configured `auth.baseUrl`) supplies signup link URLs; a
|
||||
client-supplied `baseUrl` is never trusted. Direct `engine.register` callers may
|
||||
provide a trusted `baseUrl` explicitly.
|
||||
|
||||
The verification cookie is HttpOnly, SameSite=Lax, Secure on HTTPS, and host-only.
|
||||
Its opaque token is stored hashed in the auth token store, separate from login
|
||||
sessions; it cannot bypass email verification, MFA, or account policy. Use
|
||||
`getVerificationUser(ctx, auth)` to guard your verification page and
|
||||
`clearVerificationSession(ctx, auth)` after verification. Use
|
||||
`startVerificationSession(ctx, auth, user.id)` only after credential validation
|
||||
when an unverified user signs in again. SQL-backed sessions survive server reloads;
|
||||
memory stores remain development/test-only. No email or SMS provider is configured
|
||||
automatically.
|
||||
|
||||
Framework-native authentication, identity, account-security, and session management for WRNexusJS.
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -137,10 +137,15 @@
|
||||
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
||||
}
|
||||
|
||||
function navigate(target) {
|
||||
function navigate(target, allowCrossOrigin = false) {
|
||||
if (!target) return false;
|
||||
const url = new URL(target, location.href);
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return false;
|
||||
if (url.origin !== location.origin) {
|
||||
if (!allowCrossOrigin) return false;
|
||||
location.assign(url.href);
|
||||
return true;
|
||||
}
|
||||
if (window.__wrnexusNavigate) {
|
||||
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
|
||||
} else {
|
||||
@@ -262,6 +267,9 @@
|
||||
);
|
||||
setState(element, "success", element.dataset.successMessage || "Signed in.");
|
||||
dispatch(element, "auth:passkey-authenticated", result);
|
||||
// The same-origin verification endpoint resolves and validates the final
|
||||
// destination, including cross-app SSO handoffs. Never SPA-route those.
|
||||
if (typeof result.redirect === "string" && navigate(result.redirect, true)) return;
|
||||
if (element.dataset.redirect) navigate(element.dataset.redirect);
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") return;
|
||||
@@ -405,83 +413,175 @@
|
||||
const form = element.querySelector("[data-auth-mfa-form]");
|
||||
const tokenInput = form?.querySelector("[name=mfaToken]");
|
||||
const challengeInput = form?.querySelector("[name=challengeId]");
|
||||
const methodInput = form?.querySelector("[data-auth-mfa-method]");
|
||||
const codeInput = form?.querySelector("[name=code]");
|
||||
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
|
||||
const status = form?.querySelector("[data-auth-mfa-status]");
|
||||
const saved = readMfaState();
|
||||
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
|
||||
tokenInput.value = String(saved.mfaToken);
|
||||
}
|
||||
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
|
||||
const initialMethod = element.dataset.mfaInitialMethod;
|
||||
if (methodInput && allowed.length) {
|
||||
Array.from(methodInput.options).forEach((option) => {
|
||||
option.disabled = !allowed.includes(option.value);
|
||||
option.hidden = option.disabled;
|
||||
const challenges = new Map();
|
||||
const pending = new Map();
|
||||
let expired = false;
|
||||
let disposed = false;
|
||||
let expiryTimer;
|
||||
const expiresAt = Number(saved?.mfaExpiresAt);
|
||||
const expire = () => {
|
||||
if (disposed) return;
|
||||
expired = true;
|
||||
window.clearTimeout(expiryTimer);
|
||||
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
||||
if (tokenInput) tokenInput.value = "";
|
||||
form?.querySelectorAll("input, button").forEach((control) => {
|
||||
control.disabled = true;
|
||||
});
|
||||
const selected = allowed.includes(initialMethod)
|
||||
? initialMethod
|
||||
: allowed.includes(methodInput.value)
|
||||
? methodInput.value
|
||||
: allowed[0];
|
||||
if (selected) methodInput.value = selected;
|
||||
} else if (methodInput && initialMethod) {
|
||||
methodInput.value = initialMethod;
|
||||
}
|
||||
|
||||
const onMethodChange = () => {
|
||||
if (challengeInput) challengeInput.value = "";
|
||||
// A form response's finalizer or hydration may re-enable buttons. Hide the
|
||||
// entire stale form instead of leaving a second apparently usable action.
|
||||
element.dataset.mfaExpired = "true";
|
||||
if (form) form.hidden = true;
|
||||
setMessage(status, "");
|
||||
if (codeInput) {
|
||||
codeInput.value = "";
|
||||
codeInput.setAttribute(
|
||||
"inputmode",
|
||||
methodInput?.value === "recovery-code" ? "text" : "numeric",
|
||||
);
|
||||
}
|
||||
form?.querySelectorAll("[data-error]").forEach((error) => {
|
||||
error.textContent = "";
|
||||
});
|
||||
const expiredMessage = element.querySelector("[data-auth-mfa-expired]");
|
||||
if (expiredMessage) expiredMessage.hidden = false;
|
||||
const restart = element.querySelector("[data-auth-mfa-restart]");
|
||||
if (restart) restart.hidden = false;
|
||||
};
|
||||
const checkExpiry = () => {
|
||||
// Old stored challenges have no verifiable deadline: require fresh sign-in.
|
||||
if (saved?.mfaToken && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) expire();
|
||||
return expired;
|
||||
};
|
||||
const restoreToken = () => {
|
||||
checkExpiry();
|
||||
if (!expired && tokenInput && !tokenInput.value && saved?.mfaToken)
|
||||
tokenInput.value = String(saved.mfaToken);
|
||||
};
|
||||
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
|
||||
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
|
||||
for (const radio of radios) {
|
||||
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
|
||||
const label = radio.closest("label");
|
||||
if (label) label.hidden = radio.disabled;
|
||||
}
|
||||
const initial =
|
||||
radios.find((radio) => !radio.disabled && radio.value === element.dataset.mfaInitialMethod) ||
|
||||
radios.find((radio) => !radio.disabled);
|
||||
for (const radio of radios) radio.checked = radio === initial;
|
||||
restoreToken();
|
||||
|
||||
const onSubmit = async (event) => {
|
||||
const method = methodInput?.value;
|
||||
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const sendCode = async (selected) => {
|
||||
if (disposed || expired || !isOtp(selected)) return;
|
||||
restoreToken();
|
||||
if (expired) return;
|
||||
if (!tokenInput?.value) {
|
||||
setMessage(status, "Sign-in verification has expired. Start again.", true);
|
||||
const restart = element.querySelector("[data-auth-mfa-restart]");
|
||||
if (restart) restart.hidden = false;
|
||||
return;
|
||||
}
|
||||
setFormBusy(form, true);
|
||||
setMessage(status, "Sending a one-time code…");
|
||||
const previous = challenges.get(selected);
|
||||
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
|
||||
challenges.delete(selected);
|
||||
if (!pending.has(selected) && !challenges.has(selected)) {
|
||||
pending.set(
|
||||
selected,
|
||||
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
|
||||
mfaToken: tokenInput.value,
|
||||
method: selected,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (method() === selected) {
|
||||
setFormBusy(form, true);
|
||||
setMessage(status, "Sending a one-time code…");
|
||||
}
|
||||
try {
|
||||
const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
|
||||
mfaToken: tokenInput.value,
|
||||
method,
|
||||
});
|
||||
const result = challenges.get(selected) || (await pending.get(selected));
|
||||
if (disposed || expired) return;
|
||||
challenges.set(selected, result);
|
||||
if (method() !== selected) return;
|
||||
if (challengeInput) challengeInput.value = String(result.id || "");
|
||||
setMessage(status, "A one-time code has been sent.");
|
||||
codeInput?.focus();
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
status,
|
||||
error instanceof Error ? error.message : "Unable to send a one-time code",
|
||||
true,
|
||||
selected === "email-otp"
|
||||
? "A code has been sent to your email."
|
||||
: "A code has been sent to your mobile number.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!disposed && !expired && method() === selected)
|
||||
setMessage(
|
||||
status,
|
||||
error instanceof Error ? error.message : "Unable to send a one-time code",
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
setFormBusy(form, false);
|
||||
pending.delete(selected);
|
||||
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
|
||||
}
|
||||
};
|
||||
const updateMethod = () => {
|
||||
if (checkExpiry()) return;
|
||||
const selected = method();
|
||||
setFormBusy(form, false);
|
||||
if (challengeInput) challengeInput.value = "";
|
||||
const code = form?.querySelector("[name=code]");
|
||||
if (code) code.value = "";
|
||||
const error = form?.querySelector("[data-error=code]");
|
||||
if (error) error.textContent = "";
|
||||
setMessage(status, "");
|
||||
for (const [selector, active] of [
|
||||
["[data-auth-mfa-pin]", selected !== "recovery-code"],
|
||||
["[data-auth-mfa-recovery]", selected === "recovery-code"],
|
||||
]) {
|
||||
const group = form?.querySelector(selector);
|
||||
if (!group) continue;
|
||||
group.hidden = !active;
|
||||
for (const input of group.querySelectorAll("input")) {
|
||||
input.disabled = !active;
|
||||
input.value = "";
|
||||
input.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
if (isOtp(selected)) void sendCode(selected);
|
||||
};
|
||||
const onMethodChange = (event) => {
|
||||
if (radios.includes(event.target)) updateMethod();
|
||||
};
|
||||
const onSubmit = (event) => {
|
||||
restoreToken();
|
||||
const code = form?.querySelector("[name=code]");
|
||||
const active = form?.querySelector(
|
||||
method() === "recovery-code" ? "[name=recoveryCode]" : "[name=otpCode]",
|
||||
);
|
||||
if (code && active) code.value = active.value;
|
||||
if (expired || !method() || (isOtp(method()) && !challengeInput?.value)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!expired && isOtp(method())) void sendCode(method());
|
||||
}
|
||||
};
|
||||
const onSuccess = (event) => {
|
||||
if (event.target !== form) return;
|
||||
sessionStorage.removeItem(MFA_STORAGE_KEY);
|
||||
if (event.target === form) sessionStorage.removeItem(MFA_STORAGE_KEY);
|
||||
};
|
||||
onMethodChange();
|
||||
methodInput?.addEventListener("change", onMethodChange);
|
||||
const onError = (event) => {
|
||||
if (event.target !== form || event.detail?.code !== "mfa-transaction-expired") return;
|
||||
expire();
|
||||
};
|
||||
updateMethod();
|
||||
if (!expired && Number.isFinite(expiresAt)) {
|
||||
expiryTimer = window.setTimeout(expire, Math.max(0, expiresAt - Date.now()));
|
||||
}
|
||||
// Capture native change before the shared Radio emits its component output.
|
||||
form?.addEventListener("change", onMethodChange, true);
|
||||
form?.addEventListener("submit", onSubmit, true);
|
||||
element.addEventListener("wrn:success", onSuccess);
|
||||
element.addEventListener("wrn:error", onError);
|
||||
flowCleanups.set(element, () => {
|
||||
methodInput?.removeEventListener("change", onMethodChange);
|
||||
disposed = true;
|
||||
window.clearTimeout(expiryTimer);
|
||||
form?.removeEventListener("change", onMethodChange, true);
|
||||
form?.removeEventListener("submit", onSubmit, true);
|
||||
element.removeEventListener("wrn:success", onSuccess);
|
||||
element.removeEventListener("wrn:error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, PinInput, Select } from "@wrnexus/ui"
|
||||
import { Button, Input, PinInput, Radio, Checkbox } from "@wrnexus/ui"
|
||||
|
||||
component TwoFactorChallenge {
|
||||
props {
|
||||
@@ -13,6 +13,7 @@ component TwoFactorChallenge {
|
||||
description = "Choose a verification method and enter the code."
|
||||
submitLabel = "Verify"
|
||||
recoveryHref = "/recover"
|
||||
signInHref = "/login"
|
||||
color = "primary"
|
||||
size = "md"
|
||||
class = ""
|
||||
@@ -39,15 +40,30 @@ component TwoFactorChallenge {
|
||||
<input type="hidden" name="mfaToken" value='{mfaToken}' />
|
||||
<input type="hidden" name="challengeId" value='{challengeId}' />
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<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}" />
|
||||
<input type="hidden" name="code" value="" />
|
||||
<Radio id="auth-mfa-method" name="method" data-auth-mfa-method label="Verification method" value="{method}" options="{methodOptions}" card="true" color="{color}" size="{size}" />
|
||||
<div data-auth-mfa-pin><PinInput label="Verification code" name="otpCode" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" /></div>
|
||||
<div data-auth-mfa-recovery hidden><Input label="Recovery code" name="recoveryCode" autocomplete="off" maxlength="32" color="{color}" size="{size}" /></div>
|
||||
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
|
||||
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
|
||||
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
|
||||
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
|
||||
<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(--wrn-color-primary)] hover:underline">Account recovery options</a>
|
||||
<p data-auth-mfa-expired role="alert" hidden class="wrn-auth-mfa-expired">Your sign-in request has expired. Sign in again to get a fresh verification request.</p>
|
||||
<nav class="wrn-auth-mfa-links" aria-label="Sign-in help">
|
||||
<a href='{signInHref}' data-auth-mfa-restart hidden>Back to sign in</a>
|
||||
<a href='{recoveryHref}'>Account recovery options</a>
|
||||
</nav>
|
||||
</section>
|
||||
}
|
||||
style {
|
||||
[data-auth-mfa-challenge][data-mfa-expired="true"] [data-auth-mfa-form] { display: none; }
|
||||
.wrn-auth-mfa-expired { margin: 1.5rem 0 0; padding: 1rem; border: 1px solid var(--wrn-color-danger); border-radius: var(--wrn-radius-sm); background: color-mix(in srgb, var(--wrn-color-danger) 10%, transparent); color: var(--wrn-color-danger); font-size: 0.875rem; line-height: 1.5; }
|
||||
.wrn-auth-mfa-links { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; margin-top: 1.25rem; }
|
||||
.wrn-auth-mfa-links a { color: var(--wrn-color-primary); font-size: 0.875rem; }
|
||||
.wrn-auth-mfa-links a:hover { text-decoration: underline; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.26",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
+175
-33
@@ -45,13 +45,18 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
|
||||
"change-email": 30 * 60_000,
|
||||
"change-phone": 15 * 60_000,
|
||||
"login-mfa": 5 * 60_000,
|
||||
"verification-session": 30 * 60_000,
|
||||
};
|
||||
|
||||
export interface AuthEngine {
|
||||
readonly deviceTrustPolicyEnabled: boolean;
|
||||
readonly store: AuthStore;
|
||||
readonly onSignedIn?: AuthSignedInHandler;
|
||||
readonly onSignedOut?: AuthSignedOutHandler;
|
||||
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||
readonly onSignUp?: AuthSuccessfulSignUpHandler;
|
||||
createVerificationSession(userId: string): Promise<string>;
|
||||
validateVerificationSession(token: string): Promise<ReturnType<typeof publicUser> | undefined>;
|
||||
register(input: RegisterInput): Promise<AuthResult>;
|
||||
login(input: LoginInput): Promise<AuthResult>;
|
||||
logout(sessionId: string, reason?: string): Promise<void>;
|
||||
@@ -97,7 +102,10 @@ export interface AuthEngine {
|
||||
token: string,
|
||||
input: { password?: string; displayName?: string },
|
||||
): Promise<AuthResult>;
|
||||
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
|
||||
consumeMagicLink(
|
||||
token: string,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
issueOtp(
|
||||
userId: string,
|
||||
method: "email-otp" | "sms-otp",
|
||||
@@ -116,18 +124,20 @@ export interface AuthEngine {
|
||||
completeOtpLogin(
|
||||
challengeId: string,
|
||||
code: string,
|
||||
sessionInput?: Partial<AuthSession>,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
beginMfaOtp(
|
||||
mfaToken: string,
|
||||
method: "email-otp" | "sms-otp",
|
||||
): Promise<{ id: string; expiresAt: number } | undefined>;
|
||||
completeMfa(input: {
|
||||
rememberDevice?: boolean;
|
||||
fingerprint?: string;
|
||||
mfaToken: string;
|
||||
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
|
||||
code: string;
|
||||
challengeId?: string;
|
||||
session?: Partial<AuthSession>;
|
||||
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||
}): Promise<AuthResult>;
|
||||
beginTotp(
|
||||
userId: string,
|
||||
@@ -149,6 +159,7 @@ export interface AuthEngine {
|
||||
provider: string,
|
||||
profile: OAuthProfile,
|
||||
tokens?: OAuthTokens,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
|
||||
beginPasskeyRegistration(
|
||||
@@ -172,7 +183,7 @@ export interface AuthEngine {
|
||||
key: string;
|
||||
response: unknown;
|
||||
/** Request metadata used only for the resulting session. */
|
||||
session?: Partial<AuthSession>;
|
||||
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||
}): Promise<AuthResult>;
|
||||
changePassword(
|
||||
userId: string,
|
||||
@@ -264,6 +275,9 @@ function passkeyChallengeTtl(timeout: number): number {
|
||||
}
|
||||
|
||||
export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
if (options.onSignUp && options.onSuccessfulSignUp) {
|
||||
throw new TypeError("Configure either onSignUp or onSuccessfulSignUp, not both");
|
||||
}
|
||||
if (options.secret.length < MIN_SECRET_LENGTH) {
|
||||
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
|
||||
}
|
||||
@@ -474,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
user: AuthUser,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<AuthResult | undefined> {
|
||||
const deviceCheck = await newDeviceRequirement(user, metadata);
|
||||
if (deviceCheck) return deviceCheck;
|
||||
if (!(await hasMfa(user.id))) return undefined;
|
||||
const methods = await availableMfaMethods(user.id);
|
||||
if (!methods.length) {
|
||||
@@ -483,6 +499,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
message: "Additional verification is required but no verification method is available",
|
||||
};
|
||||
}
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata,
|
||||
@@ -493,6 +510,55 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
message: "Complete two-step verification to finish signing in.",
|
||||
user: publicUser(user),
|
||||
mfaToken,
|
||||
mfaExpiresAt,
|
||||
requires: { mfa: methods },
|
||||
};
|
||||
}
|
||||
|
||||
async function newDeviceRequirement(
|
||||
user: AuthUser,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<AuthResult | undefined> {
|
||||
if (!options.requireTrustedDeviceVerification) return;
|
||||
const browser = typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined;
|
||||
if (browser) {
|
||||
const hash = await fingerprint(browser);
|
||||
const existing = await store.findTrustedDeviceByFingerprint(user.id, hash);
|
||||
if (existing && !existing.revokedAt && existing.expiresAt > now()) return;
|
||||
// Only the original registration browser can claim this one-time trust.
|
||||
// Account-state / required-contact checks run before this helper.
|
||||
if (options.trustSignupDevice && user.metadata?.signupDeviceHash === hash) {
|
||||
const { signupDeviceHash: _signup, ...rest } = user.metadata;
|
||||
user.metadata = rest;
|
||||
await store.updateUser(user);
|
||||
await engine.trustDevice(user.id, {
|
||||
fingerprint: browser,
|
||||
name: String(metadata.deviceName || "Signup browser"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const methods = (await availableMfaMethods(user.id)).filter(
|
||||
(method) => method === "email-otp" || method === "sms-otp",
|
||||
);
|
||||
if (!methods.length)
|
||||
return {
|
||||
ok: false,
|
||||
code: "device-verification-unavailable",
|
||||
message:
|
||||
"A verified email address or mobile number is required to verify this device. Use account recovery.",
|
||||
};
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata: { ...metadata, deviceVerification: true, rememberDevice: false },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
message: "Verify this new device using email or SMS. You can then trust it for 30 days.",
|
||||
mfaToken,
|
||||
mfaExpiresAt,
|
||||
requires: { mfa: methods },
|
||||
};
|
||||
}
|
||||
@@ -656,24 +722,26 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
async function sendLoginAlert(user: AuthUser, session: AuthSession): Promise<void> {
|
||||
if (!options.sendLoginAlerts || !options.delivery) return;
|
||||
const identities = await store.listIdentities(user.id);
|
||||
const destination =
|
||||
identities.find((item) => item.type === "email" && item.primary)?.value ??
|
||||
identities.find((item) => item.type === "email")?.value;
|
||||
if (!destination) return;
|
||||
await deliver({
|
||||
channel: "email",
|
||||
template: "login-alert",
|
||||
destination,
|
||||
user: publicUser(user),
|
||||
expiresAt: session.expiresAt,
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
deviceId: session.deviceId,
|
||||
ip: session.ip,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
});
|
||||
for (const type of ["email", "phone"] as const) {
|
||||
const destination =
|
||||
identities.find((item) => item.type === type && item.verifiedAt && item.primary)?.value ??
|
||||
identities.find((item) => item.type === type && item.verifiedAt)?.value;
|
||||
if (!destination) continue;
|
||||
await deliver({
|
||||
channel: type === "phone" ? "sms" : "email",
|
||||
template: "login-alert",
|
||||
destination,
|
||||
user: publicUser(user),
|
||||
expiresAt: session.expiresAt,
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
deviceId: session.deviceId,
|
||||
ip: session.ip,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(
|
||||
@@ -844,10 +912,28 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
}
|
||||
|
||||
const engine: AuthEngine = {
|
||||
deviceTrustPolicyEnabled: options.requireTrustedDeviceVerification === true,
|
||||
store,
|
||||
onSignedIn: options.onSignedIn,
|
||||
onSignedOut: options.onSignedOut,
|
||||
onSuccessfulSignUp: options.onSuccessfulSignUp,
|
||||
onSuccessfulSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
|
||||
onSignUp: options.onSignUp ?? options.onSuccessfulSignUp,
|
||||
async createVerificationSession(userId) {
|
||||
const user = await store.findUserById(userId);
|
||||
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified) {
|
||||
throw new Error("Email verification session is unavailable");
|
||||
}
|
||||
return issueToken(user.id, "verification-session");
|
||||
},
|
||||
async validateVerificationSession(raw) {
|
||||
if (!raw || raw.length > 512) return undefined;
|
||||
const token = await inspectToken(raw, "verification-session");
|
||||
if (!token) return undefined;
|
||||
const user = await store.findUserById(token.userId);
|
||||
if (!user || !["pending", "active"].includes(user.status) || user.emailVerified)
|
||||
return undefined;
|
||||
return publicUser(user);
|
||||
},
|
||||
|
||||
async register(input) {
|
||||
try {
|
||||
@@ -880,7 +966,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
timezone: input.timezone,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: input.metadata,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
...(options.trustSignupDevice && input.fingerprint
|
||||
? { signupDeviceHash: await fingerprint(input.fingerprint) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
await store.createUser(user);
|
||||
if (input.email) await createIdentity(user.id, "email", input.email, true);
|
||||
@@ -897,8 +988,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const identities = await store.listIdentities(user.id);
|
||||
const email = identities.find((item) => item.type === "email");
|
||||
const phone = identities.find((item) => item.type === "phone");
|
||||
if (email) await sendToken(user, "verify-email", email.value);
|
||||
if (phone) await sendToken(user, "verify-phone", phone.value);
|
||||
if (email) await sendToken(user, "verify-email", email.value, input.baseUrl);
|
||||
if (phone) await sendToken(user, "verify-phone", phone.value, input.baseUrl);
|
||||
await audit({ userId: user.id, type: "account.registered", severity: "info" });
|
||||
return {
|
||||
ok: true,
|
||||
@@ -1036,6 +1127,13 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return { ...verification, risk };
|
||||
const mfaEnabled = await hasMfa(user.id);
|
||||
const deviceCheck = await newDeviceRequirement(user, {
|
||||
fingerprint: input.fingerprint,
|
||||
deviceName: input.deviceName,
|
||||
ip: input.ip,
|
||||
userAgent: input.userAgent,
|
||||
});
|
||||
if (deviceCheck) return deviceCheck;
|
||||
const trustedDevice = input.fingerprint
|
||||
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
|
||||
: undefined;
|
||||
@@ -1057,6 +1155,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
risk,
|
||||
};
|
||||
}
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata: {
|
||||
@@ -1075,11 +1174,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
user: publicUser(user),
|
||||
risk,
|
||||
mfaToken,
|
||||
mfaExpiresAt,
|
||||
requires: { captcha: risk.requireCaptcha, mfa: methods },
|
||||
};
|
||||
}
|
||||
const rememberedDevice =
|
||||
input.rememberDevice && input.fingerprint
|
||||
input.rememberDevice && input.fingerprint && !trusted
|
||||
? await engine.trustDevice(user.id, {
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.deviceName,
|
||||
@@ -1468,6 +1568,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
const mfa = await mfaRequirement(user, {
|
||||
fingerprint: sessionInput.fingerprint,
|
||||
deviceName: sessionInput.deviceName,
|
||||
ip: sessionInput.ip,
|
||||
userAgent: sessionInput.userAgent,
|
||||
deviceId: sessionInput.deviceId,
|
||||
@@ -1573,6 +1675,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
const mfa = await mfaRequirement(user, {
|
||||
fingerprint: sessionInput.fingerprint,
|
||||
deviceName: sessionInput.deviceName,
|
||||
ip: sessionInput.ip,
|
||||
userAgent: sessionInput.userAgent,
|
||||
deviceId: sessionInput.deviceId,
|
||||
@@ -1602,7 +1706,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
await restoreExpiredLoginLock(user);
|
||||
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
|
||||
try {
|
||||
return await issueOtp(token.userId, method, undefined, "mfa");
|
||||
if (!(await availableMfaMethods(user.id)).includes(method)) return undefined;
|
||||
const key = "otp:" + method;
|
||||
const previousId = token.metadata?.[key];
|
||||
if (typeof previousId === "string") {
|
||||
const previous = await store.findOtp(previousId);
|
||||
if (previous && !previous.usedAt && previous.expiresAt > now())
|
||||
return { id: previous.id, expiresAt: previous.expiresAt };
|
||||
}
|
||||
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
|
||||
token.metadata = { ...token.metadata, [key]: challenge.id };
|
||||
await store.updateToken(token);
|
||||
return challenge;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1623,6 +1738,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
if (unavailable) return unavailable;
|
||||
const verification = verificationUnavailable(transactionUser);
|
||||
if (verification) return verification;
|
||||
if (
|
||||
transaction.metadata?.deviceVerification === true &&
|
||||
(input.fingerprint !== transaction.metadata.fingerprint ||
|
||||
!input.fingerprint ||
|
||||
!["email-otp", "sms-otp"].includes(input.method))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "mfa-invalid",
|
||||
message: "Verify this device using email or SMS in the browser that started sign-in.",
|
||||
};
|
||||
}
|
||||
let verified = false;
|
||||
if (input.method === "totp")
|
||||
verified = await engine.verifyTotp(transaction.userId, input.code);
|
||||
@@ -1633,7 +1760,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
if (
|
||||
otp?.userId === transaction.userId &&
|
||||
otp.method === input.method &&
|
||||
otp.purpose === "mfa"
|
||||
otp.purpose === "mfa" &&
|
||||
(transaction.metadata?.deviceVerification !== true ||
|
||||
transaction.metadata?.["otp:" + input.method] === input.challengeId)
|
||||
) {
|
||||
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
|
||||
}
|
||||
@@ -1654,7 +1783,10 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const user = transactionUser;
|
||||
const metadata = transaction.metadata ?? {};
|
||||
const rememberedDevice =
|
||||
metadata.rememberDevice === true && typeof metadata.fingerprint === "string"
|
||||
(metadata.deviceVerification === true
|
||||
? input.rememberDevice === true
|
||||
: metadata.rememberDevice === true || input.rememberDevice === true) &&
|
||||
typeof metadata.fingerprint === "string"
|
||||
? await engine.trustDevice(user.id, {
|
||||
fingerprint: metadata.fingerprint,
|
||||
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
|
||||
@@ -1688,6 +1820,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
severity: "info",
|
||||
data: { method: input.method },
|
||||
});
|
||||
await sendLoginAlert(user, session);
|
||||
return { ok: true, user: publicUser(user), session };
|
||||
},
|
||||
|
||||
@@ -1882,7 +2015,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
return account;
|
||||
},
|
||||
|
||||
async loginWithOAuth(provider, profile, tokens) {
|
||||
async loginWithOAuth(provider, profile, tokens, sessionInput = {}) {
|
||||
const providerName = normalizedOAuthProvider(provider);
|
||||
const providerAccountId = normalizedOAuthAccountId(profile.id);
|
||||
let account = await store.findOAuthAccount(providerName, providerAccountId);
|
||||
@@ -1935,21 +2068,28 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
|
||||
const deviceCheck = await newDeviceRequirement(user, {
|
||||
...sessionInput,
|
||||
oauthProvider: providerName,
|
||||
});
|
||||
if (deviceCheck) return deviceCheck;
|
||||
if (await hasMfa(user.id)) {
|
||||
const methods = await availableMfaMethods(user.id);
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata: { oauthProvider: providerName },
|
||||
metadata: { ...sessionInput, oauthProvider: providerName },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
user: publicUser(user),
|
||||
mfaToken,
|
||||
mfaExpiresAt,
|
||||
requires: { mfa: methods },
|
||||
};
|
||||
}
|
||||
const session = await createSession(user.id);
|
||||
const session = await createSession(user.id, sessionInput);
|
||||
user.lastLoginAt = now();
|
||||
user.updatedAt = now();
|
||||
await store.updateUser(user);
|
||||
@@ -2145,6 +2285,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
credential.lastUsedAt = now();
|
||||
await store.updatePasskey(credential);
|
||||
}
|
||||
const deviceCheck = await newDeviceRequirement(user, { ...input.session });
|
||||
if (deviceCheck) return deviceCheck;
|
||||
const session = await createSession(user.id, input.session);
|
||||
user.lastLoginAt = now();
|
||||
user.updatedAt = now();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from
|
||||
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||
import { completeAuth, startAuth, type OAuthProvider } from "@wrnexus/oauth";
|
||||
import { assignDefaultAuthzRoles } from "@wrnexus/authz/defaults";
|
||||
import { startVerificationSession, getVerificationUser } from "../verification-session.ts";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
@@ -63,6 +64,26 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const onSignedOut = engine.onSignedOut;
|
||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||
|
||||
function browserDevice(ctx: Context) {
|
||||
if (!engine.deviceTrustPolicyEnabled) return {};
|
||||
const cookie = "wrnexus.auth.device";
|
||||
let value = ctx.cookies.get(cookie);
|
||||
if (!value || !/^[a-f0-9-]{36}$/.test(value)) {
|
||||
value = crypto.randomUUID();
|
||||
ctx.cookies.set(cookie, value, {
|
||||
httpOnly: true,
|
||||
secure: ctx.url.protocol === "https:",
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
return {
|
||||
fingerprint: value,
|
||||
deviceName: (ctx.req.headers.get("user-agent") || "Browser").slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
function oauthProvider(ctx: Context): OAuthProvider | undefined {
|
||||
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
|
||||
}
|
||||
@@ -147,6 +168,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
provider.name,
|
||||
completed.profile,
|
||||
completed.tokens,
|
||||
browserDevice(ctx),
|
||||
);
|
||||
if (result.code === "mfa-required" && result.mfaToken) {
|
||||
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
|
||||
@@ -180,6 +202,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.register({
|
||||
...browserDevice(ctx),
|
||||
baseUrl: options.baseUrl ?? ctx.url.origin,
|
||||
email: text(input.email) || undefined,
|
||||
phone: text(input.phone) || undefined,
|
||||
username: text(input.username) || undefined,
|
||||
@@ -194,9 +218,11 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
|
||||
const action = await onSuccessfulSignUp?.(ctx, result.user);
|
||||
if (action instanceof Response) return action;
|
||||
if (action?.verificationSession) await startVerificationSession(ctx, engine, result.user.id);
|
||||
if (action?.autoSignIn) {
|
||||
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
||||
const loginResult = await engine.login({
|
||||
...browserDevice(ctx),
|
||||
identifier,
|
||||
password: text(input.password),
|
||||
ip: ctx.ip,
|
||||
@@ -226,6 +252,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
fingerprint: text(input.deviceFingerprint) || undefined,
|
||||
deviceName: text(input.deviceName) || undefined,
|
||||
rememberDevice: boolean(input.rememberDevice),
|
||||
...browserDevice(ctx),
|
||||
captchaVerified:
|
||||
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
|
||||
ctx.locals.captchaVerified === true,
|
||||
@@ -254,7 +281,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const validation = await parseBody(schemas.verificationRequest, ctx.req);
|
||||
if (!validation.ok) return validation.response;
|
||||
const type = text(validation.value.type) === "phone" ? "phone" : "email";
|
||||
const current = getAuthUser(ctx);
|
||||
const current = getAuthUser(ctx) ?? (await getVerificationUser(ctx, engine));
|
||||
const identifier = text(validation.value.identifier);
|
||||
const user =
|
||||
current ?? (identifier ? await engine.findUserByIdentifier(identifier) : undefined);
|
||||
@@ -337,6 +364,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.consumeMagicLink(text(input.token), {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
@@ -363,6 +391,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
@@ -497,6 +526,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
|
||||
: "totp";
|
||||
const result = await engine.completeMfa({
|
||||
...browserDevice(ctx),
|
||||
rememberDevice: boolean(input.rememberDevice),
|
||||
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
|
||||
method,
|
||||
code: text(input.code),
|
||||
@@ -615,6 +646,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
key: text(input.key),
|
||||
response: input.response,
|
||||
session: {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export { createAuthEngine, type AuthEngine } from "./engine.ts";
|
||||
export {
|
||||
startVerificationSession,
|
||||
getVerificationUser,
|
||||
clearVerificationSession,
|
||||
VERIFICATION_SESSION_COOKIE,
|
||||
} from "./verification-session.ts";
|
||||
export type { AuthStore } from "./store.ts";
|
||||
export { MemoryAuthStore } from "./stores/memory.ts";
|
||||
export { SqlAuthStore } from "./stores/sql.ts";
|
||||
export type { SqlAuthStoreOptions } from "./stores/sql.ts";
|
||||
export {
|
||||
authSession,
|
||||
requireAuth,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { createAuthEngine, type AuthEngine } from "../engine.ts";
|
||||
export {
|
||||
startVerificationSession,
|
||||
getVerificationUser,
|
||||
clearVerificationSession,
|
||||
VERIFICATION_SESSION_COOKIE,
|
||||
} from "../verification-session.ts";
|
||||
export {
|
||||
createAuthHttpHandlers,
|
||||
type AuthHttpOptions,
|
||||
|
||||
@@ -34,10 +34,31 @@ function placeholders(sql: string, dialect: string): string {
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
export interface SqlAuthStoreOptions {
|
||||
/** PostgreSQL schema owning authentication tables. */
|
||||
schema?: string;
|
||||
/** Defaults to wrn_auth_; use an empty prefix for e.g. sso.users. */
|
||||
tablePrefix?: string;
|
||||
}
|
||||
|
||||
export class SqlAuthStore implements AuthStore {
|
||||
constructor(private readonly db: Db) {}
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly options: SqlAuthStoreOptions = {},
|
||||
) {
|
||||
if (options.schema && !/^[a-z_][a-z0-9_]*$/i.test(options.schema))
|
||||
throw new Error("Invalid auth schema identifier");
|
||||
if (options.tablePrefix && !/^[a-z_][a-z0-9_]*$/i.test(options.tablePrefix))
|
||||
throw new Error("Invalid auth table prefix");
|
||||
if (options.schema && db.driver.dialect !== "postgres")
|
||||
throw new Error("Auth schema requires PostgreSQL");
|
||||
}
|
||||
private sql(value: string): string {
|
||||
return placeholders(value, this.db.driver.dialect);
|
||||
const qualified = value.replace(/\bwrn_auth_([a-z_]+)\b/g, (_match, table: string) => {
|
||||
const name = (this.options.tablePrefix ?? "wrn_auth_") + table;
|
||||
return this.options.schema ? '"' + this.options.schema + '"."' + name + '"' : name;
|
||||
});
|
||||
return placeholders(qualified, this.db.driver.dialect);
|
||||
}
|
||||
private async one(sql: string, params: unknown[] = []): Promise<Row | undefined> {
|
||||
return (await this.db.one(this.sql(sql), params)) ?? undefined;
|
||||
|
||||
@@ -9,7 +9,8 @@ export type AuthTokenPurpose =
|
||||
| "invite"
|
||||
| "change-email"
|
||||
| "change-phone"
|
||||
| "login-mfa";
|
||||
| "login-mfa"
|
||||
| "verification-session";
|
||||
export type AuthMfaMethod = "email-otp" | "sms-otp" | "totp" | "recovery-code" | "passkey";
|
||||
export type AuthAccountStatus = "pending" | "active" | "locked" | "disabled" | "deleted";
|
||||
export type AuthRiskLevel = "low" | "medium" | "high" | "critical";
|
||||
@@ -293,6 +294,8 @@ export type AuthSessionVerificationHandler = (
|
||||
) => Response | Promise<Response>;
|
||||
|
||||
export interface AuthSuccessfulSignUpAction {
|
||||
/** Issue a short-lived verification-only cookie backed by the auth token store. */
|
||||
verificationSession?: boolean;
|
||||
/**
|
||||
* Run the normal login policy with the newly registered credentials and
|
||||
* establish a session when verification, CAPTCHA, MFA, and account policy
|
||||
@@ -425,6 +428,8 @@ export interface AuthEngineOptions {
|
||||
* redirects to /sign-in.
|
||||
*/
|
||||
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||
/** Alias for onSuccessfulSignUp. Configure only one signup hook. */
|
||||
onSignUp?: AuthSuccessfulSignUpHandler;
|
||||
breachProvider?: PasswordBreachProvider;
|
||||
passkeys?: PasskeyProvider;
|
||||
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
||||
@@ -433,6 +438,10 @@ export interface AuthEngineOptions {
|
||||
sessionTtlMs?: number;
|
||||
sessionAbsoluteTtlMs?: number;
|
||||
trustedDeviceTtlMs?: number;
|
||||
/** Require verified email/SMS confirmation before a new browser receives a session. */
|
||||
requireTrustedDeviceVerification?: boolean;
|
||||
/** Remember the registration browser after required account verification succeeds. */
|
||||
trustSignupDevice?: boolean;
|
||||
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
|
||||
/**
|
||||
* Customize action links delivered with one-time tokens. Returning undefined
|
||||
@@ -467,6 +476,10 @@ export interface AuthEngineOptions {
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
fingerprint?: string;
|
||||
deviceName?: string;
|
||||
/** Trusted server-side origin for verification links; never copied from request data. */
|
||||
baseUrl?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
username?: string;
|
||||
@@ -498,6 +511,8 @@ export interface AuthResult {
|
||||
session?: AuthSession;
|
||||
risk?: AuthRiskDecision;
|
||||
mfaToken?: string;
|
||||
/** Absolute expiry of the pending sign-in challenge, in epoch milliseconds. */
|
||||
mfaExpiresAt?: number;
|
||||
requires?: {
|
||||
captcha?: boolean;
|
||||
mfa?: AuthMfaMethod[];
|
||||
|
||||
@@ -160,6 +160,7 @@ export const mfaOtpRequestSchema = v.object({
|
||||
});
|
||||
|
||||
export const mfaSchema = v.object({
|
||||
rememberDevice: v.boolean().optional(),
|
||||
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
|
||||
method: v
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
|
||||
export const VERIFICATION_SESSION_COOKIE = "wrnexus.auth.verification";
|
||||
|
||||
function cookieOptions(ctx: Context) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: ctx.url.protocol === "https:",
|
||||
sameSite: "Lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
/** Call only after successful registration or credential validation, never from a public identifier. */
|
||||
export async function startVerificationSession(
|
||||
ctx: Context,
|
||||
engine: AuthEngine,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await clearVerificationSession(ctx, engine);
|
||||
const token = await engine.createVerificationSession(userId);
|
||||
ctx.cookies.set(VERIFICATION_SESSION_COOKIE, token, cookieOptions(ctx));
|
||||
}
|
||||
|
||||
/** Reads the database-backed, purpose-bound session; grants no authenticated app access. */
|
||||
export async function getVerificationUser(ctx: Context, engine: AuthEngine) {
|
||||
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
|
||||
return token ? engine.validateVerificationSession(token) : undefined;
|
||||
}
|
||||
|
||||
export async function clearVerificationSession(ctx: Context, engine: AuthEngine): Promise<void> {
|
||||
const token = ctx.cookies.get(VERIFICATION_SESSION_COOKIE);
|
||||
if (token && token.length <= 512) await engine.consumeToken(token, "verification-session");
|
||||
ctx.cookies.delete(VERIFICATION_SESSION_COOKIE, cookieOptions(ctx));
|
||||
}
|
||||
@@ -92,7 +92,7 @@ test("authentication journeys use themed UI form controls", () => {
|
||||
"MagicLinkSignIn.wrn": ["<Input", "<Button"],
|
||||
"InvitationAccept.wrn": ["<Input", "<StrongPassword", "<Button"],
|
||||
"OtpSignIn.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"TwoFactorChallenge.wrn": ["<Select", "<PinInput", "<Button"],
|
||||
"TwoFactorChallenge.wrn": ["<Radio", "<Input", "<PinInput", "<Button"],
|
||||
"AuthenticatorSetup.wrn": ["<Input", "<PinInput", "<Button"],
|
||||
"VerifyEmail.wrn": ["<Input", "<Button"],
|
||||
"VerifyPhone.wrn": ["<PinInput", "<Button"],
|
||||
@@ -114,8 +114,9 @@ test("verification components can resend without application-owned schema files"
|
||||
}
|
||||
});
|
||||
|
||||
test("auth browser runtime refuses cross-origin navigation targets", () => {
|
||||
test("auth browser runtime refuses cross-origin navigation unless explicitly server-approved", () => {
|
||||
const source = readFileSync(join(directory, "..", "assets", "client", "auth.js"), "utf8");
|
||||
expect(source).toContain("if (url.origin !== location.origin) return false");
|
||||
expect(source).toContain("function navigate(target, allowCrossOrigin = false)");
|
||||
expect(source).toContain("if (!allowCrossOrigin) return false");
|
||||
expect(source).toContain("!window.AbortController");
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import type { AuthDeliveryMessage } from "../src/types.ts";
|
||||
|
||||
test("magic-link, OTP, and passkey sign-in cannot bypass new-device verification", async () => {
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
let userId = "";
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "all-flows-trust-secret-at-least-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
async registrationOptions() {
|
||||
throw new Error("Not used");
|
||||
},
|
||||
async verifyRegistration() {
|
||||
throw new Error("Not used");
|
||||
},
|
||||
async authenticationOptions() {
|
||||
return {
|
||||
challenge: "AQID",
|
||||
rpId: "example.test",
|
||||
timeout: 60000,
|
||||
userVerification: "required" as const,
|
||||
};
|
||||
},
|
||||
async verifyAuthentication() {
|
||||
return { verified: true, userId };
|
||||
},
|
||||
},
|
||||
});
|
||||
userId = (await engine.register({ email: "flows@example.test", password: "StrongPassword123!" }))
|
||||
.user!.id;
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
await engine.requestMagicLink("flows@example.test");
|
||||
const magic = messages.find((m) => m.template === "magic-link")!.token!;
|
||||
expect((await engine.consumeMagicLink(magic, { fingerprint: "browser" })).code).toBe(
|
||||
"mfa-required",
|
||||
);
|
||||
const otp = await engine.requestOtpLogin("flows@example.test", "email-otp");
|
||||
expect(
|
||||
(
|
||||
await engine.completeOtpLogin(
|
||||
otp.id,
|
||||
messages.filter((m) => m.template === "email-otp").at(-1)!.code!,
|
||||
{ fingerprint: "browser" },
|
||||
)
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
const passkey = await engine.beginPasskeyAuthentication({
|
||||
identifier: "flows@example.test",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: passkey.key,
|
||||
response: {},
|
||||
session: { fingerprint: "browser" },
|
||||
})
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
expect(await engine.listSessions(userId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("new browsers require bound email/SMS verification; signup trust expires without sliding", async () => {
|
||||
let now = 1_780_000_000_000;
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "test-browser-trust-secret-at-least-32-characters",
|
||||
clock: { now: () => now },
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
trustSignupDevice: true,
|
||||
trustedDeviceTtlMs: 30 * 86400_000,
|
||||
sendLoginAlerts: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "trust@example.test",
|
||||
phone: "+15551234567",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "signup-browser",
|
||||
});
|
||||
const userId = registered.user!.id;
|
||||
expect(
|
||||
(
|
||||
await engine.login({
|
||||
identifier: "trust@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "signup-browser",
|
||||
})
|
||||
).code,
|
||||
).toBe("email-unverified");
|
||||
expect(await engine.listTrustedDevices(userId)).toHaveLength(0);
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
await engine.verifyPhone(messages.find((m) => m.template === "verify-phone")!.token!);
|
||||
const login = (fingerprint: string, rememberDevice = false) =>
|
||||
engine.login({
|
||||
identifier: "trust@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint,
|
||||
rememberDevice,
|
||||
});
|
||||
expect((await login("signup-browser")).session?.trusted).toBe(true);
|
||||
const firstExpiry = (await engine.listTrustedDevices(userId))[0]!.expiresAt;
|
||||
now += 86400_000;
|
||||
expect((await login("signup-browser", true)).ok).toBe(true);
|
||||
expect((await engine.listTrustedDevices(userId))[0]!.expiresAt).toBe(firstExpiry);
|
||||
const before = (await engine.listSessions(userId)).length;
|
||||
const pending = await login("new-browser", true);
|
||||
expect(pending.code).toBe("mfa-required");
|
||||
expect(pending.requires?.mfa).toEqual(["email-otp", "sms-otp"]);
|
||||
expect((await engine.listSessions(userId)).length).toBe(before);
|
||||
const challenge = await engine.beginMfaOtp(pending.mfaToken!, "email-otp");
|
||||
const count = messages.length;
|
||||
expect(await engine.beginMfaOtp(pending.mfaToken!, "email-otp")).toEqual(challenge);
|
||||
expect(messages.length).toBe(count);
|
||||
const code = messages.filter((m) => m.template === "email-otp").at(-1)!.code!;
|
||||
const complete = {
|
||||
mfaToken: pending.mfaToken!,
|
||||
method: "email-otp" as const,
|
||||
challengeId: challenge!.id,
|
||||
code,
|
||||
fingerprint: "new-browser",
|
||||
};
|
||||
expect((await engine.completeMfa({ ...complete, fingerprint: "other-browser" })).ok).toBe(false);
|
||||
expect((await engine.completeMfa({ ...complete, method: "totp" })).ok).toBe(false);
|
||||
expect((await engine.completeMfa(complete)).ok).toBe(true);
|
||||
expect((await engine.listTrustedDevices(userId)).length).toBe(1); // Explicit opt-in only.
|
||||
expect((await engine.completeMfa(complete)).ok).toBe(false);
|
||||
const again = await login("new-browser");
|
||||
const sms = await engine.beginMfaOtp(again.mfaToken!, "sms-otp");
|
||||
const smsCode = messages.filter((m) => m.template === "sms-otp").at(-1)!.code!;
|
||||
expect(
|
||||
(
|
||||
await engine.completeMfa({
|
||||
mfaToken: again.mfaToken!,
|
||||
method: "sms-otp",
|
||||
challengeId: sms!.id,
|
||||
code: smsCode,
|
||||
fingerprint: "new-browser",
|
||||
rememberDevice: true,
|
||||
})
|
||||
).session?.trusted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
messages
|
||||
.filter((m) => m.template === "login-alert")
|
||||
.slice(-2)
|
||||
.map((m) => m.channel),
|
||||
).toEqual(["email", "sms"]);
|
||||
const second = (await engine.listTrustedDevices(userId)).find(
|
||||
(d) => d.expiresAt !== firstExpiry,
|
||||
)!;
|
||||
await engine.revokeTrustedDevice(userId, second.id);
|
||||
expect((await login("new-browser")).code).toBe("mfa-required");
|
||||
now = firstExpiry + 1;
|
||||
expect((await login("signup-browser")).code).toBe("mfa-required");
|
||||
});
|
||||
|
||||
test("HTTP signup binds trust to an HttpOnly cookie, not a supplied device fingerprint", async () => {
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-device-trust-secret-at-least-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
trustSignupDevice: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const url = new URL("https://sso.example.test/api/auth/register");
|
||||
const ctx = createContext(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Trust Test",
|
||||
consent: true,
|
||||
email: "http@example.test",
|
||||
password: "StrongPassword123!",
|
||||
deviceFingerprint: "attacker",
|
||||
}),
|
||||
}),
|
||||
url,
|
||||
);
|
||||
await createAuthHttpHandlers({ engine }).register(ctx);
|
||||
const cookie = ctx.cookies.headers().find((c) => c.startsWith("wrnexus.auth.device="))!;
|
||||
expect(cookie).toContain("HttpOnly");
|
||||
expect(cookie).toContain("Secure");
|
||||
expect(cookie).toContain("Max-Age=2592000");
|
||||
expect(cookie).not.toContain("attacker");
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
expect(
|
||||
(
|
||||
await engine.login({
|
||||
identifier: "http@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "attacker",
|
||||
})
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
});
|
||||
@@ -863,6 +863,7 @@ describe("authentication engine", () => {
|
||||
requires: { mfa: ["totp"] },
|
||||
});
|
||||
expect(await engine.beginMfaOtp(firstLogin.mfaToken!, "email-otp")).toBeUndefined();
|
||||
expect(firstLogin.mfaExpiresAt).toBe(now() + 5 * 60_000);
|
||||
|
||||
expect((await engine.verifyEmail(verificationToken)).ok).toBe(true);
|
||||
const secondLogin = await engine.login({
|
||||
|
||||
@@ -15,7 +15,7 @@ function context(request: Request): Context {
|
||||
t: (key: string) => key,
|
||||
ip: "127.0.0.1",
|
||||
user: null,
|
||||
cookies: {} as Context["cookies"],
|
||||
cookies: { get: () => undefined } as unknown as Context["cookies"],
|
||||
localStorage: {} as Context["localStorage"],
|
||||
session: {
|
||||
id: () => "http-test-session",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { generateTotp } from "../src/totp/index.ts";
|
||||
|
||||
test("HTTP login creates a usable MFA challenge; expired requests require a new login", async () => {
|
||||
let time = 1_720_000_000_000;
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "mfa-full-flow-test-secret-longer-than-thirty-two-characters",
|
||||
clock: { now: () => time },
|
||||
});
|
||||
const user = await engine.register({ email: "flow@example.test", password: "StrongPassword123" });
|
||||
const setup = await engine.beginTotp(user.user!.id);
|
||||
expect(
|
||||
await engine.confirmTotp(
|
||||
user.user!.id,
|
||||
setup.credentialId,
|
||||
await generateTotp(setup.secret, { timestamp: time }),
|
||||
),
|
||||
).toBe(true);
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const context = (path: string, body: unknown) => {
|
||||
const req = new Request(`https://sso.example.test${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return createContext(req, new URL(req.url));
|
||||
};
|
||||
const login = async () => {
|
||||
const response = await handlers.login(
|
||||
context("/api/auth/login", {
|
||||
identifier: "flow@example.test",
|
||||
password: "StrongPassword123",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
const result = await response.json();
|
||||
expect(result.code).toBe("mfa-required");
|
||||
expect(result.mfaExpiresAt).toBe(time + 300_000);
|
||||
return result;
|
||||
};
|
||||
const old = await login();
|
||||
time += 300_001;
|
||||
const expired = await handlers.completeMfa(
|
||||
context("/api/auth/mfa/complete", {
|
||||
mfaToken: old.mfaToken,
|
||||
method: "totp",
|
||||
code: await generateTotp(setup.secret, { timestamp: time }),
|
||||
}),
|
||||
);
|
||||
expect((await expired.json()).code).toBe("mfa-transaction-expired");
|
||||
const fresh = await login();
|
||||
const success = await handlers.completeMfa(
|
||||
context("/api/auth/mfa/complete", {
|
||||
mfaToken: fresh.mfaToken,
|
||||
method: "totp",
|
||||
code: await generateTotp(setup.secret, { timestamp: time }),
|
||||
}),
|
||||
);
|
||||
expect(success.status).toBe(200);
|
||||
expect(await success.json()).toMatchObject({ ok: true, user: { id: user.user!.id } });
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
|
||||
|
||||
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
|
||||
for (const deadline of [undefined, Date.now() - 1]) {
|
||||
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
"wrnexus.auth.mfa",
|
||||
JSON.stringify({ mfaToken: "old-challenge", mfaExpiresAt: deadline }),
|
||||
);
|
||||
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
|
||||
<input name="mfaToken"><input type="radio" name="method" value="totp"><input name="code"><p data-auth-mfa-status hidden></p><button type="submit">Verify</button>
|
||||
</form><p data-auth-mfa-expired role="alert" hidden>Sign in again</p><a data-auth-mfa-restart hidden href="/login">Back to sign in</a></section>`;
|
||||
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
||||
(window as any).WRNexusAuth.mount(window.document);
|
||||
expect(window.document.querySelector("button")!.disabled).toBe(true);
|
||||
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
|
||||
false,
|
||||
);
|
||||
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-expired]")!.hidden).toBe(
|
||||
false,
|
||||
);
|
||||
expect(window.document.querySelector("form")!.hidden).toBe(true);
|
||||
expect(window.document.querySelector("[data-auth-mfa-status]")!.textContent).toBe("");
|
||||
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
|
||||
} finally {
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting delivery radios sends once, ignores stale responses, and never sends for local methods", async () => {
|
||||
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
"wrnexus.auth.mfa",
|
||||
JSON.stringify({ mfaToken: "synthetic-test-challenge", mfaExpiresAt: Date.now() + 300_000 }),
|
||||
);
|
||||
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp"><form data-auth-mfa-form>
|
||||
<input name="mfaToken"><input name="challengeId"><input name="code">
|
||||
${["totp", "recovery-code", "email-otp", "sms-otp"].map((value) => `<label><input type="radio" name="method" value="${value}">${value}</label>`).join("")}
|
||||
<div data-auth-mfa-pin><input name="otpCode"></div><div data-auth-mfa-recovery hidden><input name="recoveryCode"></div>
|
||||
<p data-auth-mfa-status></p><button type="submit">Verify</button>
|
||||
</form></section>`;
|
||||
const requests: Array<{ method: string; resolve: (response: Response) => void }> = [];
|
||||
window.fetch = ((_: unknown, init: RequestInit) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
requests.push({ method: JSON.parse(String(init.body)).method, resolve });
|
||||
})) as any;
|
||||
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
||||
(window as any).WRNexusAuth.mount(window.document);
|
||||
const form = window.document.querySelector("form")!;
|
||||
const field = (name: string) => form.querySelector<HTMLInputElement>(`[name=${name}]`)!;
|
||||
const choose = (value: string) => {
|
||||
const radio = form.querySelector<HTMLInputElement>(`[name=method][value="${value}"]`)!;
|
||||
radio.checked = true;
|
||||
radio.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
};
|
||||
choose("recovery-code");
|
||||
expect(requests).toHaveLength(0);
|
||||
field("recoveryCode").value = "abc123-def456";
|
||||
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
expect(field("code").value).toBe("abc123-def456");
|
||||
choose("email-otp");
|
||||
choose("email-otp");
|
||||
expect(requests.map((x) => x.method)).toEqual(["email-otp"]);
|
||||
choose("sms-otp");
|
||||
expect(requests.map((x) => x.method)).toEqual(["email-otp", "sms-otp"]);
|
||||
requests[0]!.resolve(Response.json({ id: "email-challenge" }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(field("challengeId").value).toBe("");
|
||||
requests[1]!.resolve(Response.json({ id: "sms-challenge" }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(field("challengeId").value).toBe("sms-challenge");
|
||||
choose("totp");
|
||||
expect(field("challengeId").value).toBe("");
|
||||
expect(requests).toHaveLength(2);
|
||||
field("otpCode").value = "123456";
|
||||
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
expect(field("code").value).toBe("123456");
|
||||
} finally {
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("MFA radios preserve allowed methods and restore continuation without reviving expired challenges", async () => {
|
||||
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
"wrnexus.auth.mfa",
|
||||
JSON.stringify({
|
||||
mfaToken: "synthetic-test-challenge",
|
||||
mfaExpiresAt: Date.now() + 300_000,
|
||||
requires: { mfa: ["totp"] },
|
||||
}),
|
||||
);
|
||||
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp">
|
||||
<form data-auth-mfa-form>
|
||||
<input type="hidden" name="mfaToken">
|
||||
<input type="hidden" name="challengeId">
|
||||
<div data-auth-mfa-method><label><input type="radio" name="method" value="totp">Authenticator app</label><label><input type="radio" name="method" value="email-otp">Email</label></div>
|
||||
<input name="code"><p data-auth-mfa-status></p><button type="submit">Verify</button>
|
||||
</form><a data-auth-mfa-restart hidden href="/login">Back to sign in</a>
|
||||
</section>`;
|
||||
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
||||
(window as any).WRNexusAuth.mount(window.document);
|
||||
const form = window.document.querySelector("form")!;
|
||||
const token = form.querySelector<HTMLInputElement>('[name="mfaToken"]')!;
|
||||
expect(token.value).toBe("synthetic-test-challenge");
|
||||
expect(form.querySelector<HTMLInputElement>('input[value="email-otp"]')!.disabled).toBe(true);
|
||||
token.value = ""; // Hydration reapplies the component's initial empty prop.
|
||||
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
expect(token.value).toBe("synthetic-test-challenge");
|
||||
form.dispatchEvent(
|
||||
new window.CustomEvent("wrn:error", { bubbles: true, detail: { code: "mfa-invalid" } }),
|
||||
);
|
||||
expect(token.value).toBe("synthetic-test-challenge");
|
||||
form.dispatchEvent(
|
||||
new window.CustomEvent("wrn:error", {
|
||||
bubbles: true,
|
||||
detail: { code: "mfa-transaction-expired" },
|
||||
}),
|
||||
);
|
||||
// Validation finalizers may restore button.disabled after dispatching error.
|
||||
form.querySelector("button")!.disabled = false;
|
||||
expect(form.hidden).toBe(true);
|
||||
expect(
|
||||
window.document.querySelector<HTMLElement>("[data-auth-mfa-challenge]")!.dataset.mfaExpired,
|
||||
).toBe("true");
|
||||
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
|
||||
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
|
||||
false,
|
||||
);
|
||||
const retry = new window.Event("submit", { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(retry);
|
||||
expect(retry.defaultPrevented).toBe(true);
|
||||
expect(token.value).toBe("");
|
||||
} finally {
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test, spyOn } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Window } from "happy-dom";
|
||||
|
||||
test("passkey sign-in follows the server handoff, including cross-app destinations", async () => {
|
||||
for (const redirect of [
|
||||
"https://account.example.test/?sso_code=synthetic-code",
|
||||
"/signed-in",
|
||||
"javascript:alert(1)",
|
||||
]) {
|
||||
const window = new Window({ url: "https://sso.example.test/login" });
|
||||
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
||||
try {
|
||||
window.document.body.innerHTML = `<div data-auth-passkey><button>Sign in</button><p data-auth-status></p></div>`;
|
||||
Object.defineProperty(window, "PublicKeyCredential", { value: class {} });
|
||||
Object.defineProperty(window.navigator, "credentials", {
|
||||
value: {
|
||||
get: async () => ({
|
||||
id: "synthetic-credential",
|
||||
type: "public-key",
|
||||
rawId: new Uint8Array([1]),
|
||||
response: {
|
||||
clientDataJSON: new Uint8Array([1]),
|
||||
authenticatorData: new Uint8Array([2]),
|
||||
signature: new Uint8Array([3]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
window.fetch = (async (url: string) =>
|
||||
Response.json(
|
||||
url.endsWith("/options")
|
||||
? { ok: true, key: "test-key", options: { challenge: "AQID" } }
|
||||
: { ok: true, redirect },
|
||||
)) as any;
|
||||
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
||||
await (window as any).WRNexusAuth.authenticatePasskey(window.document.querySelector("div"));
|
||||
if (redirect.startsWith("javascript:")) {
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(assign).toHaveBeenCalledWith(new URL(redirect, window.location.href).href);
|
||||
}
|
||||
} finally {
|
||||
assign.mockRestore();
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { getVerificationUser, VERIFICATION_SESSION_COOKIE } from "../src/verification-session.ts";
|
||||
import type { AuthDeliveryMessage } from "../src/types.ts";
|
||||
|
||||
test("onSignUp issues a persistent verification-only cookie and registration receives a trusted link origin", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
let hookCalls = 0;
|
||||
const options = {
|
||||
store,
|
||||
secret: "signup-session-test-secret-longer-than-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
onSignUp() {
|
||||
hookCalls++;
|
||||
return { verificationSession: true, redirectTo: "/verify-email" };
|
||||
},
|
||||
delivery: {
|
||||
async send(message: AuthDeliveryMessage) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = createAuthEngine(options);
|
||||
const url = new URL("https://sso.example.test/api/auth/register");
|
||||
const ctx = createContext(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Signup Test",
|
||||
email: "signup@example.test",
|
||||
password: "StrongPassword123!",
|
||||
consent: true,
|
||||
baseUrl: "https://attacker.example",
|
||||
}),
|
||||
}),
|
||||
url,
|
||||
);
|
||||
const response = await createAuthHttpHandlers({ engine }).register(ctx);
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get("location")).toBe("https://sso.example.test/verify-email");
|
||||
expect(hookCalls).toBe(1);
|
||||
expect(messages[0]!.url).toBe(
|
||||
`https://sso.example.test/verify-email?token=${messages[0]!.token}`,
|
||||
);
|
||||
expect(messages[0]!.url).not.toContain("attacker");
|
||||
const setCookie = ctx.cookies
|
||||
.headers()
|
||||
.filter((value) => value.startsWith(`${VERIFICATION_SESSION_COOKIE}=`))
|
||||
.at(-1)!;
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("Secure");
|
||||
expect(setCookie).toContain("SameSite=Lax");
|
||||
const cookie = setCookie.split(";")[0]!;
|
||||
const restartedEngine = createAuthEngine(options);
|
||||
const nextUrl = new URL("https://sso.example.test/api/auth/verification/request");
|
||||
const next = createContext(
|
||||
new Request(nextUrl, {
|
||||
method: "POST",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "email", identifier: "attacker@example.test" }),
|
||||
}),
|
||||
nextUrl,
|
||||
);
|
||||
const pending = await getVerificationUser(next, restartedEngine);
|
||||
expect(pending?.id).toBe(messages[0]!.user.id);
|
||||
expect(next.user).toBeUndefined();
|
||||
expect(
|
||||
(await createAuthHttpHandlers({ engine: restartedEngine }).requestVerification(next)).status,
|
||||
).toBe(200);
|
||||
expect(messages[1]!.destination).toBe("signup@example.test");
|
||||
const token = decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1));
|
||||
expect(await restartedEngine.validateSession(token)).toBeUndefined();
|
||||
expect((await restartedEngine.verifyEmail(token)).ok).toBe(false);
|
||||
expect((await restartedEngine.verifyEmail(messages[1]!.token!)).ok).toBe(true);
|
||||
expect(await getVerificationUser(next, restartedEngine)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("verification sessions reject expiry, tampering, wrong purpose, and revoked tokens", async () => {
|
||||
let time = 100_000;
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "signup-session-test-secret-longer-than-32-characters",
|
||||
clock: { now: () => time },
|
||||
});
|
||||
const user = (
|
||||
await engine.register({ email: "expiry@example.test", password: "StrongPassword123!" })
|
||||
).user!;
|
||||
const token = await engine.createVerificationSession(user.id);
|
||||
expect(await engine.validateVerificationSession(`${token}x`)).toBeUndefined();
|
||||
expect(
|
||||
await engine.validateVerificationSession(await engine.issueToken(user.id, "verify-email")),
|
||||
).toBeUndefined();
|
||||
time += 30 * 60_000 + 1;
|
||||
expect(await engine.validateVerificationSession(token)).toBeUndefined();
|
||||
const renewed = await engine.createVerificationSession(user.id);
|
||||
await engine.consumeToken(renewed, "verification-session");
|
||||
expect(await engine.validateVerificationSession(renewed)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@wrnexus/db";
|
||||
import { SqlAuthStore } from "../src/stores/sql.ts";
|
||||
|
||||
test("auth store qualifies tables without changing parameter values", async () => {
|
||||
let query = "";
|
||||
let values: unknown[] = [];
|
||||
const db = {
|
||||
driver: { dialect: "postgres" },
|
||||
one: async (sql: string, params: unknown[]) => {
|
||||
query = sql;
|
||||
values = params;
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as Db;
|
||||
const store = new SqlAuthStore(db, { schema: "sso", tablePrefix: "" });
|
||||
await store.findUserById("wrn_auth_users");
|
||||
expect(query).toContain('FROM "sso"."users"');
|
||||
expect(query).toContain("$1");
|
||||
expect(values).toEqual(["wrn_auth_users"]);
|
||||
expect(() => new SqlAuthStore(db, { schema: 'sso";DROP TABLE users' })).toThrow();
|
||||
});
|
||||
+12
-1
@@ -313,7 +313,18 @@ export async function runDbCommand(
|
||||
break;
|
||||
}
|
||||
case "rollback": {
|
||||
const name = await rollback(db, migrationsDir);
|
||||
let name: string | null;
|
||||
try {
|
||||
name = await rollback(db, migrationsDir);
|
||||
} catch (error) {
|
||||
// A migration with no down section is refused rather than half-undone.
|
||||
// That is an operator decision to make, not a stack trace to read.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.startsWith("WRN-DB-MIGRATION-IRREVERSIBLE")) throw error;
|
||||
console.error(`✗ ${message.replace("WRN-DB-MIGRATION-IRREVERSIBLE: ", "")}`);
|
||||
process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.22",
|
||||
"version": "0.8.27",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -344,10 +344,16 @@ function renderAttrs(
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
dynamicExpressions
|
||||
) {
|
||||
const marker = exprRefsState(expression, reactive.stateNames)
|
||||
? ` data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([attr.name, attr.value]))}"`
|
||||
: "";
|
||||
if (isHtmlBooleanAttribute(attr.name)) {
|
||||
dynamicExpressions.push(`\${(${expression}) ? ${JSON.stringify(` ${attr.name}`)} : ""}`);
|
||||
return `\x00WRNEACH${dynamicExpressions.length - 1}\x00${marker}`;
|
||||
}
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
return ` ${attr.name}="${sentinel}"${marker}`;
|
||||
}
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null) return base;
|
||||
@@ -411,15 +417,17 @@ function substituteReactiveText(
|
||||
dynamicExpressions?: string[],
|
||||
): string {
|
||||
const text = substituteTMarkers(raw);
|
||||
if (!reactive || reactive.stateNames.size === 0) return text;
|
||||
if (!reactive) return text;
|
||||
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||
const expr = inner.trim();
|
||||
if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole;
|
||||
const clientReactive = exprRefsState(expr, reactive.stateNames);
|
||||
if (expr.startsWith("t:")) return whole;
|
||||
if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
|
||||
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`;
|
||||
return clientReactive ? `<span data-text="${attrEscape(expr)}">${sentinel}</span>` : sentinel;
|
||||
}
|
||||
if (!clientReactive) return whole;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
@@ -503,6 +511,19 @@ function renderLoopBody(node: ViewNode, locals: string[] = []): string {
|
||||
return escLit(` ${name}`);
|
||||
}
|
||||
|
||||
// Native boolean attributes inside #if/#each must use presence semantics,
|
||||
// just like attributes on the page root. disabled="false" still disables
|
||||
// an HTML input. Component props retain their serialized boolean value.
|
||||
if (
|
||||
!componentTag &&
|
||||
!node.attrs.some((a) => a.name === "data-component") &&
|
||||
isHtmlBooleanAttribute(name)
|
||||
) {
|
||||
const expression = wholeAttributeExpression(attr.value);
|
||||
if (expression) return "${(" + expression + ") ? " + JSON.stringify(` ${name}`) + ' : ""}';
|
||||
if (attr.value === "false") return "";
|
||||
}
|
||||
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`);
|
||||
})
|
||||
.join("");
|
||||
@@ -654,6 +675,19 @@ function renderNode(
|
||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||
const conditions =
|
||||
node.type === "each"
|
||||
? [node.list]
|
||||
: node.branches.flatMap((branch) => (branch.cond ? [branch.cond] : []));
|
||||
// Server-only loader values are not serialized into browser state. Their
|
||||
// controls must not be re-evaluated by a surrounding component's hydration.
|
||||
if (
|
||||
reactive &&
|
||||
conditions.some((condition) => exprRefsState(condition, reactive.runtimeStateNames)) &&
|
||||
!conditions.some((condition) => exprRefsState(condition, reactive.stateNames))
|
||||
) {
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
const definition =
|
||||
node.type === "each"
|
||||
? {
|
||||
@@ -878,7 +912,7 @@ function renderNestedComponentInvocation(
|
||||
|
||||
const compiledValue = wholeExpression
|
||||
? `\${__wrnProp(${ctx.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(attr.value, ctx);
|
||||
: compileAttrValue(attr.value, ctx, attr.name === "pattern");
|
||||
|
||||
const rendered = ` ${attr.name}="${compiledValue}"`;
|
||||
|
||||
@@ -1603,7 +1637,7 @@ function generateInner(ast: PageAst): string {
|
||||
];
|
||||
const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots);
|
||||
const reactive: PageReactive | null =
|
||||
reactiveNames.length > 0
|
||||
reactiveNames.length > 0 || runtimeStateNames.size > 0
|
||||
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
|
||||
: null;
|
||||
const loops: string[] = [];
|
||||
@@ -2220,7 +2254,7 @@ function compileText(raw: string, ctx: CompCtx): string {
|
||||
}
|
||||
|
||||
/** Compile an attribute value; `{expr}` is baked server-side (loop vars stay literal). */
|
||||
function compileAttrValue(raw: string, ctx: CompCtx): string {
|
||||
function compileAttrValue(raw: string, ctx: CompCtx, regexPattern = false): string {
|
||||
if (!raw.includes("{")) return escLit(attrEscape(raw));
|
||||
let out = "";
|
||||
let last = 0;
|
||||
@@ -2229,7 +2263,9 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
|
||||
while ((m = INTERP_RE.exec(raw))) {
|
||||
out += escLit(attrEscape(raw.slice(last, m.index)));
|
||||
const expr = m[1]!.trim();
|
||||
if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
if (regexPattern && /^\d+(?:,\d*)?$/.test(expr)) {
|
||||
out += escLit(attrEscape(m[0])); // Regex quantifiers are not template expressions.
|
||||
} else if (ctx.loopVars && exprRefsState(expr, ctx.loopVars)) {
|
||||
out += escLit(`{${expr}}`); // hydrated per-item by the list renderer
|
||||
} else {
|
||||
out += `\${__wrnAttr(${ctx.resolveExpr(expr)})}`;
|
||||
@@ -2594,7 +2630,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
const compiledValue =
|
||||
isExplicitComponentMount && wholeExpression
|
||||
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(a.value, elementContext);
|
||||
: compileAttrValue(a.value, elementContext, a.name === "pattern");
|
||||
|
||||
/*
|
||||
* A constraint attribute whose value is only known at render time (a
|
||||
@@ -3175,7 +3211,7 @@ function __wrnProp(v: unknown): string {
|
||||
function renderPageComponentAttr(
|
||||
attr: Attr,
|
||||
dynamicExpressions: string[],
|
||||
_reactive: PageReactive | null,
|
||||
reactive: PageReactive | null,
|
||||
): string {
|
||||
if (attr.event) {
|
||||
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
|
||||
@@ -3194,6 +3230,13 @@ function renderPageComponentAttr(
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
|
||||
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
if (
|
||||
reactive &&
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
!exprRefsState(expression, reactive.stateNames)
|
||||
) {
|
||||
return ` ${attr.name}="${marker}"`;
|
||||
}
|
||||
// The mount disappears during SSR. Preserve every dynamic component prop
|
||||
// as a parent-owned binding; server-only expressions simply fail closed in
|
||||
// the browser, while page state can continue driving the mounted child.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse, generate } from "../src/index.ts";
|
||||
|
||||
test("native boolean attributes inside conditional and loop bodies use presence semantics", async () => {
|
||||
const source = `page Example {
|
||||
load server account { return {} }
|
||||
view {
|
||||
{#if account.visible}<input required disabled="{!account.edit}" />{/if}
|
||||
{#each account.rows as row}<input type="checkbox" checked="{row.checked}" disabled="false" />{/each}
|
||||
}
|
||||
}`;
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(generate(parse(source)));
|
||||
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
|
||||
const html = await mod.default({
|
||||
account: { visible: true, edit: true, rows: [{ checked: false }, { checked: true }] },
|
||||
});
|
||||
expect(html).not.toContain("disabled");
|
||||
expect(html).not.toContain('checked="false"');
|
||||
expect(html.match(/ checked/g)).toHaveLength(1);
|
||||
expect(html).toContain("required");
|
||||
const denied = await mod.default({ account: { visible: true, edit: false, rows: [] } });
|
||||
expect(denied).toContain(" disabled");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { parse, generate } from "../src/index.ts";
|
||||
|
||||
test("HTML and component pattern attributes preserve regex quantifiers", () => {
|
||||
for (const tag of ["input", "Input"]) {
|
||||
for (const pattern of ["[0-9]{6}", "[a-z]{2,8}", "[A-Z]{1,}"]) {
|
||||
const code = generate(parse(`page Example { view { <${tag} pattern="${pattern}" /> } }`));
|
||||
expect(code).toContain(pattern);
|
||||
expect(code).not.toContain("__wrnAttr(6)");
|
||||
expect(code).not.toContain("__wrnAttr(2,8)");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("dynamic pattern props still compile as expressions", () => {
|
||||
const code = generate(
|
||||
parse(
|
||||
'component Example { props { pattern: string = "" } view { <input pattern="{pattern}" /> } }',
|
||||
),
|
||||
);
|
||||
expect(code).toContain("__wrnAttr(pattern)");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generate, parse } from "../src/index.ts";
|
||||
|
||||
test("server-only loader data renders native input attributes without client state", async () => {
|
||||
const code = generate(
|
||||
parse(`import UserSelect from "./UserSelect.wrn"
|
||||
page Profile {
|
||||
load server account { return {} }
|
||||
view {
|
||||
<h1>{account.name}</h1>
|
||||
<UserSelect value="{account.name}" options="{account.items}" />
|
||||
{#if account.readonly}<p>Read only</p>{:else}<p>Editable</p>{/if}
|
||||
{#each account.items as item}<strong>{item}</strong>{/each}
|
||||
<input value="{account.name}" readonly="{account.readonly}" />
|
||||
<select><option value="light" selected="{account.theme === 'light'}">Light</option><option value="dark" selected="{account.theme === 'dark'}">Dark</option></select>
|
||||
<div data-component="ProfileShell" identity="{account.identity}"></div>
|
||||
}
|
||||
}`),
|
||||
);
|
||||
const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(code);
|
||||
const page = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`
|
||||
);
|
||||
const html = await page.default({
|
||||
account: {
|
||||
items: ["Example record"],
|
||||
name: 'A "<User>"',
|
||||
readonly: false,
|
||||
theme: "dark",
|
||||
identity: { name: "Alice" },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('value="A "<User>""');
|
||||
expect(html).not.toContain(" readonly");
|
||||
expect(html).toContain('<option value="light">Light</option>');
|
||||
expect(html).toContain('<option value="dark" selected>Dark</option>');
|
||||
expect(html).toContain('identity="{"name":"Alice"}"');
|
||||
expect(html).not.toContain("data-wrn-bind");
|
||||
expect(html).not.toContain("data-wrn-prop-bind");
|
||||
expect(html).not.toContain("{account.");
|
||||
expect(html).toContain("<h1>A "<User>"</h1>");
|
||||
expect(html).toContain("<p>Editable</p>");
|
||||
expect(html).toContain("<strong>Example record</strong>");
|
||||
expect(html).not.toContain("data-wrn-if");
|
||||
expect(html).not.toContain("data-wrn-each");
|
||||
expect(html).not.toContain("data-text");
|
||||
});
|
||||
@@ -231,7 +231,18 @@ export async function migrate(
|
||||
return applyMigrations(db, loadMigrations(dir), options);
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
/**
|
||||
* Roll back the most recently applied migration. Returns its name, or null when
|
||||
* nothing has been applied.
|
||||
*
|
||||
* Refuses a migration that changed the schema but defines no `-- +down` section.
|
||||
* Removing its tracking row would leave the change in place and unrecorded, and
|
||||
* the next `migrate` would replay statements such as `ALTER TABLE ... ADD COLUMN`
|
||||
* that cannot run twice, stranding the database between the two states.
|
||||
*
|
||||
* A migration whose `up` is empty — a placeholder, or comments only — has
|
||||
* nothing to undo, so it rolls back by dropping its tracking row alone.
|
||||
*/
|
||||
export async function rollback(
|
||||
db: Db,
|
||||
dir: string,
|
||||
@@ -241,12 +252,21 @@ export async function rollback(
|
||||
const applied = await appliedMigrations(db);
|
||||
const last = applied[applied.length - 1];
|
||||
if (!last) return null;
|
||||
const migration = loadMigrations(dir).find((m) => m.name === last);
|
||||
const reversible =
|
||||
!migration || !hasExecutableSql(migration.up) || hasExecutableSql(migration.down);
|
||||
if (!reversible)
|
||||
throw new Error(
|
||||
`WRN-DB-MIGRATION-IRREVERSIBLE: ${last} changes the schema but defines no "-- +down" section, ` +
|
||||
"so rolling it back would drop its record while leaving the change applied. " +
|
||||
"Add a `-- +down` section to that migration, or write a new migration that reverses it.",
|
||||
);
|
||||
// Checked before the lock so a dry run reports the same refusal.
|
||||
if (options.dryRun) return last;
|
||||
const release =
|
||||
options.lock === false
|
||||
? undefined
|
||||
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
|
||||
const migration = loadMigrations(dir).find((m) => m.name === last);
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
|
||||
@@ -145,6 +145,67 @@ test("an empty migration set does not touch the database", async () => {
|
||||
expect(await applyMigrations(db, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("rollback refuses a schema change that defines no down section", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-nodown-"));
|
||||
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
|
||||
const db = createDb(sqlite());
|
||||
expect(await migrate(db, dir)).toEqual(["0001_add"]);
|
||||
|
||||
// Rolling this back would drop the record and leave the table, and the next
|
||||
// migrate would replay a CREATE that cannot run twice.
|
||||
await expect(rollback(db, dir)).rejects.toThrow(/WRN-DB-MIGRATION-IRREVERSIBLE/);
|
||||
|
||||
// The refusal leaves the record alone, so the database stays consistent.
|
||||
expect(await appliedMigrations(db)).toEqual(["0001_add"]);
|
||||
expect((await status(db, dir))[0]!.applied).toBe(true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("a dry run reports the same refusal instead of promising a rollback", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-dry-"));
|
||||
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
|
||||
|
||||
const db = createDb(sqlite());
|
||||
await migrate(db, dir);
|
||||
await expect(rollback(db, dir, { dryRun: true })).rejects.toThrow(
|
||||
/WRN-DB-MIGRATION-IRREVERSIBLE/,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("rollback still undoes a migration that defines a down section", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-down-"));
|
||||
writeFileSync(
|
||||
join(dir, "0001_add.sql"),
|
||||
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);\n-- +down\nDROP TABLE t;",
|
||||
);
|
||||
|
||||
const db = createDb(sqlite());
|
||||
await migrate(db, dir);
|
||||
expect(await rollback(db, dir)).toBe("0001_add");
|
||||
expect(await appliedMigrations(db)).toEqual([]);
|
||||
const remaining = await db.all<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
|
||||
);
|
||||
expect(remaining.length).toBe(0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("a migration whose up is only comments has nothing to undo and rolls back", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-noop-"));
|
||||
writeFileSync(
|
||||
join(dir, "0001_placeholder.sql"),
|
||||
"-- +up\n-- Nothing yet.\n-- +down\n-- Nothing to undo.",
|
||||
);
|
||||
|
||||
const db = createDb(sqlite());
|
||||
await migrate(db, dir);
|
||||
expect(await rollback(db, dir)).toBe("0001_placeholder");
|
||||
expect(await appliedMigrations(db)).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("comment-only migrations are recorded without executing empty SQL", async () => {
|
||||
const db = createDb(sqlite());
|
||||
const migrations = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.56",
|
||||
"version": "0.8.59",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -654,6 +654,8 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
port,
|
||||
hostname,
|
||||
development: mode === "development",
|
||||
// Keep the socket alive long enough to return the configured proxy timeout.
|
||||
idleTimeout: Math.min(255, Math.max(35, Math.ceil((limits.timeoutMs ?? 30_000) / 1000) + 5)),
|
||||
maxRequestBodySize: maxBodyBytes,
|
||||
async fetch(req, srv) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
@@ -634,6 +634,8 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
port,
|
||||
hostname,
|
||||
development: mode === "development",
|
||||
// Cold page compilation can exceed Bun's default connection idle window.
|
||||
idleTimeout: 35,
|
||||
maxRequestBodySize: 10 * 1024 * 1024,
|
||||
...(opts.tls ? { tls: opts.tls } : {}),
|
||||
fetch(request, server) {
|
||||
|
||||
@@ -1746,7 +1746,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
// component defaults creates the real child boundary; the bridged prop
|
||||
// binding synchronizes the live value when the branch is instantiated.
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (/^\{[\s\S]*\}$/.test(value) && !/^\{t:/.test(value)) delete props[key];
|
||||
if (/^\{[\s\S]*\}$/.test(value) && !/^\{t:/.test(value)) {
|
||||
// SSR object props also start/end with braces. Preserve serialized
|
||||
// JSON; only unresolved expressions should fall back to defaults.
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
delete props[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (normalizedName === "languageswitcher" && deps.i18n) {
|
||||
props.locales ??= JSON.stringify(
|
||||
@@ -2614,8 +2622,9 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
|
||||
const named: Record<string, string> = {};
|
||||
if (!inner.includes("data-slot=")) return { named, def: inner };
|
||||
|
||||
const SLOT_MOUNT_RE =
|
||||
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?\bdata-slot="([A-Za-z0-9_-]+)"[^>]*?)(\/?)>/;
|
||||
// Only direct children belong to this mount. Descendant named slots belong
|
||||
// to their own component (for example a splitter inside a shell's slot).
|
||||
const SLOT_MOUNT_RE = /<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)(\/?)>/;
|
||||
let def = "";
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
@@ -2626,11 +2635,16 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
|
||||
}
|
||||
const tagStart = i + m.index;
|
||||
def += inner.slice(i, tagStart);
|
||||
const [open, tag, , name, selfClose] = m;
|
||||
const [open, tag, attributes, selfClose] = m;
|
||||
const name = /\bdata-slot="([A-Za-z0-9_-]+)"/.exec(attributes!)?.[1];
|
||||
const openEnd = tagStart + open.length;
|
||||
const { inner: content, end } =
|
||||
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(inner, tag!, openEnd);
|
||||
named[name!] = content;
|
||||
selfClose === "/" ||
|
||||
/^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i.test(tag!)
|
||||
? { inner: "", end: openEnd }
|
||||
: readElementBody(inner, tag!, openEnd);
|
||||
if (name) named[name] = content;
|
||||
else def += inner.slice(tagStart, end);
|
||||
i = end;
|
||||
}
|
||||
return { named, def };
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, basename } from "node:path";
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
import { renderComponent } from "../../test/src/index.ts";
|
||||
import { createHandlers } from "../src/runtime.ts";
|
||||
|
||||
test("large timezone select responds through the complete page renderer", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrn-large-select-"));
|
||||
try {
|
||||
mkdirSync(join(root, "pages"));
|
||||
mkdirSync(join(root, "components"));
|
||||
writeFileSync(join(root, "pages/index.ts"), "");
|
||||
for (const name of ["AdvancedSelect", "SelectStyles", "Shell"])
|
||||
writeFileSync(
|
||||
join(root, `components/${name}.wrn`),
|
||||
`component ${name} { view { <div></div> } }`,
|
||||
);
|
||||
const select = await renderComponent(
|
||||
readFileSync(new URL("../../ui/components/AdvancedSelect.wrn", import.meta.url), "utf8"),
|
||||
{
|
||||
name: "timezone",
|
||||
value: "UTC",
|
||||
searchResultLimit: 40,
|
||||
options: Intl.supportedValuesOf("timeZone").map((value) => ({ value, label: value })),
|
||||
},
|
||||
);
|
||||
const handlers = createHandlers({
|
||||
mode: "development",
|
||||
hmr: false,
|
||||
router: buildRouter(root),
|
||||
loadModule: async (file) =>
|
||||
basename(file) === "AdvancedSelect.wrn"
|
||||
? { render: () => select }
|
||||
: basename(file) === "SelectStyles.wrn"
|
||||
? { render: () => "" }
|
||||
: basename(file) === "Shell.wrn"
|
||||
? { render: () => "<main><slot></slot></main>" }
|
||||
: {
|
||||
default: () =>
|
||||
'<div data-component="Shell">' +
|
||||
'<div data-component="AdvancedSelect"></div>'.repeat(4) +
|
||||
"</div>",
|
||||
},
|
||||
getMiddleware: async () => [],
|
||||
assets: { serve: async () => null },
|
||||
});
|
||||
const response = await handlers.fetch(new Request("http://example.test/"), {
|
||||
upgrade: () => false,
|
||||
});
|
||||
expect(response?.status).toBe(200);
|
||||
const html = await response!.text();
|
||||
expect(html).toContain("Asia/Kolkata");
|
||||
// Four full lists previously exceeded 11 MB, including hydration metadata.
|
||||
expect(html.length).toBeLessThan(6_000_000);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { fillSlots } from "../src/runtime.ts";
|
||||
|
||||
test("an outer default slot preserves a nested component's named slots", () => {
|
||||
const child =
|
||||
'<div data-component="LayoutSplitter"><div data-slot="start">Inbox</div><div data-slot="end">Message</div></div>';
|
||||
const shell = fillSlots("<main><slot></slot></main>", child, true);
|
||||
expect(shell).toContain(child);
|
||||
const panes = fillSlots(
|
||||
'<section><slot name="start"></slot><slot name="end"></slot></section>',
|
||||
'<div data-slot="start">Inbox</div><div data-slot="end">Message</div>',
|
||||
true,
|
||||
);
|
||||
expect(panes).toContain("Inbox</wrn-slot>");
|
||||
expect(panes).toContain("Message</wrn-slot>");
|
||||
});
|
||||
|
||||
test("void children do not swallow following direct named slots", () => {
|
||||
const html = fillSlots(
|
||||
'<slot name="header"></slot><slot></slot>',
|
||||
'Text<input><div data-slot="header">Heading</div><p>Body</p>',
|
||||
);
|
||||
expect(html).toBe("HeadingText<input><p>Body</p>");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createProductionHandlers } from "../src/prod.ts";
|
||||
|
||||
test("component resolution preserves JSON user/permission props and drops unresolved bindings", async () => {
|
||||
let received: Record<string, string> = {};
|
||||
const handlers = createProductionHandlers(
|
||||
{
|
||||
pages: [
|
||||
{
|
||||
raw: "/",
|
||||
mod: {
|
||||
default: () =>
|
||||
'<div data-component="account-shell" identity="{"displayName":"Alice"}" permissions="{"read":true,"update":false}" preferences="{"theme":"dark"}" pending="{account.pending}"><p>Account content</p></div>',
|
||||
},
|
||||
},
|
||||
],
|
||||
api: [],
|
||||
realtime: [],
|
||||
middleware: [],
|
||||
layouts: [],
|
||||
components: [
|
||||
{
|
||||
name: "AccountShell",
|
||||
mod: {
|
||||
render: (props: Record<string, string>) => {
|
||||
received = props;
|
||||
const identity = JSON.parse(props.identity ?? "{}");
|
||||
const permissions = JSON.parse(props.permissions ?? "{}");
|
||||
return permissions.read
|
||||
? "<main>" + identity.displayName + "<slot /></main>"
|
||||
: "<main>Denied</main>";
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
const response = await handlers.fetch(new Request("http://localhost/"), { upgrade: () => false });
|
||||
expect(response?.status).toBe(200);
|
||||
const html = await response!.text();
|
||||
expect(html).toContain("Alice");
|
||||
expect(html).toContain("Account content");
|
||||
expect(JSON.parse(received.permissions!)).toEqual({ read: true, update: false });
|
||||
expect(JSON.parse(received.preferences!)).toEqual({ theme: "dark" });
|
||||
expect(received.pending).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { documentDiagnostics, virtualTypeScriptDocument } from "../src/index.ts";
|
||||
|
||||
test("editor diagnostics accept translated PageHeader attributes without hiding real errors", () => {
|
||||
const document = {
|
||||
uri: "file:///contact.wrn",
|
||||
text: `page Contact {
|
||||
view {
|
||||
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
|
||||
<p title="{t:contact.title} {missingValue}"></p>
|
||||
}
|
||||
}`,
|
||||
};
|
||||
const diagnostics = documentDiagnostics(document).filter(
|
||||
(item) => item.source === "wrnexus-types",
|
||||
);
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.message).toContain("missingValue");
|
||||
expect(diagnostics[0]?.range.start.line).toBe(3);
|
||||
expect(virtualTypeScriptDocument(document).text).not.toContain("void (t:");
|
||||
expect(
|
||||
documentDiagnostics({ ...document, text: document.text.replace("{missingValue}", "") }).filter(
|
||||
(item) => item.source === "wrnexus-types",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -5,3 +5,80 @@ Gateway-neutral, webhook-authoritative payments for WrNexus. Amounts are integer
|
||||
Built-in adapters: offline `sandbox`, Stripe, Razorpay, and PayPal. Applications may add adapters with `defineGateway`. Capabilities are explicit, unsupported operations are refused, signed webhooks are mandatory in production, and package-owned migrations provision the append-only event ledger.
|
||||
|
||||
Webhook events are deduplicated by the provider's event ID and reduced in provider occurrence/sequence order. Captured and refund states are monotonic, so a delayed retry of an older event cannot regress a terminal payment.
|
||||
|
||||
Configure any number of named gateway accounts in `wrnexus.config.ts`; keep credentials in environment variables:
|
||||
|
||||
```ts
|
||||
const payment = {
|
||||
default: "razorpayIndia",
|
||||
gateways: {
|
||||
razorpayIndia: {
|
||||
provider: "razorpay",
|
||||
keyId: process.env.RAZORPAY_KEY_ID!,
|
||||
keySecret: process.env.RAZORPAY_KEY_SECRET!,
|
||||
webhookSecret: process.env.RAZORPAY_WEBHOOK_SECRET ?? "",
|
||||
},
|
||||
stripeGlobal: {
|
||||
provider: "stripe",
|
||||
secretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
},
|
||||
},
|
||||
} satisfies PaymentConfig;
|
||||
```
|
||||
|
||||
At runtime, call `configurePaymentFromConfig(config.payment, databasePaymentStore())` once. Pass a configured gateway name to `initializePayment` to override the default for a specific checkout.
|
||||
|
||||
## Package APIs
|
||||
|
||||
When a modern `gateways` configuration is present, the plugin contributes these routes:
|
||||
|
||||
- `GET /api/payments/providers` — client-safe names, capabilities, currencies, and countries
|
||||
- `GET|POST /api/payments/intents` — list the authenticated subject's payments or initialize one
|
||||
- `GET /api/payments/intents/[id]` — status and client-safe checkout configuration
|
||||
- `POST /api/payments/intents/[id]/sync`
|
||||
- `POST /api/payments/intents/[id]/capture`
|
||||
- `POST /api/payments/intents/[id]/cancel`
|
||||
- `POST /api/payments/intents/[id]/refund`
|
||||
- `GET /api/payments/methods`
|
||||
- `POST /api/payments/reconcile`
|
||||
- `POST /api/payments/webhook/:gateway`
|
||||
|
||||
Provider secrets are held only by server adapters and are never returned by provider, intent, or status APIs. Request-body subject values are ignored. Configure authentication and authorization in the same payment block:
|
||||
|
||||
```ts
|
||||
payment: {
|
||||
default: "razorpayIndia",
|
||||
gateways: { /* named providers */ },
|
||||
api: {
|
||||
subject: (ctx) => ctx.session?.userId,
|
||||
authorize: async (ctx, action, payment) => {
|
||||
if (["capture", "refund", "reconcile"].includes(action))
|
||||
return await isBillingAdmin(ctx)
|
||||
return !payment || payment.subject === ctx.session?.userId
|
||||
},
|
||||
// Recommended for catalog purchases: derive price and metadata server-side.
|
||||
initialize: async (ctx, body, subject) => resolveCatalogPurchase(body, subject),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Administrative actions deny by default. Set `api: false` to expose only the signed webhook endpoint. Use CSRF protection and rate limiting in the application's normal middleware.
|
||||
|
||||
## Pages, themes, and languages
|
||||
|
||||
The package exposes `PaymentOverviewPage`, `PaymentDetailsPage`, `PaymentCheckoutPage`, and `PaymentMethodsPage`, plus the smaller payment components. These use framework theme variables, inherit light/dark mode and accent colors, and collapse to a single-column mobile layout.
|
||||
|
||||
All visible copy is supplied through component props. Applications can pass their normal i18n messages without the package forcing a locale or duplicating the application's language switcher:
|
||||
|
||||
```wrn
|
||||
<PaymentMethodsPage
|
||||
methods={payments.methods}
|
||||
title={t.paymentMethodsTitle}
|
||||
description={t.paymentMethodsDescription}
|
||||
emptyLabel={t.noPaymentMethods}
|
||||
/>
|
||||
```
|
||||
|
||||
Provider-hosted card fields remain on the provider's origin. Applications should never collect or pass raw card numbers, CVVs, or expiry values through these APIs.
|
||||
@@ -1,5 +1,9 @@
|
||||
component PayNow {
|
||||
props { checkoutUrl: string label?: string = "Pay now" disabled?: boolean = false }
|
||||
props {
|
||||
checkoutUrl: string
|
||||
label?: string = "Pay now"
|
||||
disabled?: boolean = false
|
||||
}
|
||||
view {
|
||||
<a class="wrn-payment-button" href={checkoutUrl} aria-disabled={disabled}>
|
||||
{label}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import PaymentSheet from "./PaymentSheet.wrn"
|
||||
component PaymentCheckoutPage {
|
||||
props {
|
||||
checkoutUrl: string
|
||||
gateway: string
|
||||
title?: string = "Secure checkout"
|
||||
description?: string = "Complete payment through the selected secure provider."
|
||||
securityLabel?: string = "Your payment credentials are handled by the payment provider."
|
||||
}
|
||||
view {
|
||||
<section class="wrn-payment-page wrn-payment-checkout-page">
|
||||
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{gateway}</span><h1>{title}</h1><p>{description}</p></div></header>
|
||||
<div class="wrn-payment-panel"><PaymentSheet checkoutUrl={checkoutUrl} gateway={gateway} /><p class="wrn-payment-security">{securityLabel}</p></div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import PaymentStatus from "./PaymentStatus.wrn"
|
||||
component PaymentDetailsPage {
|
||||
props {
|
||||
payment: object
|
||||
title?: string = "Payment details"
|
||||
amountLabel?: string = "Amount"
|
||||
providerLabel?: string = "Provider"
|
||||
referenceLabel?: string = "Reference"
|
||||
createdLabel?: string = "Created"
|
||||
}
|
||||
view {
|
||||
<section class="wrn-payment-page">
|
||||
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1></div><PaymentStatus status={payment.status} /></header>
|
||||
<dl class="wrn-payment-details">
|
||||
<div><dt>{amountLabel}</dt><dd>{payment.currency} {payment.amount}</dd></div>
|
||||
<div><dt>{providerLabel}</dt><dd>{payment.gateway}</dd></div>
|
||||
<div><dt>{referenceLabel}</dt><dd>{payment.id}</dd></div>
|
||||
<div><dt>{createdLabel}</dt><dd>{payment.createdAt}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import PaymentMethodList from "./PaymentMethodList.wrn"
|
||||
component PaymentMethodsPage {
|
||||
props {
|
||||
methods: array = []
|
||||
title?: string = "Payment methods"
|
||||
description?: string = "Manage provider-vaulted payment methods."
|
||||
emptyLabel?: string = "No saved payment methods."
|
||||
}
|
||||
view {
|
||||
<section class="wrn-payment-page">
|
||||
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
|
||||
<div class="wrn-payment-panel">{#if methods.length}<PaymentMethodList methods={methods} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import PaymentHistory from "./PaymentHistory.wrn"
|
||||
import PaymentSummary from "./PaymentSummary.wrn"
|
||||
component PaymentOverviewPage {
|
||||
props {
|
||||
payments: array = []
|
||||
captured: number = 0
|
||||
refunded: number = 0
|
||||
net: number = 0
|
||||
currency: string = "USD"
|
||||
title?: string = "Payments"
|
||||
description?: string = "Review payment activity, statuses, and totals."
|
||||
emptyLabel?: string = "No payments yet."
|
||||
}
|
||||
view {
|
||||
<section class="wrn-payment-page">
|
||||
<header class="wrn-payment-page__header"><div><span class="wrn-payment-page__eyebrow">{title}</span><h1>{title}</h1><p>{description}</p></div></header>
|
||||
<PaymentSummary captured={captured} refunded={refunded} net={net} currency={currency} />
|
||||
<div class="wrn-payment-panel"><h2>{title}</h2>{#if payments.length}<PaymentHistory payments={payments} />{:else}<div class="wrn-payment-empty">{emptyLabel}</div>{/if}</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
component PaymentSheet {
|
||||
props { checkoutUrl: string gateway: string }
|
||||
props {
|
||||
checkoutUrl: string
|
||||
gateway: string
|
||||
}
|
||||
view { <section class="wrn-payment-sheet" data-gateway={gateway}><p>Secure payment is completed on the payment provider.</p><a href={checkoutUrl}>Continue securely</a></section> }
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
component PaymentStatus {
|
||||
props { status: string label?: string = "Payment status" }
|
||||
props {
|
||||
status: string
|
||||
label?: string = "Payment status"
|
||||
}
|
||||
view { <div class="wrn-payment-status" role="status"><strong>{label}</strong><span>{status}</span></div> }
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
component PaymentSummary {
|
||||
props { captured: number = 0 refunded: number = 0 net: number = 0 currency: string }
|
||||
view { <dl><dt>Captured</dt><dd>{currency} {captured}</dd><dt>Refunded</dt><dd>{currency} {refunded}</dd><dt>Net</dt><dd>{currency} {net}</dd></dl> }
|
||||
props {
|
||||
captured: number = 0
|
||||
refunded: number = 0
|
||||
net: number = 0
|
||||
currency: string
|
||||
}
|
||||
view { <dl class="wrn-payment-summary"><div><dt>Captured</dt><dd>{currency} {captured}</dd></div><div><dt>Refunded</dt><dd>{currency} {refunded}</dd></div><div><dt>Net</dt><dd>{currency} {net}</dd></div></dl> }
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
component RefundButton {
|
||||
props { paymentId: string refundable: number partial?: boolean = false action?: string = "/api/payments/refund" }
|
||||
props {
|
||||
paymentId: string
|
||||
refundable: number
|
||||
partial?: boolean = false
|
||||
action?: string = "/api/payments/refund"
|
||||
}
|
||||
view { <form method="post" action={action}><input type="hidden" name="paymentId" value={paymentId} /><label>Reason<input name="reason" required /></label>{#if partial}<label>Amount<input name="amount" type="number" min="1" max={refundable} /></label>{/if}<button type="submit">Refund</button></form> }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.wrn-payment-page {
|
||||
color: var(--color-text, var(--wrn-color-text, #f5f7f6));
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
.wrn-payment-page__header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-payment-page__header h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
.wrn-payment-page__header p {
|
||||
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
|
||||
margin: 0;
|
||||
max-width: 42rem;
|
||||
}
|
||||
.wrn-payment-page__eyebrow {
|
||||
color: var(--color-primary, var(--wrn-color-primary, #10b981));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.wrn-payment-panel,
|
||||
.wrn-payment-details,
|
||||
.wrn-payment-summary {
|
||||
background: var(--color-surface, var(--wrn-color-surface, #111412));
|
||||
border: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.wrn-payment-summary {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.wrn-payment-summary div,
|
||||
.wrn-payment-details div {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-payment-summary dt,
|
||||
.wrn-payment-details dt {
|
||||
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.wrn-payment-summary dd,
|
||||
.wrn-payment-details dd {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.wrn-payment-details {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
.wrn-payment-history,
|
||||
.wrn-payment-methods {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.wrn-payment-history li,
|
||||
.wrn-payment-methods li {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border, var(--wrn-color-border, #29302c));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
.wrn-payment-history li:last-child,
|
||||
.wrn-payment-methods li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.wrn-payment-status {
|
||||
align-items: center;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary, var(--wrn-color-primary, #10b981)) 12%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-primary, var(--wrn-color-primary, #10b981)) 32%, transparent);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
.wrn-payment-status strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.wrn-payment-status span {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.wrn-payment-sheet {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-payment-sheet a,
|
||||
.wrn-payment-button {
|
||||
align-items: center;
|
||||
background: var(--color-primary, var(--wrn-color-primary, #10b981));
|
||||
border-radius: var(--radius-md, 0.7rem);
|
||||
color: var(--color-on-primary, #fff);
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wrn-payment-button[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wrn-payment-empty {
|
||||
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-payment-security {
|
||||
color: var(--color-text-muted, var(--wrn-color-muted, #a7afab));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.wrn-payment-panel h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-payment-page__header {
|
||||
display: grid;
|
||||
}
|
||||
.wrn-payment-summary,
|
||||
.wrn-payment-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wrn-payment-panel,
|
||||
.wrn-payment-details,
|
||||
.wrn-payment-summary {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/payment",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,6 +13,7 @@
|
||||
".": "./src/index.ts",
|
||||
"./adapters": "./src/adapters.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./runtime-api": "./src/runtime-api.ts",
|
||||
"./runtime-webhook": "./src/runtime-webhook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -343,6 +343,23 @@ export interface RazorpayConfig {
|
||||
webhookSecret: string;
|
||||
theme?: { color?: string };
|
||||
}
|
||||
export interface RazorpayCheckoutResult {
|
||||
orderId: string;
|
||||
paymentId: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/** Verify the signed result returned by Razorpay Standard Checkout. */
|
||||
export async function verifyRazorpayCheckoutSignature(
|
||||
result: RazorpayCheckoutResult,
|
||||
keySecret: string,
|
||||
) {
|
||||
if (!result.orderId || !result.paymentId || !result.signature || !keySecret) return false;
|
||||
return equal(
|
||||
result.signature.toLowerCase(),
|
||||
await hmac(`${result.orderId}|${result.paymentId}`, keySecret),
|
||||
);
|
||||
}
|
||||
export function razorpayGateway(config: RazorpayConfig): PaymentGateway<RazorpayConfig> {
|
||||
const auth = `Basic ${btoa(`${config.keyId}:${config.keySecret}`)}`;
|
||||
const request = (path: string, init: RequestInit = {}) =>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
paypalGateway,
|
||||
razorpayGateway,
|
||||
sandboxGateway,
|
||||
stripeGateway,
|
||||
type PayPalConfig,
|
||||
type RazorpayConfig,
|
||||
type SandboxOptions,
|
||||
type StripeConfig,
|
||||
} from "./adapters.ts";
|
||||
import { configurePayment, type PaymentOptions, type PaymentService } from "./core.ts";
|
||||
import type { PaymentGateway, PaymentStore } from "./types.ts";
|
||||
import type { PaymentApiConfig } from "./http.ts";
|
||||
|
||||
export type ConfiguredPaymentGateway =
|
||||
| ({ provider: "sandbox" } & SandboxOptions)
|
||||
| ({ provider: "stripe" } & StripeConfig)
|
||||
| ({ provider: "razorpay" } & RazorpayConfig)
|
||||
| ({ provider: "paypal" } & PayPalConfig);
|
||||
|
||||
export interface PaymentConfig {
|
||||
/** Name of the gateway used when an operation does not select one explicitly. */
|
||||
default: string;
|
||||
/** Any number of named gateway accounts. Names are persisted with payment intents. */
|
||||
gateways: Record<string, ConfiguredPaymentGateway | false>;
|
||||
environment?: "development" | "test" | "production";
|
||||
api?: PaymentApiConfig | false;
|
||||
/** Defaults to the package database store. Override for custom persistence or tests. */
|
||||
store?: PaymentStore;
|
||||
}
|
||||
|
||||
function required(name: string, fields: Record<string, unknown>) {
|
||||
for (const [field, value] of Object.entries(fields))
|
||||
if (typeof value !== "string" || !value.trim())
|
||||
throw new Error(`WRN-PAYMENT-CONFIG: gateway '${name}' requires ${field}`);
|
||||
}
|
||||
|
||||
export function validatePaymentConfig(config: PaymentConfig) {
|
||||
if (!config?.default?.trim()) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
||||
if (!config.gateways || typeof config.gateways !== "object")
|
||||
throw new Error("WRN-PAYMENT-CONFIG: payment.gateways is required");
|
||||
for (const [name, value] of Object.entries(config.gateways)) {
|
||||
if (value === false) continue;
|
||||
if (!name.trim()) throw new Error("WRN-PAYMENT-CONFIG: gateway name is required");
|
||||
if (value.provider === "stripe")
|
||||
required(name, {
|
||||
secretKey: value.secretKey,
|
||||
publishableKey: value.publishableKey,
|
||||
webhookSecret: value.webhookSecret,
|
||||
});
|
||||
if (value.provider === "razorpay")
|
||||
required(name, { keyId: value.keyId, keySecret: value.keySecret });
|
||||
if (value.provider === "razorpay" && config.environment === "production")
|
||||
required(name, { webhookSecret: value.webhookSecret });
|
||||
if (value.provider === "paypal")
|
||||
required(name, {
|
||||
clientId: value.clientId,
|
||||
clientSecret: value.clientSecret,
|
||||
webhookId: value.webhookId,
|
||||
});
|
||||
}
|
||||
if (!config.gateways[config.default])
|
||||
throw new Error(
|
||||
`WRN-PAYMENT-CONFIG: default gateway '${config.default}' is not enabled or configured`,
|
||||
);
|
||||
}
|
||||
|
||||
function namedGateway(name: string, gateway: PaymentGateway): PaymentGateway {
|
||||
return Object.freeze({ ...gateway, id: name, provider: gateway.provider ?? gateway.id });
|
||||
}
|
||||
|
||||
export function paymentOptionsFromConfig(
|
||||
config: PaymentConfig,
|
||||
store: PaymentStore,
|
||||
): PaymentOptions {
|
||||
validatePaymentConfig(config);
|
||||
|
||||
const adapters: PaymentGateway[] = [];
|
||||
const webhookSecrets: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(config.gateways)) {
|
||||
if (value === false) continue;
|
||||
if (!name.trim()) throw new Error("WRN-PAYMENT-CONFIG: gateway name is required");
|
||||
let adapter: PaymentGateway;
|
||||
switch (value.provider) {
|
||||
case "sandbox":
|
||||
adapter = sandboxGateway(value);
|
||||
break;
|
||||
case "stripe":
|
||||
adapter = stripeGateway(value);
|
||||
break;
|
||||
case "razorpay":
|
||||
adapter = razorpayGateway(value);
|
||||
break;
|
||||
case "paypal":
|
||||
adapter = paypalGateway(value);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`WRN-PAYMENT-CONFIG: unsupported provider '${(value as any).provider}'`);
|
||||
}
|
||||
adapters.push(namedGateway(name, adapter));
|
||||
if ("webhookSecret" in value && value.webhookSecret) webhookSecrets[name] = value.webhookSecret;
|
||||
if (value.provider === "paypal" && value.webhookId) webhookSecrets[name] = value.webhookId;
|
||||
}
|
||||
return {
|
||||
store,
|
||||
adapters,
|
||||
default: config.default,
|
||||
webhookSecrets,
|
||||
environment: config.environment,
|
||||
};
|
||||
}
|
||||
|
||||
export function configurePaymentFromConfig(
|
||||
config: PaymentConfig,
|
||||
store: PaymentStore,
|
||||
): PaymentService {
|
||||
return configurePayment(paymentOptionsFromConfig(config, store));
|
||||
}
|
||||
@@ -416,6 +416,13 @@ export function definePayment(options: PaymentOptions) {
|
||||
reconcilePayments,
|
||||
paymentWebhookHandler,
|
||||
capabilitiesOf: (id: string) => adapterFor(id)?.capabilities,
|
||||
providers: () =>
|
||||
[...adapters.values()].map((adapter) => ({
|
||||
id: adapter.id,
|
||||
provider: adapter.provider ?? adapter.id,
|
||||
capabilities: adapter.capabilities,
|
||||
supports: adapter.supports,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { InitializePaymentInput, PaymentService } from "./core.ts";
|
||||
import type { PaymentIntent } from "./types.ts";
|
||||
|
||||
export type PaymentApiAction =
|
||||
| "list"
|
||||
| "read"
|
||||
| "initialize"
|
||||
| "sync"
|
||||
| "cancel"
|
||||
| "capture"
|
||||
| "refund"
|
||||
| "methods"
|
||||
| "reconcile";
|
||||
|
||||
export interface PaymentApiContext {
|
||||
req: Request;
|
||||
params?: Record<string, string>;
|
||||
user?: { id?: string };
|
||||
session?: { userId?: string; subject?: string };
|
||||
}
|
||||
|
||||
export interface PaymentApiConfig {
|
||||
enabled?: boolean;
|
||||
/** Resolve the authenticated subject on the server. Request-body subject values are ignored. */
|
||||
subject?(context: PaymentApiContext): string | undefined | Promise<string | undefined>;
|
||||
/** Additional authorization. Administrative actions deny by default. */
|
||||
authorize?(
|
||||
context: PaymentApiContext,
|
||||
action: PaymentApiAction,
|
||||
payment?: PaymentIntent,
|
||||
): boolean | Promise<boolean>;
|
||||
/** Replace client input with authoritative catalog/order data before creating an intent. */
|
||||
initialize?(
|
||||
context: PaymentApiContext,
|
||||
body: Record<string, unknown>,
|
||||
subject: string,
|
||||
): Omit<InitializePaymentInput, "subject"> | Promise<Omit<InitializePaymentInput, "subject">>;
|
||||
}
|
||||
|
||||
const HTTP = Symbol.for("@wrnexus/payment:http:v1");
|
||||
type PaymentHttpGlobal = typeof globalThis & { [HTTP]?: PaymentApiConfig };
|
||||
|
||||
export function configurePaymentApi(config: PaymentApiConfig = {}) {
|
||||
(globalThis as PaymentHttpGlobal)[HTTP] = config;
|
||||
}
|
||||
|
||||
export function getPaymentApiConfig(): PaymentApiConfig {
|
||||
return (globalThis as PaymentHttpGlobal)[HTTP] ?? {};
|
||||
}
|
||||
|
||||
export async function paymentApiSubject(context: PaymentApiContext) {
|
||||
const config = getPaymentApiConfig();
|
||||
const resolved = await config.subject?.(context);
|
||||
return resolved?.trim() || context.user?.id?.trim() || context.session?.userId?.trim() || "";
|
||||
}
|
||||
|
||||
export async function paymentApiAllowed(
|
||||
context: PaymentApiContext,
|
||||
action: PaymentApiAction,
|
||||
payment?: PaymentIntent,
|
||||
) {
|
||||
const config = getPaymentApiConfig();
|
||||
if (config.enabled === false) return false;
|
||||
if (config.authorize) return Boolean(await config.authorize(context, action, payment));
|
||||
const subject = await paymentApiSubject(context);
|
||||
if (!subject) return false;
|
||||
if (["capture", "refund", "reconcile"].includes(action)) return false;
|
||||
return !payment || payment.subject === subject;
|
||||
}
|
||||
|
||||
export function publicPayment(payment: PaymentIntent, status: string) {
|
||||
return {
|
||||
id: payment.id,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
gateway: payment.gateway,
|
||||
status,
|
||||
createdAt: payment.createdAt,
|
||||
checkoutUrl: payment.checkoutUrl,
|
||||
client: payment.client,
|
||||
metadata: payment.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOwnedPayment(
|
||||
service: PaymentService,
|
||||
context: PaymentApiContext,
|
||||
id: string,
|
||||
action: PaymentApiAction,
|
||||
) {
|
||||
const payment = await service.getPayment(id);
|
||||
if (!payment) return { response: Response.json({ error: "payment not found" }, { status: 404 }) };
|
||||
if (!(await paymentApiAllowed(context, action, payment)))
|
||||
return { response: Response.json({ error: "payment access denied" }, { status: 403 }) };
|
||||
return { payment };
|
||||
}
|
||||
@@ -3,5 +3,7 @@ export * from "./store.ts";
|
||||
export * from "./database.ts";
|
||||
export * from "./core.ts";
|
||||
export * from "./adapters.ts";
|
||||
export * from "./config.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./contract.ts";
|
||||
export { paymentPlugin } from "./plugin.ts";
|
||||
@@ -1,10 +1,25 @@
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||
import { configurePaymentFromConfig, validatePaymentConfig, type PaymentConfig } from "./config.ts";
|
||||
import { configurePaymentApi } from "./http.ts";
|
||||
import { databasePaymentStore } from "./database.ts";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts";
|
||||
const webhookEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-webhook${extension}`);
|
||||
const apiEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-api${extension}`);
|
||||
const apiRoutes = [
|
||||
"/api/payments/providers",
|
||||
"/api/payments/intents",
|
||||
"/api/payments/intents/[id]",
|
||||
"/api/payments/intents/[id]/sync",
|
||||
"/api/payments/intents/[id]/capture",
|
||||
"/api/payments/intents/[id]/cancel",
|
||||
"/api/payments/intents/[id]/refund",
|
||||
"/api/payments/methods",
|
||||
"/api/payments/reconcile",
|
||||
];
|
||||
const migration = `-- +up
|
||||
CREATE TABLE IF NOT EXISTS wrn_payment_intent (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, checkout_url TEXT, client_json TEXT NOT NULL, metadata_json TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS wrn_payment_intent_subject ON wrn_payment_intent(subject, created_at);
|
||||
@@ -31,25 +46,54 @@ export function paymentPlugin() {
|
||||
const key = "@wrnexus/payment:enabled";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/payment",
|
||||
version: "0.8.1",
|
||||
version: "0.8.5",
|
||||
componentDirs: [join(root, "components")],
|
||||
styleSources: [
|
||||
{
|
||||
id: "payment-components",
|
||||
entry: join(root, "components", "payment.css"),
|
||||
source: join(root, "components"),
|
||||
order: "normal",
|
||||
},
|
||||
],
|
||||
routeEntries(context) {
|
||||
return context.metadata.get(key)
|
||||
? [{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry }]
|
||||
: [];
|
||||
if (!context.metadata.get(key)) return [];
|
||||
const apiEnabled = context.metadata.get("@wrnexus/payment:api") !== false;
|
||||
return [
|
||||
{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry },
|
||||
...(apiEnabled
|
||||
? apiRoutes.map((path) => ({ kind: "api" as const, path, entry: apiEntry }))
|
||||
: []),
|
||||
];
|
||||
},
|
||||
configure(config, context) {
|
||||
const value = config.payment as
|
||||
{ default?: string; gateways?: Record<string, unknown>; sandbox?: boolean } | undefined;
|
||||
const value = config.payment as PaymentConfig | undefined;
|
||||
context.metadata.set(key, Boolean(value));
|
||||
if (!value) return;
|
||||
const api = value.api === false ? { enabled: false } : (value.api ?? {});
|
||||
context.metadata.set(
|
||||
"@wrnexus/payment:api",
|
||||
Boolean(value.gateways) && api.enabled !== false,
|
||||
);
|
||||
configurePaymentApi(api);
|
||||
if (value.gateways) {
|
||||
validatePaymentConfig(value);
|
||||
configurePaymentFromConfig(value, value.store ?? databasePaymentStore());
|
||||
}
|
||||
if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required");
|
||||
if (!value.gateways?.[value.default] && value.default !== "sandbox")
|
||||
const legacySandbox = value.default === "sandbox" && !value.gateways;
|
||||
if (!value.gateways?.[value.default] && !legacySandbox)
|
||||
throw new Error(
|
||||
`WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`,
|
||||
);
|
||||
if (process.env.NODE_ENV === "production" && value.sandbox)
|
||||
throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production");
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
(legacySandbox ||
|
||||
Object.values(value.gateways ?? {}).some(
|
||||
(gateway) => gateway && gateway.provider === "sandbox",
|
||||
))
|
||||
)
|
||||
throw new Error("WRN-PAYMENT-CONFIG: sandbox gateways cannot be enabled in production");
|
||||
},
|
||||
migrations(context: PluginContext) {
|
||||
return context.metadata.get(key)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getPaymentService } from "./core.ts";
|
||||
import {
|
||||
ensureOwnedPayment,
|
||||
getPaymentApiConfig,
|
||||
paymentApiAllowed,
|
||||
paymentApiSubject,
|
||||
publicPayment,
|
||||
type PaymentApiContext,
|
||||
} from "./http.ts";
|
||||
|
||||
const json = (value: unknown, status = 200) => Response.json(value, { status });
|
||||
const bodyOf = async (req: Request) =>
|
||||
(await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const idOf = (ctx: PaymentApiContext) => ctx.params?.id?.trim() ?? "";
|
||||
const resultResponse = (result: any, success = 200) =>
|
||||
result.ok
|
||||
? json(result.value, success)
|
||||
: json({ error: result.reason, fault: result.fault }, 422);
|
||||
|
||||
export async function GET(ctx: PaymentApiContext) {
|
||||
const service = getPaymentService();
|
||||
const path = new URL(ctx.req.url).pathname;
|
||||
if (path.endsWith("/providers")) {
|
||||
if (getPaymentApiConfig().enabled === false)
|
||||
return json({ error: "payment API disabled" }, 404);
|
||||
return json({ providers: service.providers() });
|
||||
}
|
||||
if (path.endsWith("/methods")) {
|
||||
if (!(await paymentApiAllowed(ctx, "methods")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
return json({ methods: await service.paymentMethods(await paymentApiSubject(ctx)) });
|
||||
}
|
||||
if (idOf(ctx)) {
|
||||
const owned = await ensureOwnedPayment(service, ctx, idOf(ctx), "read");
|
||||
if (owned.response) return owned.response;
|
||||
return json(publicPayment(owned.payment!, await service.checkPayment(owned.payment!.id)));
|
||||
}
|
||||
if (!(await paymentApiAllowed(ctx, "list"))) return json({ error: "payment access denied" }, 403);
|
||||
const subject = await paymentApiSubject(ctx);
|
||||
const rows = await service.listPayments({ subject });
|
||||
return json({
|
||||
payments: await Promise.all(
|
||||
rows.map(async (payment) => publicPayment(payment, await service.checkPayment(payment.id))),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(ctx: PaymentApiContext) {
|
||||
const service = getPaymentService();
|
||||
const path = new URL(ctx.req.url).pathname;
|
||||
const body = await bodyOf(ctx.req);
|
||||
if (path.endsWith("/intents")) {
|
||||
if (!(await paymentApiAllowed(ctx, "initialize")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
const subject = await paymentApiSubject(ctx);
|
||||
const resolver = getPaymentApiConfig().initialize;
|
||||
const input = resolver
|
||||
? await resolver(ctx, body, subject)
|
||||
: {
|
||||
amount: Number(body.amount),
|
||||
currency: String(body.currency ?? "").toUpperCase(),
|
||||
gateway: typeof body.gateway === "string" ? body.gateway : undefined,
|
||||
country: typeof body.country === "string" ? body.country : undefined,
|
||||
capture: body.capture === "manual" ? ("manual" as const) : ("automatic" as const),
|
||||
idempotencyKey: String(
|
||||
body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? "",
|
||||
),
|
||||
returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : undefined,
|
||||
metadata:
|
||||
body.metadata && typeof body.metadata === "object"
|
||||
? (body.metadata as Record<string, string>)
|
||||
: undefined,
|
||||
};
|
||||
const result = await service.initializePayment({ ...input, subject });
|
||||
if (!result.ok) return resultResponse(result, 201);
|
||||
return json(publicPayment(result.value, await service.checkPayment(result.value.id)), 201);
|
||||
}
|
||||
if (path.endsWith("/reconcile")) {
|
||||
if (!(await paymentApiAllowed(ctx, "reconcile")))
|
||||
return json({ error: "payment access denied" }, 403);
|
||||
return json({ discrepancies: await service.reconcilePayments() });
|
||||
}
|
||||
const id = idOf(ctx);
|
||||
if (!id) return json({ error: "payment id is required" }, 400);
|
||||
const action = path.endsWith("/sync")
|
||||
? "sync"
|
||||
: path.endsWith("/capture")
|
||||
? "capture"
|
||||
: path.endsWith("/cancel")
|
||||
? "cancel"
|
||||
: path.endsWith("/refund")
|
||||
? "refund"
|
||||
: undefined;
|
||||
if (!action) return json({ error: "payment operation not found" }, 404);
|
||||
const owned = await ensureOwnedPayment(service, ctx, id, action);
|
||||
if (owned.response) return owned.response;
|
||||
if (action === "sync") return resultResponse(await service.syncPayment(id));
|
||||
if (action === "capture")
|
||||
return resultResponse(
|
||||
await service.capturePayment(
|
||||
id,
|
||||
body.amount === undefined
|
||||
? undefined
|
||||
: { amount: Number(body.amount), currency: owned.payment!.currency },
|
||||
),
|
||||
);
|
||||
if (action === "cancel")
|
||||
return resultResponse(await service.cancelPayment(id, String(body.reason ?? "")));
|
||||
return resultResponse(
|
||||
await service.refundPayment({
|
||||
id,
|
||||
amount: body.amount === undefined ? undefined : Number(body.amount),
|
||||
reason: String(body.reason ?? ""),
|
||||
idempotencyKey: String(body.idempotencyKey ?? ctx.req.headers.get("idempotency-key") ?? ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export interface WebhookEvent {
|
||||
|
||||
export interface PaymentGateway<TConfig = unknown> {
|
||||
readonly id: string;
|
||||
/** Built-in provider type when the id is an application-defined gateway alias. */
|
||||
readonly provider?: string;
|
||||
readonly config?: TConfig;
|
||||
readonly capabilities: GatewayCapabilities;
|
||||
readonly supports: GatewaySupport;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
exerciseGatewayContract,
|
||||
paypalGateway,
|
||||
razorpayGateway,
|
||||
verifyRazorpayCheckoutSignature,
|
||||
sandboxGateway,
|
||||
signSandboxWebhook,
|
||||
stripeGateway,
|
||||
@@ -180,6 +181,22 @@ test("shared executable contract drives Razorpay through its HTTP adapter", asyn
|
||||
expect(report).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
test("Razorpay checkout signatures are verified without trusting browser data", async () => {
|
||||
const secret = "checkout-secret";
|
||||
const orderId = "order_1";
|
||||
const paymentId = "pay_1";
|
||||
const signed = await signature(`${orderId}|${paymentId}`, secret);
|
||||
expect(
|
||||
await verifyRazorpayCheckoutSignature({ orderId, paymentId, signature: signed }, secret),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await verifyRazorpayCheckoutSignature(
|
||||
{ orderId, paymentId: "pay_tampered", signature: signed },
|
||||
secret,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shared executable contract drives PayPal through its HTTP adapter", async () => {
|
||||
globalThis.fetch = (async (value, init) => {
|
||||
const url = String(value),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { memoryPaymentStore } from "../src/store.ts";
|
||||
import { paymentOptionsFromConfig } from "../src/config.ts";
|
||||
|
||||
test("configuration creates any number of named provider gateways", () => {
|
||||
const options = paymentOptionsFromConfig(
|
||||
{
|
||||
default: "india",
|
||||
environment: "test",
|
||||
gateways: {
|
||||
india: {
|
||||
provider: "razorpay",
|
||||
keyId: "rzp_test",
|
||||
keySecret: "secret",
|
||||
webhookSecret: "razorpay-hook",
|
||||
},
|
||||
global: {
|
||||
provider: "stripe",
|
||||
secretKey: "sk_test",
|
||||
publishableKey: "pk_test",
|
||||
webhookSecret: "stripe-hook",
|
||||
},
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
memoryPaymentStore(),
|
||||
);
|
||||
|
||||
expect(options.default).toBe("india");
|
||||
expect(options.adapters.map((adapter) => adapter.id)).toEqual(["india", "global"]);
|
||||
expect(options.adapters.map((adapter) => adapter.provider)).toEqual(["razorpay", "stripe"]);
|
||||
expect(options.webhookSecrets).toEqual({ india: "razorpay-hook", global: "stripe-hook" });
|
||||
});
|
||||
|
||||
test("configuration rejects a disabled or missing default gateway", () => {
|
||||
expect(() =>
|
||||
paymentOptionsFromConfig(
|
||||
{ default: "missing", gateways: { sandbox: { provider: "sandbox" } } },
|
||||
memoryPaymentStore(),
|
||||
),
|
||||
).toThrow("default gateway 'missing' is not enabled or configured");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { configurePaymentFromConfig } from "../src/config.ts";
|
||||
import { configurePaymentApi } from "../src/http.ts";
|
||||
import { GET, POST } from "../src/runtime-api.ts";
|
||||
import { memoryPaymentStore } from "../src/store.ts";
|
||||
|
||||
function request(path: string, body?: unknown) {
|
||||
return new Request(`https://app.test${path}`, {
|
||||
method: body ? "POST" : "GET",
|
||||
headers: body ? { "content-type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
test("package APIs use server subjects and never expose gateway secrets", async () => {
|
||||
configurePaymentFromConfig(
|
||||
{
|
||||
default: "testGateway",
|
||||
environment: "test",
|
||||
gateways: { testGateway: { provider: "sandbox", secret: "server-only" } },
|
||||
},
|
||||
memoryPaymentStore(),
|
||||
);
|
||||
configurePaymentApi({ subject: (ctx) => ctx.user?.id });
|
||||
|
||||
const providers = await GET({ req: request("/api/payments/providers") });
|
||||
const providerBody = await providers.text();
|
||||
expect(providers.status).toBe(200);
|
||||
expect(providerBody).toContain("testGateway");
|
||||
expect(providerBody).toContain('"provider":"sandbox"');
|
||||
expect(providerBody).not.toContain("server-only");
|
||||
|
||||
const denied = await POST({
|
||||
req: request("/api/payments/intents", {
|
||||
subject: "attacker-selected",
|
||||
amount: 500,
|
||||
currency: "USD",
|
||||
idempotencyKey: "anonymous",
|
||||
}),
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
|
||||
const created = await POST({
|
||||
req: request("/api/payments/intents", {
|
||||
subject: "attacker-selected",
|
||||
amount: 500,
|
||||
currency: "USD",
|
||||
idempotencyKey: "owned",
|
||||
}),
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const payment = await created.json();
|
||||
expect(payment.id).toStartWith("pay_");
|
||||
expect(JSON.stringify(payment)).not.toContain("server-only");
|
||||
|
||||
const listed = await GET({
|
||||
req: request("/api/payments/intents"),
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
expect((await listed.json()).payments).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("administrative payment APIs deny by default", async () => {
|
||||
configurePaymentApi({ subject: () => "user-1" });
|
||||
const response = await POST({
|
||||
req: request("/api/payments/reconcile", {}),
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -768,9 +768,7 @@ function expandStructuredStateDeclarations(lines, unit) {
|
||||
}
|
||||
|
||||
function formatStructuredPropDeclaration(value, unit, depth, printWidth) {
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([\[{][\s\S]*)$/.exec(
|
||||
value,
|
||||
);
|
||||
const match = /^([A-Za-z_$][\w$]*\??\s*(?::\s*[^=]+?)?\s*=\s*)([[{][\s\S]*)$/.exec(value);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
Static type checking for `.wrn` declarations, props, state, outputs, functions, stores, and generated virtual TypeScript files.
|
||||
|
||||
The package is compiler tooling rather than a browser UI package, so its public kit consists of programmatic typecheck helpers and diagnostics.
|
||||
|
||||
View attribute `{t:key}` segments are translation markers, not TypeScript expressions. Ordinary expressions in the same attribute remain typechecked. Inline event bindings accept statement lists, with `event.currentTarget` typed as the bound HTML/SVG element. Nullable DOM queries still require normal guards.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -377,6 +377,9 @@ export function virtualTypeScriptModule(
|
||||
// misspelled state/function name cannot silently become `undefined` at
|
||||
// runtime. Each/handler locals mirror the browser evaluator's bindings.
|
||||
let bindingIndex = 0;
|
||||
append(
|
||||
"type __WrnEventElement<Tag extends string> = Tag extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[Tag] : Tag extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[Tag] : HTMLElement;",
|
||||
);
|
||||
const reservedProps = new Set(
|
||||
ast.props.map((prop) => prop.name).filter((name) => !safeBindingName(name)),
|
||||
);
|
||||
@@ -386,7 +389,12 @@ export function virtualTypeScriptModule(
|
||||
for (const attr of node.attrs) {
|
||||
const expressions = attr.event
|
||||
? [attr.value]
|
||||
: extractInterpolations(attr.value).map((segment) => segment.expression);
|
||||
: extractInterpolations(attr.value)
|
||||
// Translation markers are resolved by i18n, not evaluated as JavaScript.
|
||||
// Filter individual segments so mixed translated/dynamic attributes
|
||||
// still typecheck their ordinary expressions.
|
||||
.filter((segment) => !/^t:[^{}]+$/.test(segment.expression))
|
||||
.map((segment) => segment.expression);
|
||||
for (const expression of expressions) {
|
||||
if (!expression.trim()) continue;
|
||||
const declarations = [
|
||||
@@ -394,12 +402,12 @@ export function virtualTypeScriptModule(
|
||||
...(attr.event
|
||||
? [
|
||||
"declare const payload: any;",
|
||||
"declare const event: Event & { target: HTMLElement };",
|
||||
`declare const event: Event & { target: HTMLElement; currentTarget: __WrnEventElement<${JSON.stringify(node.tag)}> };`,
|
||||
]
|
||||
: []),
|
||||
].join(" ");
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${safeViewExpression(expression, reservedProps)}); }`,
|
||||
`namespace __wrn_view_${bindingIndex++} { ${declarations} ${attr.event ? `void (() => { ${safeViewExpression(expression, reservedProps)} });` : `void (${safeViewExpression(expression, reservedProps)});`} }`,
|
||||
attr.value,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { checkWrnSource, virtualTypeScriptModule } from "../src/index.ts";
|
||||
|
||||
test("translation markers in native and component attributes are not TypeScript expressions", () => {
|
||||
const source = `page Contact {
|
||||
state count = 1
|
||||
view {
|
||||
<div data-component="PageHeader" eyebrow="{t:contact.eyebrow}" title="{t:contact.title}" description="{t:contact.description}"></div>
|
||||
<input aria-label="{t:contact.form.name}" placeholder='{t:contact.form.namePlaceholder}' />
|
||||
<div data-success-message="{t:contact.form.successMessage}" title="{t:contact.title} — {count}">{t:contact.title}</div>
|
||||
<span t:aria-label="contact.form.label"></span>
|
||||
}
|
||||
}`;
|
||||
expect(checkWrnSource(source)).toEqual([]);
|
||||
const virtual = virtualTypeScriptModule(source).code;
|
||||
expect(virtual).not.toContain("void (t:");
|
||||
expect(virtual).toContain("void (count)");
|
||||
});
|
||||
|
||||
test("mixed translation bindings retain genuine expression diagnostics", () => {
|
||||
const source = `page Contact {
|
||||
view {
|
||||
<div title="{t:contact.title} — {missingValue}"></div>
|
||||
<div title="{true ? missingBranch : 'fallback'}"></div>
|
||||
<div data-value="{{ t: missingProperty }}"></div>
|
||||
}
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source);
|
||||
for (const name of ["missingValue", "missingBranch", "missingProperty"]) {
|
||||
expect(
|
||||
diagnostics.some((item) => item.code === "WRN-TYPE-2304" && item.message.includes(name)),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(diagnostics.some((item) => item.message.includes("name 't'"))).toBe(false);
|
||||
});
|
||||
|
||||
test("the translation exemption does not apply to event handlers", () => {
|
||||
const diagnostics = checkWrnSource(
|
||||
`page Contact { view { <button @click="t:contact.title"></button> } }`,
|
||||
);
|
||||
expect(
|
||||
diagnostics.some((item) => item.code === "WRN-TYPE-2304" && item.message.includes("contact")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("form event statement lists have the correct currentTarget type", () => {
|
||||
const source = `page Contact {
|
||||
view {
|
||||
<form @wrn:success="event.currentTarget.reset(); toast.success('Sent')"></form>
|
||||
<input @input="toast.info(event.currentTarget.value)" />
|
||||
<button @click="event.currentTarget.disabled = true; toast.info('Working')"></button>
|
||||
}
|
||||
}`;
|
||||
expect(checkWrnSource(source)).toEqual([]);
|
||||
const invalid = checkWrnSource(
|
||||
source.replace("event.currentTarget.disabled", "event.currentTarget.notARealProperty"),
|
||||
);
|
||||
expect(invalid.some((item) => item.code === "WRN-TYPE-2339")).toBe(true);
|
||||
});
|
||||
@@ -119,7 +119,7 @@ Theme-aware, responsive badge component.
|
||||
Theme-aware, responsive blockquote component.
|
||||
|
||||
- Mount: `data-component="Blockquote"`
|
||||
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `class: string = ""`
|
||||
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -290,7 +290,7 @@ Theme-aware, responsive styled icon component.
|
||||
Present responsive chronological activity, milestones, or workflow status with rich item metadata.
|
||||
|
||||
- Mount: `data-component="Timeline"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `numbered: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
@@ -413,7 +413,7 @@ Theme-aware, responsive file input component.
|
||||
Theme-aware, responsive input component.
|
||||
|
||||
- Mount: `data-component="Input"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `invalidMessage: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `focus({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `blur({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `invalid({ value: string | number | boolean | null | object; name: string; message: string; sourceEvent: Event })`, `keydown({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`, `keyup({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`
|
||||
|
||||
@@ -827,7 +827,7 @@ Theme-aware, responsive navbar component.
|
||||
|
||||
- Mount: `data-component="Navbar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: Record<string, unknown> = {}`, `items: unknown[] = []`, `actions: unknown[] = []`, `active: string = ""`, `sticky: boolean = false`, `floating: boolean = false`, `startFloating: string = "0px"`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `collapseAt: string = "900px"`, `mobilePanel: string = "content"`, `mobileUtilitiesPosition: string = "header"`, `gutter: string = "md"`, `contentGutter: string = "md"`, `flushMobile: boolean = false`, `translationPrefix: string = ""`, `class: string = ""`
|
||||
- Slots: `topbar`, `actions`, `utilities`
|
||||
- Slots: `topbar`, `brand`, `actions`, `utilities`
|
||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`, `action({ item: string | number | boolean | null | object; value: string | number | boolean })`
|
||||
|
||||
### Pagination
|
||||
@@ -853,7 +853,7 @@ Theme-aware, responsive scrollspy component.
|
||||
Theme-aware, responsive sidebar component.
|
||||
|
||||
- Mount: `data-component="Sidebar"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `mobile: boolean = true`, `drawerPlacement: string = "left"`, `class: string = ""`
|
||||
- Slots: `default`, `drawer`
|
||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })`
|
||||
|
||||
@@ -872,7 +872,7 @@ Switch between related responsive content panels with horizontal or vertical ori
|
||||
|
||||
- Mount: `data-component="Tabs"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""`
|
||||
- Slots: `panel-{valueOf(item, index)}`, `default`
|
||||
- Slots: `default`
|
||||
- Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })`
|
||||
|
||||
## Overlays
|
||||
@@ -900,7 +900,7 @@ Present responsive modal side or bottom content with focus management, backdrop
|
||||
Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.
|
||||
|
||||
- Mount: `data-component="Dropdown"`
|
||||
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
|
||||
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `ariaLabel: string = ""`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
|
||||
|
||||
|
||||
@@ -1751,6 +1751,13 @@
|
||||
"default": "true",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "maxWidth",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"xl\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
@@ -1791,6 +1798,13 @@
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "activeIcon",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"icon-[lucide--file-text]\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "separator",
|
||||
"type": "string",
|
||||
@@ -5461,6 +5475,13 @@
|
||||
"default": "\"Open menu\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "ariaLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "icon",
|
||||
"type": "string",
|
||||
@@ -7044,6 +7065,13 @@
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "invalidMessage",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "inline",
|
||||
"type": "boolean",
|
||||
@@ -9282,7 +9310,7 @@
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"slots": ["topbar", "actions", "utilities"],
|
||||
"slots": ["topbar", "brand", "actions", "utilities"],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "toggle",
|
||||
@@ -11528,6 +11556,20 @@
|
||||
"default": "\"Navigation\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "mobile",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "drawerPlacement",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"default": "\"left\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
@@ -12727,7 +12769,7 @@
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"slots": ["panel-{valueOf(item, index)}", "default"],
|
||||
"slots": ["default"],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "change",
|
||||
@@ -13329,6 +13371,13 @@
|
||||
"default": "\"default\"",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "numbered",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "false",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "class",
|
||||
"type": "string",
|
||||
|
||||
@@ -304,7 +304,7 @@ component AdvancedSelect {
|
||||
aria-label="{selectedOptionsLabel}"
|
||||
data-show="multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' && selectedCount() > 0"
|
||||
>
|
||||
{#each allOptions() as option}
|
||||
{#each multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' ? selectedOptions() : [] as option}
|
||||
<span data-show="isSelected(option)">
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
@@ -371,11 +371,11 @@ component AdvancedSelect {
|
||||
role="listbox"
|
||||
aria-multiselectable="{multiple}"
|
||||
>
|
||||
{#each groups as group}
|
||||
{#each open ? groups : [] as group}
|
||||
{#if visibleOptions(group.options || []).length}
|
||||
<div class="wrn-next__option-group" role="group" aria-label="{group.label}">
|
||||
<div class="wrn-next__group-label">{group.label}</div>
|
||||
{#each group.options || [] as option}
|
||||
{#each visibleOptions(group.options || []) as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
@@ -402,7 +402,7 @@ component AdvancedSelect {
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each options as option}
|
||||
{#each open ? visibleOptions(options) : [] as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
|
||||
@@ -150,7 +150,7 @@ open: boolean = false
|
||||
aria-label='{closeLabel}'
|
||||
@click='hideDrawer("close-button", event)'
|
||||
>
|
||||
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -397,7 +397,7 @@ open: boolean = false
|
||||
|
||||
.wrn-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.35rem 1.35rem 1.1rem;
|
||||
@@ -519,6 +519,12 @@ open: boolean = false
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.wrn-drawer__header {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-drawer[data-placement="right"] .wrn-drawer__panel,
|
||||
.wrn-drawer[data-placement="left"] .wrn-drawer__panel {
|
||||
|
||||
@@ -12,10 +12,12 @@ items: unknown[] = []
|
||||
open: boolean = false
|
||||
defaultOpen: boolean = false
|
||||
label: string = "Open menu"
|
||||
ariaLabel: string = ""
|
||||
icon: string = ""
|
||||
showChevron: boolean = true
|
||||
menuLabel: string = "Dropdown menu"
|
||||
placement: string = "bottom-start"
|
||||
mobilePanel: string = "sheet"
|
||||
width: string = "md"
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -47,6 +49,11 @@ items: unknown[] = []
|
||||
visible = false
|
||||
output.close({ reason: reason, sourceEvent: sourceEvent })
|
||||
output.toggle({ open: false, reason: reason, sourceEvent: sourceEvent })
|
||||
if (reason === "escape") {
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const trigger = root.querySelector(".wrn-dropdown__trigger")
|
||||
if (trigger) { trigger.focus() }
|
||||
}
|
||||
}
|
||||
|
||||
client function toggleMenu(sourceEvent) {
|
||||
@@ -65,14 +72,14 @@ items: unknown[] = []
|
||||
output.select({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
value: item.value == null ? "" : item.value,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
if (item.action) {
|
||||
output.action({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
value: item.value == null ? "" : item.value,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
@@ -83,18 +90,24 @@ items: unknown[] = []
|
||||
|
||||
client function moveFocus(sourceEvent, direction) {
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const activeIndex = options.indexOf(document.activeElement)
|
||||
const nextIndex = (activeIndex + direction + options.length) % options.length
|
||||
options[nextIndex].focus()
|
||||
window.setTimeout(function () {
|
||||
if (!root.isConnected || !isOpen()) { return }
|
||||
const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const activeIndex = options.indexOf(document.activeElement)
|
||||
const nextIndex = activeIndex < 0 ? (direction > 0 ? 0 : options.length - 1) : (activeIndex + direction + options.length) % options.length
|
||||
options[nextIndex].focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (disabled) { return }
|
||||
if (sourceEvent.key === "Escape") {
|
||||
if (!isOpen()) { return }
|
||||
sourceEvent.preventDefault()
|
||||
sourceEvent.stopPropagation()
|
||||
hideMenu("escape", sourceEvent)
|
||||
} else if (sourceEvent.key === "ArrowDown") {
|
||||
sourceEvent.preventDefault()
|
||||
@@ -111,19 +124,26 @@ items: unknown[] = []
|
||||
} else if (sourceEvent.key === "Home" && isOpen()) {
|
||||
sourceEvent.preventDefault()
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const first = root.querySelector("[data-dropdown-item]:not([disabled])")
|
||||
const first = root.querySelector('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')
|
||||
if (first) {
|
||||
first.focus()
|
||||
}
|
||||
} else if (sourceEvent.key === "End" && isOpen()) {
|
||||
sourceEvent.preventDefault()
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
|
||||
const options = [...root.querySelectorAll('[data-dropdown-item]:not([disabled]):not([aria-disabled="true"])')]
|
||||
if (options.length) {
|
||||
options[options.length - 1].focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client function handleFocusout(sourceEvent) {
|
||||
const root = sourceEvent.currentTarget
|
||||
if (isOpen() && sourceEvent.relatedTarget && !root.contains(sourceEvent.relatedTarget)) {
|
||||
hideMenu("focusout", sourceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
@@ -132,18 +152,21 @@ items: unknown[] = []
|
||||
data-ui-component="Dropdown"
|
||||
data-open='{open || visible ? "true" : "false"}'
|
||||
data-placement='{placement}'
|
||||
data-mobile-panel='{mobilePanel}'
|
||||
data-width='{width}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
class='wrn-dropdown {class}'
|
||||
@keydown='handleKeydown(event)'
|
||||
@focusout='handleFocusout(event)'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-dropdown__trigger"
|
||||
disabled='{disabled}'
|
||||
aria-haspopup="menu"
|
||||
aria-label='{ariaLabel || label}'
|
||||
aria-expanded='{open || visible ? "true" : "false"}'
|
||||
@click='toggleMenu(event)'
|
||||
>
|
||||
@@ -163,7 +186,7 @@ items: unknown[] = []
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-dropdown__dismiss-layer"
|
||||
data-show='{open || visible}'
|
||||
hidden='{!(open || visible)}'
|
||||
aria-label="Close menu"
|
||||
@click='hideMenu("outside", event)'
|
||||
></button>
|
||||
@@ -172,7 +195,7 @@ items: unknown[] = []
|
||||
<div
|
||||
class="wrn-dropdown__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
hidden='{!(open || visible)}'
|
||||
role="menu"
|
||||
aria-label='{menuLabel}'
|
||||
>
|
||||
@@ -194,6 +217,7 @@ items: unknown[] = []
|
||||
data-danger='{item.danger ? "true" : "false"}'
|
||||
data-selected='{item.selected || item.checked ? "true" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
tabindex='{item.disabled ? "-1" : "0"}'
|
||||
class="wrn-dropdown__item"
|
||||
@click='chooseItem(item, itemIndex, event)'
|
||||
>
|
||||
@@ -460,7 +484,7 @@ items: unknown[] = []
|
||||
border: 0;
|
||||
border-radius: 0.72rem;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease;
|
||||
@@ -550,7 +574,7 @@ items: unknown[] = []
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-dropdown__panel {
|
||||
.wrn-dropdown[data-placement][data-mobile-panel="sheet"] .wrn-dropdown__panel {
|
||||
position: fixed;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
|
||||
@@ -475,6 +475,7 @@ component Footer {
|
||||
}
|
||||
|
||||
.wrn-footer__copyright {
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
@@ -502,7 +503,12 @@ component Footer {
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.wrn-footer__copyright-left {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.wrn-footer__copyright-right {
|
||||
grid-column: 3;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -545,10 +551,12 @@ component Footer {
|
||||
}
|
||||
.wrn-footer__copyright-left,
|
||||
.wrn-footer__copyright-right {
|
||||
grid-column: 1;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-footer__copyright {
|
||||
grid-column: 1;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ component Sidebar {
|
||||
active: string = ""
|
||||
mobileLabel: string = "Open navigation"
|
||||
drawerTitle: string = "Navigation"
|
||||
mobile: boolean = true
|
||||
drawerPlacement: string = "left"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
@@ -60,14 +62,16 @@ component Sidebar {
|
||||
return Boolean(entry.value) && entry.value === active
|
||||
}
|
||||
|
||||
client function choose(entry, level) {
|
||||
client function choose(entry, level, sourceEvent) {
|
||||
if (entry.disabled) {
|
||||
sourceEvent.preventDefault()
|
||||
return
|
||||
}
|
||||
output.select({ item: entry, value: entry.value || "", level: level })
|
||||
if (drawerOpen) {
|
||||
drawerOpen = false
|
||||
output.close({ source: "select" })
|
||||
output.toggle({ open: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +95,7 @@ component Sidebar {
|
||||
class='wrn-sidebar {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-mobile='{mobile ? "true" : "false"}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -117,7 +122,8 @@ component Sidebar {
|
||||
data-active='{isActive(entry)}'
|
||||
aria-current='{isActive(entry) ? "page" : "false"}'
|
||||
aria-disabled='{entry.disabled ? "true" : "false"}'
|
||||
@click='choose(entry, 1)'
|
||||
tabindex='{entry.disabled ? "-1" : "0"}'
|
||||
@click='choose(entry, 1, event)'
|
||||
>
|
||||
<span
|
||||
class='wrn-sidebar__icon {entry.icon}'
|
||||
@@ -137,7 +143,9 @@ component Sidebar {
|
||||
data-active='{isActive(child)}'
|
||||
aria-current='{isActive(child) ? "page" : "false"}'
|
||||
aria-disabled='{child.disabled ? "true" : "false"}'
|
||||
@click='choose(child, 2)'
|
||||
data-wrn-roving-item="true"
|
||||
tabindex='{child.disabled ? "-1" : "0"}'
|
||||
@click='choose(child, 2, event)'
|
||||
>
|
||||
<span
|
||||
class='wrn-sidebar__icon {child.icon}'
|
||||
@@ -159,7 +167,10 @@ component Sidebar {
|
||||
href='{leaf.href || "#"}'
|
||||
data-active='{isActive(leaf)}'
|
||||
aria-current='{isActive(leaf) ? "page" : "false"}'
|
||||
@click='choose(leaf, 3)'
|
||||
aria-disabled='{leaf.disabled ? "true" : "false"}'
|
||||
data-wrn-roving-item="true"
|
||||
tabindex='{leaf.disabled ? "-1" : "0"}'
|
||||
@click='choose(leaf, 3, event)'
|
||||
>
|
||||
<span class="wrn-sidebar__label">{leaf.label}</span>
|
||||
</a>
|
||||
@@ -175,15 +186,94 @@ component Sidebar {
|
||||
<slot />
|
||||
</nav>
|
||||
|
||||
{#if mobile}
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
placement="left"
|
||||
placement={drawerPlacement}
|
||||
title={drawerTitle}
|
||||
label={label}
|
||||
@close="closeDrawer()"
|
||||
>
|
||||
<nav class="wrn-sidebar__drawer-nav" aria-label='{label}'>
|
||||
<ul class="wrn-sidebar__list" data-wrn-roving="vertical">
|
||||
{#each entryList() as entry}
|
||||
<li class="wrn-sidebar__entry" data-group='{isGroup(entry)}'>
|
||||
<p class="wrn-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
|
||||
|
||||
<a
|
||||
class="wrn-sidebar__link"
|
||||
data-show="!entry.heading"
|
||||
href='{entry.href || "#"}'
|
||||
data-wrn-roving-item="true"
|
||||
data-active='{isActive(entry)}'
|
||||
aria-current='{isActive(entry) ? "page" : "false"}'
|
||||
aria-disabled='{entry.disabled ? "true" : "false"}'
|
||||
tabindex='{entry.disabled ? "-1" : "0"}'
|
||||
@click='choose(entry, 1, event)'
|
||||
>
|
||||
<span
|
||||
class='wrn-sidebar__icon {entry.icon}'
|
||||
data-show="entry.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-sidebar__label">{entry.label}</span>
|
||||
<span class="wrn-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
|
||||
</a>
|
||||
|
||||
<ul class="wrn-sidebar__sublist" data-show="childrenOf(entry).length > 0">
|
||||
{#each childrenOf(entry) as child}
|
||||
<li class="wrn-sidebar__entry">
|
||||
<a
|
||||
class="wrn-sidebar__link"
|
||||
href='{child.href || "#"}'
|
||||
data-active='{isActive(child)}'
|
||||
aria-current='{isActive(child) ? "page" : "false"}'
|
||||
aria-disabled='{child.disabled ? "true" : "false"}'
|
||||
data-wrn-roving-item="true"
|
||||
tabindex='{child.disabled ? "-1" : "0"}'
|
||||
@click='choose(child, 2, event)'
|
||||
>
|
||||
<span
|
||||
class='wrn-sidebar__icon {child.icon}'
|
||||
data-show="child.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-sidebar__label">{child.label}</span>
|
||||
<span class="wrn-sidebar__badge" data-show="child.badge">{child.badge}</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wrn-sidebar__sublist wrn-sidebar__sublist--level3"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
>
|
||||
{#each childrenOf(child) as leaf}
|
||||
<li class="wrn-sidebar__entry">
|
||||
<a
|
||||
class="wrn-sidebar__link"
|
||||
href='{leaf.href || "#"}'
|
||||
data-active='{isActive(leaf)}'
|
||||
aria-current='{isActive(leaf) ? "page" : "false"}'
|
||||
aria-disabled='{leaf.disabled ? "true" : "false"}'
|
||||
data-wrn-roving-item="true"
|
||||
tabindex='{leaf.disabled ? "-1" : "0"}'
|
||||
@click='choose(leaf, 3, event)'
|
||||
>
|
||||
<span class="wrn-sidebar__label">{leaf.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
<slot name="drawer"></slot>
|
||||
</Drawer>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -231,9 +321,21 @@ component Sidebar {
|
||||
}
|
||||
|
||||
.wrn-sidebar__sublist {
|
||||
margin-left: 0.85rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--wrn-color-border);
|
||||
margin-inline-start: 0.85rem;
|
||||
padding-inline-start: 0.5rem;
|
||||
border-inline-start: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-sidebar__entry[data-group="true"] > .wrn-sidebar__sublist {
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
border-inline-start: 0;
|
||||
}
|
||||
|
||||
.wrn-sidebar__icon {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wrn-sidebar__entry[data-group="true"] + .wrn-sidebar__entry {
|
||||
@@ -286,7 +388,7 @@ component Sidebar {
|
||||
}
|
||||
|
||||
.wrn-sidebar__badge {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
|
||||
@@ -323,11 +425,11 @@ component Sidebar {
|
||||
* the focus trap and the scroll lock.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.wrn-sidebar__launcher {
|
||||
.wrn-sidebar[data-mobile="true"] > .wrn-sidebar__launcher {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrn-sidebar__rail {
|
||||
.wrn-sidebar[data-mobile="true"] > .wrn-sidebar__rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,16 @@ component StrongPassword {
|
||||
state detailsOpen: boolean = false
|
||||
state revealed: boolean = false
|
||||
|
||||
state nativeError: string = ""
|
||||
|
||||
functions {
|
||||
client function handleInvalid(sourceEvent) {
|
||||
sourceEvent.preventDefault()
|
||||
sourceEvent.stopPropagation()
|
||||
nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.")
|
||||
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
|
||||
}
|
||||
|
||||
shared function hasMinimumLength() {
|
||||
return password.length >= Number(minLength)
|
||||
}
|
||||
@@ -126,7 +135,7 @@ component StrongPassword {
|
||||
|
||||
view {
|
||||
<div
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--strong-password wrn-next--strong-password-{presentation} {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--strong-password wrn-next--strong-password-{presentation} {invalid || validationMessage || nativeError ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
data-wrn-strong-password
|
||||
data-strength="{password ? strengthLevel() : 'empty'}"
|
||||
data-score="{password ? score() : 0}"
|
||||
@@ -146,11 +155,11 @@ component StrongPassword {
|
||||
disabled="{disabled}"
|
||||
readonly="{readonly}"
|
||||
required="{required}"
|
||||
aria-invalid="{invalid ? 'true' : 'false'}"
|
||||
aria-describedby="{validationMessage ? name + '-validation' : hintText ? name + '-hint' : ''}"
|
||||
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
|
||||
aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : hintText ? name + '-hint' : ''}"
|
||||
@focus="detailsOpen = true"
|
||||
@blur="detailsOpen = false"
|
||||
@input="password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
|
||||
@invalid="handleInvalid(event)" @input="nativeError = ''; password = event.target.value; output.input({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() }); output.strength({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
|
||||
@change="output.change({ value: password, score: score(), maximumScore: maximumScore(), percent: strengthPercent(), level: strengthLevel() })"
|
||||
/>
|
||||
<button
|
||||
@@ -231,11 +240,14 @@ component StrongPassword {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<small id="{name}-validation" class="wrn-next__validation" data-error="{name}">{validationMessage}</small>
|
||||
<small id="{name}-validation" class="wrn-next__validation" data-error="{name}">{(validationMessage || nativeError)}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--strong-password .wrn-next__validation {
|
||||
color: var(--wrn-color-danger, #ef4444);
|
||||
}
|
||||
.wrn-next--strong-password {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
@@ -36,7 +36,16 @@ component TogglePassword {
|
||||
|
||||
state revealed = visible
|
||||
|
||||
state nativeError: string = ""
|
||||
|
||||
functions {
|
||||
client function handleInvalid(sourceEvent) {
|
||||
sourceEvent.preventDefault()
|
||||
sourceEvent.stopPropagation()
|
||||
nativeError = validationMessage || (sourceEvent.currentTarget.validity.valueMissing ? "Enter your password." : "Enter a password that meets the requirements.")
|
||||
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
|
||||
}
|
||||
|
||||
shared function toggleVisibility() {
|
||||
if (disabled || readonly || !toggleable) {
|
||||
return
|
||||
@@ -55,7 +64,7 @@ component TogglePassword {
|
||||
|
||||
view {
|
||||
<fieldset
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--toggle-password {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--toggle-password {invalid || validationMessage || nativeError ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
data-wrn-toggle-password
|
||||
data-visible="{revealed ? 'true' : 'false'}"
|
||||
>
|
||||
@@ -78,8 +87,8 @@ component TogglePassword {
|
||||
disabled="{disabled || field.disabled}"
|
||||
readonly="{readonly || field.readonly}"
|
||||
required="{required || field.required}"
|
||||
aria-invalid="{invalid ? 'true' : 'false'}"
|
||||
@input="output.input({ name: event.target.name, value: event.target.value, visible: revealed })"
|
||||
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
|
||||
@invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ name: event.target.name, value: event.target.value, visible: revealed })"
|
||||
@change="output.change({ name: event.target.name, value: event.target.value, visible: revealed })"
|
||||
/>
|
||||
{#if toggleable && toggleMode === "button"}
|
||||
@@ -122,9 +131,9 @@ component TogglePassword {
|
||||
disabled="{disabled}"
|
||||
readonly="{readonly}"
|
||||
required="{required}"
|
||||
aria-invalid="{invalid ? 'true' : 'false'}"
|
||||
aria-describedby="{validationMessage ? name + '-validation' : helpText ? name + '-help' : ''}"
|
||||
@input="output.input({ value: event.target.value, visible: revealed })"
|
||||
aria-invalid="{invalid || nativeError ? 'true' : 'false'}"
|
||||
aria-describedby="{(validationMessage || nativeError) ? name + '-validation' : helpText ? name + '-help' : ''}"
|
||||
@invalid="handleInvalid(event)" @input="nativeError = ''; output.input({ value: event.target.value, visible: revealed })"
|
||||
@change="output.change({ value: event.target.value, visible: revealed })"
|
||||
/>
|
||||
{#if toggleable && toggleMode === "button"}
|
||||
@@ -153,16 +162,19 @@ component TogglePassword {
|
||||
<span>{checkboxLabel}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
|
||||
{#if helpText && !(validationMessage || nativeError)}<small id="{name}-help">{helpText}</small>{/if}
|
||||
<small
|
||||
id="{name}-validation"
|
||||
class="wrn-next__validation"
|
||||
data-error="{name}"
|
||||
>{validationMessage}</small>
|
||||
>{(validationMessage || nativeError)}</small>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--toggle-password .wrn-next__validation {
|
||||
color: var(--wrn-color-danger, #ef4444);
|
||||
}
|
||||
.wrn-next--toggle-password {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
@@ -25,6 +25,7 @@ component Input {
|
||||
helperText: string = ""
|
||||
cornerHint: string = ""
|
||||
error: string = ""
|
||||
invalidMessage: string = ""
|
||||
inline: boolean = false
|
||||
readonly: boolean = false
|
||||
disabled: boolean = false
|
||||
@@ -56,7 +57,16 @@ component Input {
|
||||
client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) }
|
||||
client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) }
|
||||
client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) }
|
||||
client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) {
|
||||
sourceEvent.preventDefault()
|
||||
sourceEvent.stopPropagation()
|
||||
const field = sourceEvent.currentTarget
|
||||
const validity = field.validity
|
||||
validationError = error || invalidMessage || (validity.valueMissing ? "This field is required." : validity.typeMismatch ? "Enter a valid " + (type === "email" ? "email address." : "value.") : validity.tooShort ? "Enter at least " + minlength + " characters." : validity.tooLong ? "Enter no more than " + maxlength + " characters." : validity.patternMismatch ? "Enter a value in the required format." : validity.rangeUnderflow ? "Enter a value of at least " + min + "." : validity.rangeOverflow ? "Enter a value no greater than " + max + "." : "Enter a valid value.")
|
||||
field.setAttribute("aria-invalid", "true")
|
||||
field.closest(".wrn-next--field").setAttribute("data-invalid", "true")
|
||||
output.invalid({ value: field.value, name: name, message: validationError, sourceEvent: sourceEvent })
|
||||
}
|
||||
client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
@@ -68,6 +78,7 @@ component Input {
|
||||
data-inline="{inline}"
|
||||
data-floating="{variant === 'floating'}"
|
||||
data-invalid="{error || validationError ? 'true' : 'false'}"
|
||||
data-input-type="{type}"
|
||||
>
|
||||
<div
|
||||
class="wrn-next__field-heading"
|
||||
@@ -89,10 +100,11 @@ component Input {
|
||||
<div
|
||||
class="wrn-next__field-control"
|
||||
data-icon-position="{iconPosition}"
|
||||
data-has-icon="{icon || type === 'date' || type === 'datetime-local' || type === 'time' || type === 'month' || type === 'week' ? 'true' : 'false'}"
|
||||
>
|
||||
{#if icon}
|
||||
{#if icon || type === "date" || type === "datetime-local" || type === "time" || type === "month" || type === "week"}
|
||||
<span
|
||||
class="{icon} wrn-next__field-icon"
|
||||
class="{icon || (type === 'time' ? 'icon-[lucide--clock-3]' : type === 'datetime-local' ? 'icon-[lucide--calendar-clock]' : 'icon-[lucide--calendar-days]')} wrn-next__field-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
@@ -144,6 +156,7 @@ component Input {
|
||||
id="{(id || name) + '-error'}"
|
||||
class="wrn-next__field-error"
|
||||
data-error="{name}"
|
||||
data-empty="{error || validationError ? 'false' : 'true'}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{error || validationError}
|
||||
@@ -298,6 +311,12 @@ component Input {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Input is a block-level control. Shared field sizing must not cap it when
|
||||
it participates in a grid or flex layout owned by the consumer. */
|
||||
.wrn-next--field.wrn-next--input {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
@@ -336,10 +355,29 @@ component Input {
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
.wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
.wrn-next--input[data-input-type="date"] .wrn-next__field-control,
|
||||
.wrn-next--input[data-input-type="datetime-local"] .wrn-next__field-control,
|
||||
.wrn-next--input[data-input-type="time"] .wrn-next__field-control,
|
||||
.wrn-next--input[data-input-type="month"] .wrn-next__field-control,
|
||||
.wrn-next--input[data-input-type="week"] .wrn-next__field-control {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
.wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"]) {
|
||||
min-width: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wrn-next--input :is(input[type="date"], input[type="datetime-local"], input[type="time"], input[type="month"], input[type="week"])::-webkit-calendar-picker-indicator {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.wrn-next__field-control[data-has-icon="true"][data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
@@ -423,6 +461,10 @@ component Input {
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__field-error[data-empty="true"] {
|
||||
display: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
|
||||
@@ -46,7 +46,7 @@ size: string = "default"
|
||||
}
|
||||
client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { sourceEvent.preventDefault(); sourceEvent.stopPropagation(); validationError = error || "Choose an option."; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}" data-readonly="{readonly}">
|
||||
@@ -57,7 +57,7 @@ size: string = "default"
|
||||
{#if !multiple}<option value="" selected="{value === ''}" disabled="{required}">{placeholder}</option>{/if}
|
||||
{#each options as option}<option value="{option.value}" selected="{selected(option)}" disabled="{option.disabled}">{option.label}</option>{/each}
|
||||
</select>
|
||||
{#if !multiple}<span class="icon-[lucide--chevron-down] wrn-next__select-indicator" aria-hidden="true"></span>{/if}
|
||||
{#if !multiple}<svg class="wrn-next__select-indicator" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg>{/if}
|
||||
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
|
||||
</div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}
|
||||
@@ -79,7 +79,11 @@ size: string = "default"
|
||||
|
||||
.wrn-next__select-indicator {
|
||||
position: absolute;
|
||||
right: 0.85rem;
|
||||
inset-inline-end: 0.85rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ size: string = "default"
|
||||
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
|
||||
}
|
||||
client function handleInvalid(sourceEvent) {
|
||||
validationError = sourceEvent.currentTarget.validationMessage
|
||||
sourceEvent.preventDefault()
|
||||
sourceEvent.stopPropagation()
|
||||
validationError = error || (sourceEvent.currentTarget.validity.valueMissing ? "This field is required." : "Enter text within the allowed length.")
|
||||
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
|
||||
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true")
|
||||
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.56",
|
||||
"version": "0.8.70",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface MegaMenuColumn {
|
||||
actionHref?: string;
|
||||
}
|
||||
|
||||
export interface MegaMenuRailItem extends NavigationLink {}
|
||||
export type MegaMenuRailItem = NavigationLink;
|
||||
|
||||
export interface MegaMenuFeatured {
|
||||
eyebrow?: string;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderComponent, mountHtml } from "../../test/src/index.ts";
|
||||
import { uiComponentPath } from "../src/registry.ts";
|
||||
import { parse } from "../../compiler/src/index.ts";
|
||||
import { Window } from "happy-dom";
|
||||
|
||||
const source = (name: string) => readFileSync(uiComponentPath(name), "utf8");
|
||||
|
||||
describe("account shell UI regressions", () => {
|
||||
test("Drawer has a self-contained close icon and a compact centered mobile header", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Drawer"), { title: "Account center", open: true, duration: 0 }),
|
||||
);
|
||||
const close = dom.querySelector(".wrn-drawer__close") as HTMLButtonElement;
|
||||
expect(close.getAttribute("aria-label")).toBe("Close drawer");
|
||||
expect(close.querySelector('svg[aria-hidden="true"] path')).not.toBeNull();
|
||||
expect(close.querySelector('[class*="icon-"]')).toBeNull();
|
||||
expect(source("Drawer")).toMatch(/\.wrn-drawer__header\s*\{[^}]*align-items: center;/);
|
||||
expect(source("Drawer")).toContain("padding-block: 0.75rem;");
|
||||
let closed = false;
|
||||
dom.querySelector('[data-ui-component="Drawer"]')?.addEventListener("wrnexus:close", () => {
|
||||
closed = true;
|
||||
});
|
||||
close.click();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
test("Dropdown allows anchored mobile menus without changing existing sheet defaults", async () => {
|
||||
for (const mobilePanel of [undefined, "dropdown", "sheet"]) {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Dropdown"), {
|
||||
label: "User",
|
||||
placement: "bottom-end",
|
||||
...(mobilePanel ? { mobilePanel } : {}),
|
||||
}),
|
||||
);
|
||||
expect(dom.querySelector(".wrn-dropdown")?.getAttribute("data-mobile-panel")).toBe(
|
||||
mobilePanel ?? "sheet",
|
||||
);
|
||||
(dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelector(".wrn-dropdown__panel")?.hasAttribute("hidden")).toBe(false);
|
||||
}
|
||||
expect(source("Dropdown")).not.toContain(
|
||||
".wrn-dropdown[data-placement] .wrn-dropdown__panel {",
|
||||
);
|
||||
});
|
||||
test("Dropdown is natively hidden before CSS or hydration and still toggles afterward", async () => {
|
||||
for (const props of [{}, { open: true }, { defaultOpen: true }]) {
|
||||
const html = await renderComponent(source("Dropdown"), { label: "User", ...props });
|
||||
const server = new Window();
|
||||
try {
|
||||
server.document.body.innerHTML = html;
|
||||
const closed = !(props.open || props.defaultOpen);
|
||||
for (const selector of [".wrn-dropdown__panel", ".wrn-dropdown__dismiss-layer"]) {
|
||||
expect(server.document.querySelector(selector)?.hasAttribute("hidden")).toBe(closed);
|
||||
}
|
||||
} finally {
|
||||
await server.happyDOM.close();
|
||||
}
|
||||
}
|
||||
const dom = mountHtml(await renderComponent(source("Dropdown"), { label: "User" }));
|
||||
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
||||
const panel = dom.querySelector(".wrn-dropdown__panel")!;
|
||||
expect(panel.hasAttribute("hidden")).toBe(true);
|
||||
trigger.click();
|
||||
expect(panel.hasAttribute("hidden")).toBe(false);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
trigger.click();
|
||||
expect(panel.hasAttribute("hidden")).toBe(true);
|
||||
});
|
||||
|
||||
test("Sidebar supplies navigation to its mobile Drawer without an app slot", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Sidebar"), {
|
||||
items: [
|
||||
{ heading: "Account", items: [{ label: "Profile", href: "/profile", value: "profile" }] },
|
||||
],
|
||||
active: "profile",
|
||||
}),
|
||||
);
|
||||
const mobileNav = dom.querySelector(".wrn-sidebar__drawer-nav");
|
||||
expect(mobileNav?.querySelector('a[href="/profile"]')?.getAttribute("aria-current")).toBe(
|
||||
"page",
|
||||
);
|
||||
expect(dom.querySelector('.wrn-sidebar__rail a[href="/profile"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Sidebar embedded in another drawer does not generate a second mobile drawer", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Sidebar"), {
|
||||
mobile: false,
|
||||
items: [{ label: "Profile", href: "/profile" }],
|
||||
}),
|
||||
);
|
||||
expect(dom.querySelector('[data-component="Drawer"]')).toBeNull();
|
||||
expect(dom.querySelector(".wrn-sidebar")?.getAttribute("data-mobile")).toBe("false");
|
||||
});
|
||||
|
||||
test("Sidebar blocks disabled links at every nesting level", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Sidebar"), {
|
||||
items: [
|
||||
{
|
||||
label: "Parent",
|
||||
disabled: true,
|
||||
href: "/parent",
|
||||
items: [
|
||||
{
|
||||
label: "Child",
|
||||
disabled: true,
|
||||
href: "/child",
|
||||
items: [{ label: "Leaf", disabled: true, href: "/leaf" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
for (const href of ["/parent", "/child", "/leaf"]) {
|
||||
const link = dom.querySelector(`.wrn-sidebar__rail a[href="${href}"]`)!;
|
||||
expect(link.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(link.getAttribute("tabindex")).toBe("-1");
|
||||
const click = new (dom.window as { MouseEvent: typeof MouseEvent }).MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
link.dispatchEvent(click);
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("Dropdown keyboard navigation skips disabled links and ArrowUp starts at the last item", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Dropdown"), {
|
||||
label: "Account",
|
||||
items: [
|
||||
{ label: "Profile", href: "/profile" },
|
||||
{ label: "Disabled", href: "/disabled", disabled: true },
|
||||
{ label: "Security", href: "/security" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
||||
// Execute the component's real JS functions: the lightweight mount runtime
|
||||
// intentionally does not interpret nested timer callbacks.
|
||||
const functions = parse(source("Dropdown"))
|
||||
.functions.join("\n")
|
||||
.replace(/\b(client|shared) function/g, "function");
|
||||
const handle = new Function(
|
||||
"document",
|
||||
"window",
|
||||
"output",
|
||||
`
|
||||
let open = false, visible = false, disabled = false;
|
||||
${functions}
|
||||
return handleKeydown;
|
||||
`,
|
||||
)(dom.document, dom.window, {
|
||||
open() {},
|
||||
close() {},
|
||||
toggle({ open }: { open: boolean }) {
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
},
|
||||
});
|
||||
const key = (_target: Element, value: string) =>
|
||||
handle({
|
||||
key: value,
|
||||
currentTarget: dom.querySelector(".wrn-dropdown"),
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
trigger.focus();
|
||||
key(trigger, "ArrowUp");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(dom.document.activeElement?.textContent).toContain("Security");
|
||||
key(dom.document.activeElement!, "ArrowUp");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(dom.document.activeElement?.textContent).toContain("Profile");
|
||||
key(dom.document.activeElement!, "Escape");
|
||||
expect(dom.document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
test("Dropdown preserves falsy action values and has an explicit accessible trigger name", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Dropdown"), {
|
||||
label: "",
|
||||
ariaLabel: "Account menu",
|
||||
items: [{ label: "Zero", value: 0, action: true }],
|
||||
}),
|
||||
);
|
||||
let value: unknown = undefined;
|
||||
dom.querySelector(".wrn-dropdown")!.addEventListener("action", (event) => {
|
||||
value = (event as CustomEvent).detail.value;
|
||||
});
|
||||
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
||||
expect(trigger.getAttribute("aria-label")).toBe("Account menu");
|
||||
trigger.click();
|
||||
(dom.querySelector("[data-dropdown-item]") as HTMLButtonElement).click();
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
test("Dropdown closes when keyboard focus leaves the component", async () => {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source("Dropdown"), {
|
||||
label: "Menu",
|
||||
items: [{ label: "Profile", href: "/profile" }],
|
||||
}),
|
||||
);
|
||||
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
||||
trigger.click();
|
||||
const outside = dom.document.createElement("button");
|
||||
dom.document.body.append(outside);
|
||||
trigger.dispatchEvent(
|
||||
new (dom.window as { FocusEvent: typeof FocusEvent }).FocusEvent("focusout", {
|
||||
bubbles: true,
|
||||
relatedTarget: outside,
|
||||
}),
|
||||
);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
test("layout fixes belong to components, not consuming applications", () => {
|
||||
expect(source("Sidebar")).toMatch(
|
||||
/\.wrn-sidebar__icon\s*\{[^}]*width: 1.05rem;[^}]*height: 1.05rem;/s,
|
||||
);
|
||||
expect(source("Sidebar")).toContain("margin-inline-start");
|
||||
expect(source("Dropdown")).toContain(
|
||||
'.wrn-dropdown[data-placement][data-mobile-panel="sheet"] .wrn-dropdown__panel',
|
||||
);
|
||||
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-left\s*\{\s*grid-column: 1;/);
|
||||
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-right\s*\{\s*grid-column: 3;/);
|
||||
});
|
||||
});
|
||||
+189
-3
@@ -1294,6 +1294,10 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
|
||||
expect(validation.error).toBe("Enter a valid email address.");
|
||||
|
||||
const source = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const sharedFieldRule = source.lastIndexOf(".wrn-next--field,");
|
||||
const inputWidthRule = source.lastIndexOf(".wrn-next--field.wrn-next--input");
|
||||
expect(inputWidthRule).toBeGreaterThan(sharedFieldRule);
|
||||
expect(source.slice(inputWidthRule, inputWidthRule + 140)).toContain("max-width: none");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source, {
|
||||
id: "account-email",
|
||||
@@ -1317,6 +1321,7 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
|
||||
expect(root.dataset.variant).toBe("floating");
|
||||
expect(root.dataset.inline).toBe("true");
|
||||
expect(root.dataset.invalid).toBe("true");
|
||||
expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("false");
|
||||
expect(input.id).toBe("account-email");
|
||||
expect(input.name).toBe("email");
|
||||
expect(input.value).toBe("not-an-email");
|
||||
@@ -1344,7 +1349,39 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
|
||||
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
|
||||
});
|
||||
|
||||
test("basic fields render native constraint messages and select matches field surfaces", async () => {
|
||||
test("Input does not reserve validation space while its error is empty", async () => {
|
||||
const source = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const dom = mountHtml(await renderComponent(source, { name: "search", hiddenLabel: true }));
|
||||
expect(dom.querySelector(".wrn-next__field-error")?.getAttribute("data-empty")).toBe("true");
|
||||
expect(source).toContain('.wrn-next__field-error[data-empty="true"]');
|
||||
});
|
||||
|
||||
test("Input only reserves icon space when it renders an icon", async () => {
|
||||
const source = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const plain = mountHtml(
|
||||
await renderComponent(source, { name: "limit", label: "Limit", placeholder: "Enter limit" }),
|
||||
);
|
||||
const decorated = mountHtml(
|
||||
await renderComponent(source, {
|
||||
name: "email",
|
||||
label: "Email",
|
||||
icon: "icon-[lucide--mail]",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plain.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
|
||||
"false",
|
||||
);
|
||||
expect(plain.querySelector(".wrn-next__field-icon")).toBeNull();
|
||||
expect(decorated.querySelector(".wrn-next__field-control")?.getAttribute("data-has-icon")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(decorated.querySelector(".wrn-next__field-icon")).not.toBeNull();
|
||||
expect(source).toContain('[data-has-icon="true"][data-icon-position="start"]');
|
||||
expect(source).toContain('[data-has-icon="true"][data-icon-position="end"]');
|
||||
});
|
||||
|
||||
test("basic fields suppress native popups and render package constraint messages", async () => {
|
||||
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(inputSource, {
|
||||
@@ -1355,9 +1392,14 @@ test("basic fields render native constraint messages and select matches field su
|
||||
}),
|
||||
);
|
||||
const input = dom.querySelector("input") as HTMLInputElement;
|
||||
input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false }));
|
||||
const invalidEvent = new (dom.window as any).Event("invalid", {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(invalidEvent);
|
||||
expect(invalidEvent.defaultPrevented).toBe(true);
|
||||
expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe(
|
||||
input.validationMessage,
|
||||
"This field is required.",
|
||||
);
|
||||
expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true");
|
||||
|
||||
@@ -1367,6 +1409,74 @@ test("basic fields render native constraint messages and select matches field su
|
||||
expect(selectSource).toContain("wrn-next__select-indicator");
|
||||
});
|
||||
|
||||
test("Select renders its own directional indicator without app icon CSS", async () => {
|
||||
const source = readFileSync(uiComponentPath("Select"), "utf8");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source, {
|
||||
name: "method",
|
||||
value: "totp",
|
||||
options: [{ label: "Authenticator app", value: "totp" }],
|
||||
}),
|
||||
);
|
||||
const indicator = dom.querySelector("svg.wrn-next__select-indicator");
|
||||
expect(indicator?.getAttribute("width")).toBe("16");
|
||||
expect(indicator?.getAttribute("height")).toBe("16");
|
||||
expect(indicator?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(indicator?.querySelector("path")?.getAttribute("d")).toBe("m6 9 6 6 6-6");
|
||||
expect(source).toContain("inset-inline-end: 0.85rem");
|
||||
expect(source).not.toContain("icon-[lucide--chevron-down]");
|
||||
const multiple = mountHtml(await renderComponent(source, { multiple: true }));
|
||||
expect(multiple.querySelector(".wrn-next__select-indicator")).toBeNull();
|
||||
});
|
||||
|
||||
test("OTP inputs accept six digits and show custom inline errors without native popups", async () => {
|
||||
const source = readFileSync(uiComponentPath("Input"), "utf8");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source, {
|
||||
name: "code",
|
||||
pattern: "[0-9]{6}",
|
||||
required: true,
|
||||
invalidMessage: "Enter the six-digit verification code.",
|
||||
}),
|
||||
);
|
||||
const input = dom.querySelector("input") as HTMLInputElement;
|
||||
input.value = "737651";
|
||||
expect(input.checkValidity()).toBe(true);
|
||||
input.value = "123";
|
||||
const event = new (dom.window as any).Event("invalid", { cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(dom.querySelector("[data-error='code']")?.textContent).toContain(
|
||||
"Enter the six-digit verification code.",
|
||||
);
|
||||
});
|
||||
|
||||
test("password fields cancel native invalid popups and show inline errors", async () => {
|
||||
for (const component of ["TogglePassword", "StrongPassword"]) {
|
||||
const dom = mountHtml(
|
||||
await renderComponent(readFileSync(uiComponentPath(component), "utf8"), {
|
||||
name: "password",
|
||||
required: true,
|
||||
}),
|
||||
);
|
||||
const input = dom.querySelector("input") as HTMLInputElement;
|
||||
const event = new (dom.window as any).Event("invalid", { cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(dom.querySelector("[data-error='password']")?.textContent).toContain(
|
||||
"Enter your password.",
|
||||
);
|
||||
expect(dom.querySelector(".wrn-next--invalid")).not.toBeNull();
|
||||
const source = readFileSync(uiComponentPath(component), "utf8");
|
||||
expect(source).toMatch(
|
||||
/\.wrn-next--(?:toggle|strong)-password \.wrn-next__validation\s*\{\s*color: var\(--wrn-color-danger, #ef4444\)/,
|
||||
);
|
||||
input.value = "ExamplePassword123!";
|
||||
input.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
|
||||
expect(dom.querySelector(".wrn-next--invalid")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("all basic form components render values and their must-have interaction events", async () => {
|
||||
const components = [
|
||||
"Checkbox",
|
||||
@@ -1837,6 +1947,82 @@ test("input number increments, decrements, applies steps, and respects limits",
|
||||
expect(decrement().disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("advanced select keeps large closed lists compact and renders only selected tags", async () => {
|
||||
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
||||
const options = Array.from({ length: 420 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: "Option " + i,
|
||||
}));
|
||||
const html = await renderComponent(source, {
|
||||
name: "large",
|
||||
value: "419",
|
||||
options,
|
||||
searchResultLimit: 40,
|
||||
});
|
||||
expect(Buffer.byteLength(html)).toBeLessThan(120_000);
|
||||
const dom = mountHtml(html);
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
|
||||
expect(dom.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(0);
|
||||
expect(dom.querySelector(".wrn-next__select-value")?.textContent).toContain("Option 419");
|
||||
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(40);
|
||||
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
|
||||
|
||||
const multi = mountHtml(
|
||||
await renderComponent(source, {
|
||||
name: "selected-tags",
|
||||
multiple: true,
|
||||
values: ["419"],
|
||||
options,
|
||||
searchResultLimit: 4,
|
||||
}),
|
||||
);
|
||||
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(1);
|
||||
expect(multi.querySelector(".wrn-next__select-tags")?.textContent).toContain("Option 419");
|
||||
(multi.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
|
||||
(multi.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
|
||||
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(2);
|
||||
(multi.querySelector(".wrn-next__clear-select") as HTMLButtonElement).click();
|
||||
expect(multi.querySelectorAll(".wrn-next__select-tags > span")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("advanced select lazily opens grouped options and honors defaultOpen", async () => {
|
||||
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
||||
const groups = [{ label: "Regions", options: [{ value: "in", label: "India" }] }];
|
||||
const dom = mountHtml(await renderComponent(source, { name: "grouped", groups }));
|
||||
expect(dom.querySelectorAll(".wrn-next__option-group")).toHaveLength(0);
|
||||
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelectorAll(".wrn-next__option-group")).toHaveLength(1);
|
||||
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("in");
|
||||
const opened = mountHtml(
|
||||
await renderComponent(source, { name: "opened", groups, defaultOpen: true }),
|
||||
);
|
||||
expect(opened.querySelectorAll(".wrn-next__select-option")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("advanced select limits rendered options but searches the full dataset and retains saved values", async () => {
|
||||
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
||||
const dom = mountHtml(
|
||||
await renderComponent(source, {
|
||||
name: "region",
|
||||
value: "99",
|
||||
searchResultLimit: 4,
|
||||
options: Array.from({ length: 100 }, (_, i) => ({ value: String(i), label: "Region " + i })),
|
||||
}),
|
||||
);
|
||||
expect(dom.querySelector(".wrn-next__select-value")?.textContent).toContain("Region 99");
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(0);
|
||||
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(4);
|
||||
const search = dom.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
search.value = "Region 99";
|
||||
search.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
|
||||
expect(dom.querySelectorAll(".wrn-next__select-option")).toHaveLength(1);
|
||||
expect(dom.querySelector(".wrn-next__select-option")?.textContent).toContain("Region 99");
|
||||
});
|
||||
|
||||
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
|
||||
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
|
||||
Reference in new issue
Block a user