Verification/recovery: guard the OTP code field against a pasted space (numeric inputmode + digits-only pattern + one-time-code autofill); Kratos doesn't trim, so a space-padded code was rejected as 'invalid or already used'
This commit is contained in:
@@ -43,6 +43,10 @@ test("field defaults to a bare text input, escapes a string error, and never thr
|
||||
const bare = flat(await render({ id: "x", name: "x", label: "X" }));
|
||||
assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/);
|
||||
|
||||
// OTP code field: inputmode + pattern render (both after autocomplete, before required).
|
||||
const code = flat(await render({ id: "field-code", name: "code", label: "Verification code", autocomplete: "one-time-code", inputmode: "numeric", pattern: "[0-9]*", required: true, icon: "i-shield" }));
|
||||
assert.match(code, /<input class="input has-ico" id="field-code" name="code" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="\[0-9\]\*" required>/);
|
||||
|
||||
const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." }));
|
||||
assert.match(stringErr, /<span><b>Required<\/b>\.<\/span>/); // string error is escaped
|
||||
assert.match(stringErr, /aria-describedby="x-err"/);
|
||||
|
||||
@@ -96,6 +96,30 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
|
||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||
});
|
||||
|
||||
test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => {
|
||||
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
|
||||
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
|
||||
const view = buildFlowView(
|
||||
flow([
|
||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||
node({ name: "code", type: "text", required: true }, { label: "Verification code", group: "code" }),
|
||||
node({ name: "method", type: "submit", value: "code" }, { label: "Continue", group: "code" }),
|
||||
]),
|
||||
"verification",
|
||||
);
|
||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||
icon: "i-shield",
|
||||
id: "field-code",
|
||||
inputmode: "numeric",
|
||||
label: "Verification code",
|
||||
name: "code",
|
||||
pattern: "[0-9]*",
|
||||
required: true,
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("chrome varies per flow type: registration alt, recovery back link", () => {
|
||||
const reg = buildFlowView(flow([]), "registration");
|
||||
assert.equal(reg.title, "Create account");
|
||||
|
||||
+8
-1
@@ -11,8 +11,10 @@ export interface FlowField {
|
||||
error?: { text: string };
|
||||
icon?: string; // Lucide sprite id for the input
|
||||
id: string;
|
||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||
label: string;
|
||||
name: string;
|
||||
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
|
||||
required?: boolean;
|
||||
type: string;
|
||||
value?: string;
|
||||
@@ -93,7 +95,11 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
|
||||
|
||||
function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
const value = str(node.attributes["value"]);
|
||||
const autocomplete = str(node.attributes["autocomplete"]);
|
||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
||||
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
||||
const isCode = name === "code";
|
||||
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
||||
const icon = iconFor(name, type);
|
||||
const errorMsg = node.messages.find((m) => m.type === "error");
|
||||
return {
|
||||
@@ -104,6 +110,7 @@ function toField(node: UiNode, name: string, type: string): FlowField {
|
||||
...(autocomplete ? { autocomplete } : {}),
|
||||
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||
...(value ? { value } : {}),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Mirrors html-css-foundation auth markup. Config:
|
||||
id, name, label
|
||||
type? (default "text"), value?, placeholder?, autocomplete?, required?, readonly?, minlength?
|
||||
inputmode?, pattern? virtual-keyboard hint + client-side validity regex (e.g. the OTP code)
|
||||
icon? input-ico id (e.g. "i-mail") → left-padded input
|
||||
optional? show an "Optional" tag in the label row
|
||||
link? { href, label } inline beside the label (e.g. Forgot password?)
|
||||
@@ -24,7 +25,7 @@
|
||||
<% } else { -%>
|
||||
<label for="<%= id %>"><%= locals.label %></label>
|
||||
<% } -%>
|
||||
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %><% if (locals.readonly) { %> readonly<% } %>></div>
|
||||
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.inputmode) { %> inputmode="<%= locals.inputmode %>"<% } %><% if (locals.pattern) { %> pattern="<%= locals.pattern %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %><% if (locals.readonly) { %> readonly<% } %>></div>
|
||||
<% if (hint) { -%>
|
||||
<span class="field-hint"><%= hint %></span>
|
||||
<% } -%>
|
||||
|
||||
Reference in New Issue
Block a user