@clicktap/ui 2.3.4 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  import { PasswordCheckProps } from './PasswordCheck.types';
2
- export declare function PasswordCheck({ value, variant, requirements, }: PasswordCheckProps): import("react/jsx-runtime").JSX.Element | null;
2
+ export declare function PasswordCheck({ value, variant, requirements, schema, }: PasswordCheckProps): import("react/jsx-runtime").JSX.Element | null;
3
3
  export default PasswordCheck;
@@ -1 +1 @@
1
- "use client";import{jsxs as n,jsx as t}from"react/jsx-runtime";import{cn as r}from"../../utils/cn.js";import{trimEdgeWhitespace as g,checkStrength as x,getProgressText as o}from"./PasswordCheck.utils.js";const f=[{label:"At least 8 characters",test:s=>s.length>=8},{label:"At least one uppercase letter",test:s=>/[A-Z]/.test(s)},{label:"At least one lowercase letter",test:s=>/[a-z]/.test(s)},{label:"At least one digit",test:s=>/\d/.test(s)},{label:"At least one special character",test:s=>/[^A-Za-z0-9]/.test(s)}];function p({value:s,variant:i="default",requirements:d=f}){const c=g(s),e=x(c);return i==="requirements"?s?n("div",{className:"mt-4 flex flex-col gap-2",children:[t("ul",{className:"flex flex-col gap-1 text-xs text-left",children:d.map(a=>{const l=a.test(c);return n("li",{className:"flex items-center gap-1.5",children:[t("svg",{xmlns:"http://www.w3.org/2000/svg",width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",className:r("shrink-0 transition-colors",l?"text-success":"text-fg-subtle"),children:l?t("path",{d:"M20 6 9 17l-5-5"}):t("circle",{cx:"12",cy:"12",r:"4"})}),t("span",{className:r("transition-colors",l?"text-success":"text-fg-muted"),children:a.label})]},a.label)})}),n("div",{className:"flex flex-col",children:[t("div",{className:"w-full flex gap-x-2",children:Array.from({length:5},(a,l)=>l).map((a,l)=>t("div",{className:r("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},a))}),n("div",{className:r("inline-flex gap-x-1 text-xs text-fg-strong transition-opacity mt-1",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:r((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:o(e)})]})]})]}):null:t("div",{children:n("div",{className:r("relative w-full flex gap-x-2",i==="default"&&"mb-8 -mt-1"),children:[Array.from({length:5},(a,l)=>l).map((a,l)=>t("div",{className:r("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},a)),i==="default"&&n("div",{className:r("inline-flex gap-x-1 absolute top-0 left-0 text-xs translate-y-2/4 text-fg-strong",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:r((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:o(e)})]})]})})}export{p as PasswordCheck,p as default};
1
+ "use client";import{jsxs as n,jsx as t}from"react/jsx-runtime";import{cn as a}from"../../utils/cn.js";import{trimEdgeWhitespace as h,checkStrength as p,requirementsFromSchema as b,getProgressText as m}from"./PasswordCheck.utils.js";const w=[{label:"At least 8 characters",test:s=>s.length>=8},{label:"At least one uppercase letter",test:s=>/[A-Z]/.test(s)},{label:"At least one lowercase letter",test:s=>/[a-z]/.test(s)},{label:"At least one digit",test:s=>/\d/.test(s)},{label:"At least one special character",test:s=>/[^A-Za-z0-9]/.test(s)}];function k({value:s,variant:c="default",requirements:x,schema:f}){const o=h(s),e=p(o),d=b(f),u=x??(d.length>0?d:w);return c==="requirements"?s?n("div",{className:"mt-4 flex flex-col gap-2",children:[t("ul",{className:"flex flex-col gap-1 text-xs text-left",children:u.map(r=>{const l=r.test,g=l!==void 0,i=l?l(o):!1;return n("li",{className:"flex items-center gap-1.5",children:[n("svg",{xmlns:"http://www.w3.org/2000/svg",width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",className:a("shrink-0 transition-colors",i?"text-success":"text-fg-subtle"),children:[i&&t("path",{d:"M20 6 9 17l-5-5"}),!i&&g&&t("circle",{cx:"12",cy:"12",r:"4"}),!g&&t("circle",{cx:"12",cy:"12",r:"2.5",fill:"currentColor"})]}),t("span",{className:a("transition-colors",i?"text-success":"text-fg-muted"),children:r.label})]},r.label)})}),n("div",{className:"flex flex-col",children:[t("div",{className:"w-full flex gap-x-2",children:Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r))}),n("div",{className:a("inline-flex gap-x-1 text-xs text-fg-strong transition-opacity mt-1",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:m(e)})]})]})]}):null:t("div",{children:n("div",{className:a("relative w-full flex gap-x-2",c==="default"&&"mb-8 -mt-1"),children:[Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r)),c==="default"&&n("div",{className:a("inline-flex gap-x-1 absolute top-0 left-0 text-xs translate-y-2/4 text-fg-strong",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:m(e)})]})]})})}export{k as PasswordCheck,k as default};
@@ -1,9 +1,53 @@
1
1
  export type PasswordRequirement = {
2
2
  label: string;
3
- test: (value: string) => boolean;
3
+ /**
4
+ * Whether the value satisfies this rule.
5
+ *
6
+ * Optional since CTAP-1908. A rule derived from the backend schema may be one this component
7
+ * cannot evaluate in the browser — an arbitrary regular expression, a rule a module added after
8
+ * this file was written. Such a rule renders as a neutral bullet rather than being dropped:
9
+ * a rule that is enforced but not displayed refuses the user with no explanation, which is the
10
+ * whole defect this work exists to end.
11
+ */
12
+ test?: (value: string) => boolean;
13
+ };
14
+ /**
15
+ * One thing a backend schema says about a value — CTAP-1908.
16
+ *
17
+ * Declared structurally rather than imported from an app's generated types, in keeping with the
18
+ * rest of this library: a codegen `SchemaDescriptionClause` is assignable to it without `ui`
19
+ * depending on any app.
20
+ */
21
+ export type SchemaDescriptionClause = {
22
+ /**
23
+ * A stable machine code (`min_length`, `at_least_one_lowercase`, …). An OPEN vocabulary — a module
24
+ * that owns a constraint coins its own, so never assume this component knows them all.
25
+ */
26
+ code: string;
27
+ /** `rule` — a condition the value must satisfy. `note` — information about it, not a check. */
28
+ kind: string;
29
+ /** The rule in English, ready to render. */
30
+ text: string;
31
+ /** The rule's arguments, e.g. `{ min: 8 }`. Read these; never parse `text`. */
32
+ parameters?: Record<string, unknown> | null;
33
+ };
34
+ /** A validation schema described in English, as served by `passwordSchemaDescription`. */
35
+ export type SchemaDescription = {
36
+ clauses?: readonly (SchemaDescriptionClause | null)[] | null;
4
37
  };
5
38
  export type PasswordCheckProps = {
6
39
  value: string;
7
40
  variant?: 'default' | 'short' | 'requirements';
41
+ /**
42
+ * The rules to show. Overrides everything — a consumer with its own policy keeps full control.
43
+ */
8
44
  requirements?: PasswordRequirement[];
45
+ /**
46
+ * The password policy as the backend describes it (`passwordSchemaDescription`) — CTAP-1908.
47
+ *
48
+ * Pass it and the rule list is derived from the schema that actually enforces the password, so
49
+ * the two cannot drift. Without it the component falls back to {@link defaultRequirements}, which
50
+ * is a static copy and has been wrong before.
51
+ */
52
+ schema?: SchemaDescription | null;
9
53
  };
@@ -1,3 +1,4 @@
1
+ import { PasswordRequirement, SchemaDescription } from './PasswordCheck.types';
1
2
  /**
2
3
  * Strip edge whitespace the way the backend does before it validates a password.
3
4
  *
@@ -22,3 +23,24 @@ export declare const getPasswordRequirements: (value?: string) => {
22
23
  upLowCaseLetters: boolean;
23
24
  oneNumber: boolean;
24
25
  };
26
+ /**
27
+ * The rules a described backend schema states, as things this component can render — CTAP-1908.
28
+ *
29
+ * ## Why this exists
30
+ *
31
+ * `PasswordService::getPasswordSchema()` enforces a minimum length plus uppercase, **lowercase**,
32
+ * number and special-character rules. The hand-written list in this file showed four of them and
33
+ * omitted lowercase, so a user could watch every tick turn green and still be refused, with no
34
+ * explanation, because the rule that rejected them was never displayed. Deriving the list from the
35
+ * schema that enforces it is what stops that happening again.
36
+ *
37
+ * ## Two rules about unrecognised clauses
38
+ *
39
+ * 1. **An unrecognised `code` is still rendered.** It comes back with no `test`, so it shows as a
40
+ * neutral bullet rather than a tick that can never turn green. Dropping it would recreate the
41
+ * exact bug.
42
+ * 2. **A `note` is not rendered.** "Leading and trailing whitespace is removed" is true and worth
43
+ * saying on a configuration screen; under a password field it is noise. The backend marks the
44
+ * difference so this does not have to guess.
45
+ */
46
+ export declare const requirementsFromSchema: (schema: SchemaDescription | null | undefined) => PasswordRequirement[];
@@ -1 +1 @@
1
- const s=t=>t.replace(/^[\s\u0000\u200B\uFEFF]+|[\s\u0000\u200B\uFEFF]+$/g,""),n=t=>{const r=t.length>=8;let e=0;return/^(?=.*[a-z])(?=.*[A-Z])/.test(t)&&r&&(e+=1),/\d/.test(t)&&r&&(e+=1),t.length>=8&&(e+=1),t.length>=11&&(e+=1),/[ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/.test(t)&&r&&(e+=1),e},c=t=>{switch(t){case 5:return"Very Strong";case 4:return"Strong";case 3:return"Good";case 2:return"Weak";case 1:return"Very Weak";default:return""}},g=(t="")=>({eightСharacters:t.length>=8,upLowCaseLetters:/^(?=.*[a-z])(?=.*[A-Z])/.test(t),oneNumber:/\d/.test(t)});export{n as checkStrength,g as getPasswordRequirements,c as getProgressText,s as trimEdgeWhitespace};
1
+ const a=t=>t.replace(/^[\s\u0000\u200B\uFEFF]+|[\s\u0000\u200B\uFEFF]+$/g,""),o=t=>{const e=t.length>=8;let r=0;return/^(?=.*[a-z])(?=.*[A-Z])/.test(t)&&e&&(r+=1),/\d/.test(t)&&e&&(r+=1),t.length>=8&&(r+=1),t.length>=11&&(r+=1),/[ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/.test(t)&&e&&(r+=1),r},c=t=>{switch(t){case 5:return"Very Strong";case 4:return"Strong";case 3:return"Good";case 2:return"Weak";case 1:return"Very Weak";default:return""}},i=(t="")=>({eightСharacters:t.length>=8,upLowCaseLetters:/^(?=.*[a-z])(?=.*[A-Z])/.test(t),oneNumber:/\d/.test(t)}),u=t=>t?.clauses?t.clauses.filter(e=>!!e&&e.kind==="rule").map(e=>({label:e.text,test:s(e)})):[],n=(t,e)=>{const r=t.parameters?.[e];return typeof r=="number"?r:void 0},s=t=>{switch(t.code){case"min_length":{const e=n(t,"min");return e===void 0?void 0:r=>r.length>=e}case"max_length":{const e=n(t,"max");return e===void 0?void 0:r=>r.length<=e}case"exact_length":{const e=n(t,"length");return e===void 0?void 0:r=>r.length===e}case"at_least_one_lowercase":return e=>/[a-z]/.test(e);case"at_least_one_uppercase":return e=>/[A-Z]/.test(e);case"at_least_one_number":return e=>/\d/.test(e);case"at_least_one_special_character":return e=>/[\W_]/.test(e);case"not_blank":return e=>e.trim().length>0;default:return}};export{o as checkStrength,i as getPasswordRequirements,c as getProgressText,u as requirementsFromSchema,a as trimEdgeWhitespace};
@@ -1 +1,3 @@
1
1
  export { PasswordCheck } from './PasswordCheck';
2
+ export { requirementsFromSchema } from './PasswordCheck.utils';
3
+ export type { PasswordCheckProps, PasswordRequirement, SchemaDescription, SchemaDescriptionClause, } from './PasswordCheck.types';
@@ -1,2 +1,2 @@
1
1
  "use client";
2
- import{PasswordCheck as i}from"./PasswordCheck.js";import"react/jsx-runtime";import"../../utils/cn.js";import"./PasswordCheck.utils.js";export{i as PasswordCheck};
2
+ import{PasswordCheck as t}from"./PasswordCheck.js";import{requirementsFromSchema as i}from"./PasswordCheck.utils.js";import"react/jsx-runtime";import"../../utils/cn.js";export{t as PasswordCheck,i as requirementsFromSchema};
@@ -0,0 +1,109 @@
1
+ # `PasswordCheck` renders the rules the server actually enforces
2
+
3
+ **Date:** 2026-09-24
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `libs/frontend-modules/ui/src/components/PasswordCheck/*` — new `schema` prop, new `requirementsFromSchema` export, `PasswordRequirement.test` is now optional
6
+ **Requires:** a middleware serving `passwordSchemaDescription` (CTAP-1908) to use the new prop; without one nothing changes
7
+ **Classification:** IMPROVEMENT
8
+ **Breaking:** No. Every existing call site keeps working, and a consumer passing `requirements` still overrides everything.
9
+ **Ticket:** CTAP-1908
10
+
11
+ ## Problem
12
+
13
+ `PasswordCheck`'s rule list was a hard-coded array in the component:
14
+
15
+ ```tsx
16
+ const defaultRequirements = [
17
+ { label: 'At least 8 characters', ... },
18
+ { label: 'At least one uppercase letter', ... },
19
+ { label: 'At least one digit', ... },
20
+ { label: 'At least one special character', ... },
21
+ ];
22
+ ```
23
+
24
+ The middleware enforces those four **and a lowercase rule**. So a visitor could type `PASSW0RD!`,
25
+ watch all four ticks turn green, submit, and be refused — by a rule that was never on the screen.
26
+ Four hand-written lines against five enforced rules, with nothing to keep them equal.
27
+
28
+ ## Fix
29
+
30
+ ```tsx
31
+ const passwordSchema = usePasswordSchemaDescription(); // passwordSchemaDescription query
32
+
33
+ <PasswordCheck value={value} variant="requirements" schema={passwordSchema} />
34
+ ```
35
+
36
+ The list is now derived from the schema that enforces the password. Five rules where there were
37
+ four, and a sixth rule added on the backend appears with no frontend change.
38
+
39
+ ## Precedence
40
+
41
+ | Given | Rules shown |
42
+ |---|---|
43
+ | `requirements` prop | exactly that list — a consumer with its own policy keeps full control |
44
+ | `schema` with at least one `rule` clause | derived from the schema |
45
+ | neither, or a schema stating no rules | `defaultRequirements`, the static list |
46
+
47
+ The static list stays as the fallback because the component cannot tell "this policy has no rules"
48
+ from "the query has not resolved yet", and a password field showing nothing is worse than one
49
+ showing a stale list. It is a floor, not a claim of completeness — it has been wrong before, which
50
+ is the whole reason `schema` exists.
51
+
52
+ ## Two rules the component follows, and yours should too
53
+
54
+ **An unrecognised clause is rendered, not dropped.** Clause codes are an open vocabulary: a module
55
+ that owns a constraint coins its own. `requirementsFromSchema` returns a rule with no `test` for a
56
+ code it cannot evaluate, and the component shows it with a small filled dot instead of a tick —
57
+ present, never claimed satisfied, never claimed failed. Dropping it would recreate the original bug
58
+ exactly.
59
+
60
+ **Notes are not rules.** The backend marks `kind: 'note'` for things like "Leading and trailing
61
+ whitespace is removed" — true, worth showing on an admin screen, noise under a password field. The
62
+ component skips them without needing to know what they say.
63
+
64
+ ## API
65
+
66
+ ```ts
67
+ type PasswordRequirement = {
68
+ label: string;
69
+ test?: (value: string) => boolean; // OPTIONAL since this change
70
+ };
71
+
72
+ requirementsFromSchema(schema): PasswordRequirement[]
73
+ ```
74
+
75
+ `test` became optional so an unevaluable rule can still be listed. Every existing consumer supplies
76
+ it, so this is a widening.
77
+
78
+ `PasswordCheckProps`, `PasswordRequirement`, `SchemaDescription` and `SchemaDescriptionClause` are
79
+ now exported from `@clicktap/ui/components/PasswordCheck` — they were not, so a consumer building a
80
+ rule list from server data could not type it.
81
+
82
+ `SchemaDescription` is declared structurally in this library, mirroring the GraphQL shape, so a
83
+ codegen type is assignable to it without `ui` depending on any app.
84
+
85
+ ## ⚠️ The strength meter still measures the old policy
86
+
87
+ `checkStrength()` is independent of `requirements` and always was: it hard-codes 8/11 characters,
88
+ mixed case, digit and special. Override the rule list or pass a schema and the five-bar meter keeps
89
+ scoring the built-in policy. That was true before this change and is unchanged by it —
90
+ deriving the meter from the schema needs its own ticket.
91
+
92
+ ## Consumer checklist
93
+
94
+ - **Nothing to do** if you are happy with the static list.
95
+ - **To adopt:** take a middleware serving `passwordSchemaDescription`, add the query, and pass the
96
+ result as `schema` at every `PasswordCheck` call site. In this monorepo that is three components,
97
+ four call sites: `Signup`, `ResetPassword` and `UpdatePasswordForm` (twice).
98
+ - **If your `*-ui` fork copied `defaultRequirements`,** it is almost certainly missing the lowercase
99
+ rule too. Copy `PasswordCheck.utils.ts`'s `requirementsFromSchema` and the `schema` prop rather
100
+ than adding the one missing line.
101
+
102
+ ## Verification
103
+
104
+ `libs/frontend-modules/ui/src/components/PasswordCheck/PasswordCheck.spec.tsx` — 9 tests. The
105
+ load-bearing two assert five rules from the real `passwordSchemaDescription` payload and the
106
+ presence of the lowercase rule by name. Others pin that only satisfied rules tick, that `min` is
107
+ read from the clause parameters rather than assumed to be 8, that an unrecognised code is rendered
108
+ but never ticked, that notes are not rendered, that `requirements` still overrides, and both
109
+ fallback paths.
@@ -9,6 +9,16 @@
9
9
  "breaking": "No API change, and no prop, export or class-name contract moves. **Visual:** 76 of the 118 swaps resolve to the exact same light-theme value and render identically. The other 42 do not — they are listed component by component under \"Rendering changes\" and are all one step along a neutral ramp, except `UploadImage`, whose hover background **appears for the first time** because the class it had was a typo.",
10
10
  "affects": "54 files under `src/components/**` — every component listed in \"What changed\" below — plus the new root-level `eslint.rules.no-literal-palette-color.mjs` and the two eslint configs that reference it."
11
11
  },
12
+ {
13
+ "path": "2026/09/ui-2026-09-24-password-check-renders-from-the-schema.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-24",
17
+ "title": "`PasswordCheck` renders the rules the server actually enforces",
18
+ "classification": "IMPROVEMENT",
19
+ "breaking": "No. Every existing call site keeps working, and a consumer passing `requirements` still overrides everything.",
20
+ "affects": "`libs/frontend-modules/ui/src/components/PasswordCheck/*` — new `schema` prop, new `requirementsFromSchema` export, `PasswordRequirement.test` is now optional"
21
+ },
12
22
  {
13
23
  "path": "2026/09/ui-2026-09-24-password-check-lowercase-and-trim.md",
14
24
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.3.4",
3
+ "version": "2.4.1",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",
@@ -107,8 +107,10 @@
107
107
  "@nx/react": "20.5.0",
108
108
  "@nx/vite": "20.5.0",
109
109
  "@storybook/addon-actions": "^8.6.18",
110
- "@storybook/react": "7.4.5",
110
+ "@storybook/react": "^8.6.18",
111
111
  "@storybook/react-webpack5": "^8.6.18",
112
+ "@tailwindcss/postcss": "^4.3.3",
113
+ "@tailwindcss/typography": "0.5.19",
112
114
  "@testing-library/react": "15.0.6",
113
115
  "@vitejs/plugin-react": "4.5.2",
114
116
  "framer-motion": "^11.18.2",
@@ -124,6 +126,7 @@
124
126
  "react-stately": "3.46.0",
125
127
  "rollup-preserve-directives": "^1.1.3",
126
128
  "tailwindcss": "^4.3.3",
129
+ "tailwindcss-react-aria-components": "1.2.0",
127
130
  "vite": "^7.3.6",
128
131
  "vite-plugin-dts": "4.5.4",
129
132
  "vite-plugin-static-copy": "^3.4.0",