@clicktap/ui 2.3.4 → 2.4.0
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.
- package/components/PasswordCheck/PasswordCheck.d.ts +1 -1
- package/components/PasswordCheck/PasswordCheck.js +1 -1
- package/components/PasswordCheck/PasswordCheck.types.d.ts +45 -1
- package/components/PasswordCheck/PasswordCheck.utils.d.ts +22 -0
- package/components/PasswordCheck/PasswordCheck.utils.js +1 -1
- package/components/PasswordCheck/index.d.ts +2 -0
- package/components/PasswordCheck/index.js +1 -1
- package/docs/migrations/2026/09/ui-2026-09-24-password-check-renders-from-the-schema.md +109 -0
- package/docs/migrations/index.json +10 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,2 +1,2 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import{PasswordCheck as i}from"./PasswordCheck.js";import"react/jsx-runtime";import"../../utils/cn.js";
|
|
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",
|