@micha.bigler/ui-core-micha 2.26.4 → 2.28.2
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/DESIGN.md +2 -1
- package/WORK_ORDERS.md +5 -0
- package/dist/components/PasswordChangeForm.js +14 -4
- package/dist/components/PasswordSetForm.js +10 -2
- package/dist/components/charts/TimeSeriesChart.js +44 -4
- package/dist/i18n/authTranslations.js +93 -0
- package/dist/i18n/notificationsTranslations.js +8 -0
- package/dist/notifications/NotificationSettings.js +48 -3
- package/dist/notifications/api.js +5 -0
- package/package.json +1 -1
- package/src/components/PasswordChangeForm.jsx +26 -1
- package/src/components/PasswordSetForm.jsx +18 -1
- package/src/components/charts/TimeSeriesChart.jsx +47 -5
- package/src/i18n/authTranslations.ts +94 -0
- package/src/i18n/notificationsTranslations.ts +8 -0
- package/src/notifications/NotificationSettings.jsx +95 -0
- package/src/notifications/api.js +6 -0
- package/tests/NotificationSettings.test.jsx +105 -0
- package/tests/PasswordChangeForm.test.jsx +70 -0
- package/tests/PasswordSetForm.test.jsx +70 -0
- package/tests/TimeSeriesChart.test.jsx +81 -9
- package/tests/authErrorCodeRendering.test.jsx +77 -0
- package/tests/authTranslations.test.js +52 -0
- package/work-orders/AUTH-1.md +74 -0
- package/work-orders/AUTH-2.md +74 -0
- package/work-orders/CHART-3.md +163 -0
- package/work-orders/CHART-4.md +203 -0
package/DESIGN.md
CHANGED
|
@@ -24,7 +24,7 @@ These principles are the spec a **prototype** encodes and the implementation mat
|
|
|
24
24
|
|
|
25
25
|
## Craft
|
|
26
26
|
|
|
27
|
-
10. **Prototype = spec, parity = guardrail.** Purely visual/UX — function stays
|
|
27
|
+
10. **Prototype = spec, parity = guardrail — both directions, within a DECLARED coverage.** Purely visual/UX — function stays; **"No behaviour / permission / data-contract change"** is the hard condition in the WO. Prototypes are routinely **partial** (tabs undrawn, flows stubbed), and nobody downstream can tell a deliberate omission from a required removal — so **prototype silence never authorizes a removal; the Envelope's "Replaces / removes" list does.** Every prototype therefore **declares what it covers and what it leaves untouched**. Inside the covered area each omission is resolved at authoring time into *removes* or *deliberately keeps*, leaving nothing ambiguous; outside it the prototype says nothing. Parity is then checked both ways **within that coverage**: everything drawn is present, and nothing listed as removed survives. **A redesign that ends up net-additive has failed**, however good its new parts — shipping MORE UI than before is the estate's recurring cleanup failure (status bands, duplicated badges, tip text layered on top of the very legacy the redesign was meant to replace).
|
|
28
28
|
11. **Align to the shipped reality.** When related features land, pull the prototype onto them so it is a truthful spec, not a contradicting vision.
|
|
29
29
|
12. **Avoid the generic "AI look"; take details seriously.** No cream / serif / neon default; embed the real font (CSP-safe as a data-URI, not a silent-fallback risk); `tabular-nums` for aligned numbers; a11y (role/aria on chart regions, focusable info). **Template tells to CUT** (unless the app's design system genuinely specifies them): monospace-uppercase **eyebrow / kicker labels** (e.g. `IMARA · ADVANCING RESILIENT HEALTH SYSTEMS`), decorative **pills / badges**, `FIG. 01 ·` / `F.01` **figure captions**, `↳`-prefixed **micro-links**, **oversized display** headlines, **sketchy / hand-drawn** styling, and **explanatory paragraphs that restate the UI** (help is on-demand — see #6). Restraint is the default; each of these is added only with a real reason.
|
|
30
30
|
|
|
@@ -35,3 +35,4 @@ For the **design craft** of a prototype (an HTML artifact) and its charts, load
|
|
|
35
35
|
13. **Spacing / density = tool density, not document width.** A defined spacing rhythm, dense enough to scan and operate — not airy like a reading document. Use the theme's spacing scale; don't hand-pick pixel gaps.
|
|
36
36
|
14. **Responsive = container-sized, not fixed px.** Layouts and charts size to their container; define breakpoint behaviour deliberately (what stacks, what hides, what scrolls) — the same container-responsive rule the charts already follow.
|
|
37
37
|
15. **Motion = restrained and functional.** Transitions serve orientation/feedback (state change, reveal), never decoration. Short, few, purposeful; respect `prefers-reduced-motion`.
|
|
38
|
+
16. **Prefer icons for secondary actions where the meaning is conventional.** A dense action row reads better as one labelled primary action plus **icon buttons** (preview, download, edit, delete, overflow) than as a row of text buttons — and a labelled `Button` is simply the cheapest thing to type, so implementations drift toward text-heavy rows the prototype never had. Conditions: use an **established glyph** (never invented iconography); always pair it with a **`Tooltip` AND an `aria-label`**, since an icon-only control otherwise has no accessible name; and **keep the text label** when the action is primary, destructive without a confirm step, or app-specific enough that no conventional icon exists. A tooltip may NAME an action — if it has to explain a concept, the control needs a label, or on-demand help (#6).
|
package/WORK_ORDERS.md
CHANGED
|
@@ -14,6 +14,7 @@ rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
|
|
|
14
14
|
| `CHART-*` | Shared chart / data-viz kit (`ChartFrame` + MUI X-Charts presets + neutral palette/formatters) |
|
|
15
15
|
| `MSG-*` | Shared messaging platform, Phase B (dcm register is canonical; this repo's rows are its ucm-side WOs) |
|
|
16
16
|
| `DX-*` | Developer experience / repo tooling (ucm-local prefix; unrelated to `django-core-micha`'s own `DX-*`) |
|
|
17
|
+
| `AUTH-*` | Auth components UX (login/password/MFA/profile forms, auth error i18n) |
|
|
17
18
|
|
|
18
19
|
Introduce a new prefix when none fits and add it here. New WOs always get a
|
|
19
20
|
prefixed ID; never reuse a bare flat number across workstreams.
|
|
@@ -22,6 +23,8 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
22
23
|
|
|
23
24
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
24
25
|
|---|---|---|---|---|---|---|
|
|
26
|
+
| CHART-4 | Fix `TimeSeriesChart` chart canvas rendering at zero height | Neither `ChartFrame` nor `BarChart` ever receive a height from `TimeSeriesChart` — MUI X-Charts' responsive container measures a zero-height parent and draws no bars/axes, while the legend (sized by its own content) renders regardless, which is why the live symptom was "legend shows, chart canvas doesn't". Fix supplies a preset default height (`CHART_HEIGHT = 320`, `minHeight` on `ChartFrame` + `height` forwarded to the real `MuiBarChart`), matching the working `dev/entries.jsx` reference pattern. Full spec: [work-orders/CHART-4.md](work-orders/CHART-4.md). | 2026-08-07 | done | f0953a1, fcb289b | Second, independent bug found live in jg-ferien immediately after CHART-3 + the ucm pin bump landed — CHART-3's own fix is what let this one become visible for the first time (it was previously masked by the permanently-empty-chart bug). Root cause of it shipping twice undetected: every test in `tests/TimeSeriesChart.test.jsx`/`tests/BarChart.test.jsx` mocks the real `@mui/x-charts/BarChart`, so no test has ever mounted the real chart and observed its size. **Codex out of credits, known up front** — implemented directly (author-flip, independent review mandatory). **Test-writing pitfall found and self-corrected during implementation**: first wrote an *unmocked* render test asserting the real chart SVG's `viewBox` height was non-zero — verified vacuous (stays green even fully reverting the fix, since jsdom's `ResizeObserver`/layout handling doesn't reproduce the real zero-height-parent browser behavior this bug depends on). Deleted; replaced with a `chartSpy`-mocked prop assertion (`props.height` non-zero) matching the file's existing convention, confirmed non-vacuous (fails pre-fix with `props.height` undefined). Independent `reviewer` + `ui_reviewer` ran concurrently: `reviewer` verified the fix mechanism end-to-end (no prop-name collision between `BarChart`'s own `minHeight`/`aspect` and the forwarded `height`) and the test's non-vacuity claim, no findings. `ui_reviewer`: two P3 informational notes, neither blocking — (U1) the fixed 320px height is a deliberate, documented exception to `DESIGN.md` #14 ("responsive = container-sized, not fixed px"), needed specifically because MUI X-Charts' own `ResizeObserver`-based height measurement is what caused CHART-4 in the first place; a code comment was added warning future readers not to "fix" this back to a responsive height without re-verifying the underlying MUI bug is resolved. (U2) `CHART_HEIGHT` uses 320 for both `ChartFrame.minHeight` and `BarChart.height`, vs. `dev/entries.jsx`'s 360/320 split — functionally harmless (WO explicitly permits one fixed value), no change made. 13/13 scoped vitest tests green, `tsc` build clean. Published: ucm 2.28.2 (patch, no interface change). No app pin bumps in this WO — jg-ferien picks it up on its next ucm bump. |
|
|
27
|
+
| CHART-3 | Fix `TimeSeriesChart` rendering nothing on async data load | `visibleKeys` was a lazy `useState` initialized once from `data` at first mount; any host that fetches asynchronously after mount (the normal case, e.g. jg-ferien's `ActivitySection`) ends up with a permanently-empty `visibleKeys`, so `isDataEmpty` stays `true` forever and the chart body never renders even once real data arrives. Fix syncs newly-seen series keys to visible-by-default while preserving a user's manual toggle-off across data updates. Full spec: [work-orders/CHART-3.md](work-orders/CHART-3.md). | 2026-08-06 | done | 9a1bd9d, 48cefa8 | Found live in jg-ferien's Aktivität screen (ACT-3 consumer) — toolbar rendered, chart body stayed on `ChartFrame`'s empty state permanently. **Codex was out of workspace credits** (genuine quota error on invocation, no partial work) — orchestrator implemented directly per the Codex→Claude fallback rule; independence flip applies (author = orchestrator, so independent review was mandatory, not self-check). Independent `reviewer` + `ui_reviewer` both ran (background, concurrent): `ui_reviewer` found no findings; `reviewer` found R1 (P2, real bug — the seen-keys tracker was a `useRef` mutated during the render body alongside a paired `setVisibleKeys` call; under React concurrent rendering an interrupted/discarded render leaves the ref mutation applied while the paired state update is discarded, permanently hiding a key without it ever reaching `visibleKeys` — a different-shaped recurrence of the exact bug this WO exists to fix) and R2 (P3, test-file duplication — a second near-duplicate render helper). Both fixed: seen-keys tracking moved from a ref to a second `useState` (both states updated together in the render body, so a discarded render rolls both back together — React's documented pattern for this), and the duplicate test helper collapsed into the existing `renderChart`'s `rerenderWith`. Re-verified: 12/12 scoped vitest tests green (3 new CHART-3 regression tests, 2 of which confirmed non-vacuous by failing against the pre-fix code), `tsc` build clean. Published: ucm 2.28.1 (patch, no interface change). No app pin bumps in this WO — jg-ferien picks it up on its next ucm bump. |
|
|
25
28
|
| ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
|
|
26
29
|
| PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
|
|
27
30
|
| CHART-1 | Shared chart kit: themed `ChartFrame` + MUI X-Charts Bar/Line presets | Typed-agnostic `ChartFrame` (Paper + title + toolbar slot + loading/error/empty + responsive container + SVG/PNG export + a11y) wrapping any chart body via `children`; thin `BarChart`/`LineChart` presets over MUI X-Charts baking the governance chart contract (both-axis labels, legend-when-multiseries, tooltip, theme-token colours, responsive); neutral theme-aware palette + locale-aware formatters. Additive exports; publishes ucm 2.15.0. | 2026-07-30 | done | f1b3614 | Envelope authored by Expertenchat; `work-orders/CHART-1.md`. Operator approved the `@mui/x-charts` peerDependency 2026-07-30 (in-chat) before Codex ran. Codex-built direct (no nesting). Independent `reviewer` (background, author=Codex ≠ reviewer): found R1 (P1, real bug — 3 of 4 WO-required tests failed on actual execution: no RTL `cleanup()` between `render()` calls left stale DOM across the `ChartFrame` state-precedence/empty tests, and the `BarChart` responsive-sizing assertion called `.not.toContain(null)` on a `getAttribute('style')` that MUI's `sx` never sets inline — an invalid matcher call), R2 (P2 — `BarChart`/`LineChart` passed an invalid `tooltip={{trigger:'axis'}}` prop that isn't part of either component's real API per installed `@mui/x-charts@8.29.2` types; silently worked only via the library's own default, and the mocked tests couldn't catch it), R3 (P2 — `ChartFrame`'s chart region set both `aria-label` and `aria-labelledby`; per ARIA accessible-name computation `aria-labelledby` wins, so a caller-supplied `ariaLabel` override was silently discarded by assistive tech), R4 (P3 — the PNG export control was never actually clicked in the export test, only checked for presence). All four fixed by the orchestrator directly (in-scope reviewer-finding fixes, not a re-implementation): added `afterEach(cleanup)` to the three render-based test files, fixed the responsive-sizing assertion to tolerate `null`/empty inline style, moved tooltip config to the real `slotProps={{tooltip:{trigger:'axis'}}}` API (tests updated to match), suppressed `aria-labelledby` whenever a custom `ariaLabel` is supplied (with a new regression test), and added a PNG-export regression test asserting the callback fires. Re-verified: 16/16 scoped vitest tests green, `pnpm`/tsc build clean. hram adoption (pin bump + ~20 Results/Research panel migration) is a gated follow-on WO in the hram repo, gated on 2.15.0 published (publish-from-main CI on this push). SVG specialists (scatter/tornado/heatmap/CI/frontier) get frame+export in adoption but keep raw-SVG bodies — retiring raw SVG is a tracked future CHART-2. |
|
|
@@ -46,3 +49,5 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
46
49
|
| SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | done | 8ef8cbf (WO), bf4cdd1 (impl) | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: **Profil goes from one tap to two in every app** (paid consciously — the price of the consolidation). hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E (dcm MSG-13 preview `Switch`) had already landed separately as NOTIF-14 (`72e6697`) before this WO started, so A-D ship alongside it in the same release. Host header rewires (spesix/survey_app/jg/hram) are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar` — not done here. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (same failure as NOTIF-14) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (WO's TIER line makes both mandatory). **Decisions stated per WO's explicit requirement** (both reviewers flagged these as initially undocumented, now recorded here): scope D icons — **no entry carries a leading or trailing icon** (uniform with none, avoids an unstated icon contract for host items); Profil/Abmelden i18n — **new `UserMenu.PROFILE`/`UserMenu.LOGOUT` keys** in a fresh `src/i18n/userMenuTranslations.ts`, not a reuse of `Account.TAB_PROFILE` (different UI surface, keeps the component independently consumable). New component `src/auth/UserMenu.jsx` mirrors `NotificationBell({ resolveLink })`'s exact anchor/Menu/onClose shape — no custom keyboard/focus-trap code, relies entirely on MUI `Menu`'s native Escape/click-outside/focus-return. Gate: `tests/UserMenu.test.jsx` (new, 6/6 passed, all WO-required cases incl. non-vacuous ordering + logout-stub assertions) + affected-areas set `tests/AuthContext.test.jsx` + `tests/NotificationSettings.test.jsx` (untouched by this diff, included per WO's test-scope note since scope E is in the same release) → 18/18 passed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Both reviewers' only findings were the now-resolved documentation gaps above — no code-correctness findings from either. **HOTFIX 2026-08-04 (`b5b6abd`, published 2.26.1)**: operator screenshot feedback — two-letter avatar initials looked cramped in the 32px circle (MUI's default `Avatar` font-size doesn't auto-scale for a smaller width/height override). Added `fontSize: '0.8rem'`. Independent `reviewer`: no findings. **HOTFIX 2026-08-04 (`769f55d`, published 2.26.2)**: operator screenshot from hram — identity block showed the same email twice (display name falls back to `username` when no first/last name; on allauth email-login hosts `username === email`). The email line now only renders when it differs from the shown display name. Independent `reviewer`: no findings. |
|
|
47
50
|
| CHART-2 | Time-series chart preset with range picker and series toggles | Extends CHART-1, whose `ChartFrame` already ships a **toolbar slot that no consumer uses yet**. Operator request 2026-08-04 from jg's activity chart, generalised: every app charting a time series wants a duration picker and per-series toggles, and `django-core-micha` ACT-1 will make activity data available to all of them. jg's `ActivitySection.jsx:9` currently imports `@mui/x-charts` directly and hand-rolls its own Paper/loading/error/empty. | 2026-08-04 | done | a277c82, abf11b0 | Envelope: [work-orders/CHART-2.md](work-orders/CHART-2.md). **Presentational only — the host fetches**; the component takes data plus callbacks and calls no endpoint. **The half most likely to be dropped: the range picker must emit a GRANULARITY alongside the window** — new `TimeSeriesChart` component emits `onRangeChange(rangeKey, granularity)` per the fixed table (1d→hour, 1w→4hour, 1m→day, 1y→month), test 1 asserts the exact emitted payload, non-vacuity confirmed by manually removing the granularity arg and observing the test fail. **Series toggles implemented as `Checkbox`+`FormControlLabel`** (this repo's established multi-choice pattern, matching `PollCard.jsx`); range picker as `RadioGroup`/`Radio`/`FormControlLabel` (same repo's deliberate single-choice convention, chosen over `ToggleButtonGroup` for which this repo has zero precedent). **Second y-axis: explicitly NOT added** — series toggles are the chosen fix (stated in the component's own JSDoc), not silently absent. **Color/identity stability**: each series' color is keyed to its original (unfiltered) index, not its position after toggling others off — verified independently by `reviewer`, prevents a toggle from shifting a *different* series' color. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (mandatory per WO tier), **both clean, no findings**. `ui_reviewer` confirmed accessible labels (series name text, not color alone), native MUI keyboard nav untouched, all 4 languages present for new i18n keys, host-supplied series labels never passed through `t()`. Gate: 9/9 new `TimeSeriesChart` tests + 25/25 across the full chart-component test set (`ChartFrame`/`BarChart`/`LineChart`/`chartsPalette`/`TimeSeriesChart`), `tsc --noEmit` clean. `ChartFrame`/`BarChart`/`LineChart`/`palette.js` confirmed untouched. |
|
|
48
51
|
| MSG-6j | Poll option label collides with its vote-count/percent text at zero gap | Operator screenshots from jg-ferien production: `PollCard.jsx` renders each option's label and result text in a `Stack justifyContent="space-between"` with no `spacing`, inside a `MessageBubble` that is `width: fit-content` — since the bubble's own width is derived from this exact content, there is never leftover space for space-between to distribute, so label and vote-count/percent text end up flush against each other. Percentage math itself confirmed correct (operator's second screenshot showed the right numbers once both votes had landed) -- layout only. | 2026-08-04 | done | e47e243, c480bce | Envelope: [work-orders/MSG-6j.md](work-orders/MSG-6j.md). Two additive fixes: `spacing={1}` on the label/result row so space-between can never collapse to zero gap; `minWidth: 260` on the poll's `Paper`, reconciled against `MessageBubble`'s `maxWidth: min(75%, 680px)` ceiling at a 375px viewport (75% = 281px there). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. **One finding addressed before commit**: a speculative `noWrap` added to the result-text `Typography` (not one of the WO's two prescribed fixes) risked silently ellipsizing the vote-count/percent text on a narrow squeeze instead of wrapping — worse than the reported bug for a sighted user, since the count would become invisible rather than merely tight. Reverted. **Disclosed, not fixed (P3, WO only required 375px verification)**: `minWidth: 260` can still exceed `MessageBubble`'s ceiling below ~347px viewport width (e.g. a 320px device) — a residual squeeze risk on narrower-than-tested devices. Live browser verification at 375px was not performed this round (port conflict with a concurrent session's dev server, unresolvable from this session) — relied on `getComputedStyle`-based regression tests instead, non-vacuity proven by temporarily removing the `spacing` fix and confirming the test fails. Gate: `tests/messagingPollRendering.test.jsx` 7/7 (2 new); full suite 278/279 (the one failure, `NotificationsProvider.test.jsx`, is an unrelated WebSocket-mock timing flake under full-suite load, confirmed passing 9/9 in isolation); `tsc -p tsconfig.build.json --noEmit` clean. Published as npm 2.26.3 (confirmed via `gh run list` on the publish workflow). **jg-ferien's ucm pin bump to 2.26.3 is a separate, not-yet-done step.** |
|
|
52
|
+
| AUTH-1 | Password forms: state the rules (helperText + inline validator) | Spesix field report 2026-08-06: the invitation set-password screen rejects first-time users without ever stating the rules. Add `helperText` (default `Auth.PASSWORD_RULES_HINT`, optional `passwordRulesHint` prop override) on the new-password field of **PasswordSetForm + PasswordChangeForm** (NOT `PasswordResetRequestForm` — email-only, report listed it by mistake) + an inline validator for the two cheap/faithful rules (min-8, not-all-numeric) via the existing `localErrorKey` path (signal before the backend rejects). Baseline = dcm's 4 default validators. ucm-only, **zero per-app**. Full spec: [work-orders/AUTH-1.md](work-orders/AUTH-1.md). | 2026-08-06 | done | 84387cf, 103a252 | Tier 2 (shared-core). **Codex → Claude fallback**: re-confirmed out of workspace credits three times on 2026-08-06 alone (see `project_codex_quota_status` memory) before this WO started, so implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit. `PasswordChangeForm.jsx` had no `localErrorKey` mechanism at all before this WO (only `PasswordSetForm.jsx` did) — added it from scratch, matching the existing pattern exactly (same `<Box sx={{color:'error.main'}}>`, same check ordering). **`ui_reviewer`: no findings** (helperText/error-box conventions match, translation coverage 316/316 keys × 4 locales, hint text checked against dcm's actual `AUTH_PASSWORD_VALIDATORS`, no missing DX-1 harness entry since none existed before either). **`reviewer`: one P2 fixed before commit** — the inline min-8/not-all-numeric rule was hard-coded with no escape hatch; since `AUTH_PASSWORD_VALIDATORS` is a per-app Django setting this shared-core repo doesn't control, an app running a looser backend policy would have valid passwords rejected client-side on its next ucm pin-bump. Fixed by adding optional `minPasswordLength` (default 8) / `allowNumericPassword` (default false) props to both forms, with regression tests; all other consumers keep today's behaviour unchanged (identical defaults). Two P3s from `reviewer` disclosed, not fixed (both pre-existing/out-of-scope): the WO's own rationale text overstated that `PasswordChangeForm` already had the local-error pattern (it didn't; corrected here); neither form clears its local error on the next keystroke after a rejection (pre-existing `PasswordSetForm` behaviour, now also present in `PasswordChangeForm`). Gate: 32/32 new tests across 4 files; full suite 322/322, `tsc` clean. Published as npm 2.28.0 (bundled with AUTH-2 in the same release). Consumers adopt on their own pin-bump (Spesix pinned at 2.5.0 = separate later bump, unaffected today). Companion: AUTH-2. |
|
|
53
|
+
| AUTH-2 | Translate backend auth error codes (stop leaking raw codes) | ucm surfaces raw backend/allauth error **codes**, not text: `apiClient.jsx:107` extracts `err.code`, components do `t(err.code)`/`setErrorKey(err.code)`, but the catalogue has no entries → users see literal `email_password_mismatch` / `password_too_short`. Affects **all** rendering auth screens (login/change-pw/MFA/profile). Fix = add de/fr/en i18n entries **keyed by the enumerated codes** → the existing `t(err.code)` resolves, **zero component/app change**. Does NOT reach the delegating invitation `PasswordSetForm` (that's AUTH-1). Full spec: [work-orders/AUTH-2.md](work-orders/AUTH-2.md). | 2026-08-06 | done | 84387cf, 103a252 | Tier 2 (shared-core). Same Codex→Claude fallback and reviewer pairing as AUTH-1 (one combined diff, both WOs landed together). **Keying decided: raw-code (drop-in)**, per the WO's own stated preference — no operator request for the namespaced `i18nKey` alternative. **Codes enumerated by reading the installed allauth/Django source directly** (not guessed): `email_password_mismatch`/`username_password_mismatch`/`phone_password_mismatch` (`allauth/headless/account/inputs.py` `LoginInput.clean`), `too_many_login_attempts`/`invalid_login`/`account_inactive`/`enter_current_password` (`allauth/account/adapter.py` error_messages + `allauth/headless/account/inputs.py` `ChangePasswordInput.clean_current_password` — confirmed this genuinely means "wrong current password", not merely "field left blank"), `incorrect_code` (`allauth/mfa/**`), `password_too_short`/`password_too_common`/`password_entirely_numeric`/`password_too_similar` (Django `contrib/auth/password_validation.py`, matching dcm's `AUTH_PASSWORD_VALIDATORS` baseline). Added de/fr/en/**sw** (file's established 4-locale convention, honoured over the WO text's literal "de/fr/en"). **`ui_reviewer` + `reviewer`: no AUTH-2-specific findings** (both spot-checked the code spellings independently against the same installed packages; no key collisions with existing `Auth.*` keys; `authErrorCodeRendering.test.jsx` proves a mapped code renders human text via a real i18next instance and a genuinely-uncoded error still falls back to the existing generic `Auth.PASSWORD_CHANGE_FAILED`). Gate: shared with AUTH-1 — full suite 322/322, `tsc` clean. Published as npm 2.28.0. Companion: AUTH-1. |
|
|
@@ -6,22 +6,32 @@ import { useTranslation } from 'react-i18next';
|
|
|
6
6
|
* A simplified form to handle password changes.
|
|
7
7
|
* Does not require password confirmation.
|
|
8
8
|
*/
|
|
9
|
-
export function PasswordChangeForm({ onSubmit, submitting = false }) {
|
|
9
|
+
export function PasswordChangeForm({ onSubmit, submitting = false, passwordRulesHint, minPasswordLength = 8, allowNumericPassword = false, }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
12
12
|
const [newPassword, setNewPassword] = useState('');
|
|
13
|
+
const [localErrorKey, setLocalErrorKey] = useState(null);
|
|
13
14
|
const handleSubmit = async (e) => {
|
|
14
15
|
e.preventDefault();
|
|
15
16
|
if (!onSubmit)
|
|
16
17
|
return;
|
|
18
|
+
setLocalErrorKey(null);
|
|
19
|
+
if (minPasswordLength > 0 && newPassword.length < minPasswordLength) {
|
|
20
|
+
setLocalErrorKey('Auth.PASSWORD_TOO_SHORT_LOCAL');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!allowNumericPassword && /^\d+$/.test(newPassword)) {
|
|
24
|
+
setLocalErrorKey('Auth.PASSWORD_NUMERIC_LOCAL');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
17
27
|
// Parent steuert Loading/Errors, hier nur Aufruf
|
|
18
28
|
await onSubmit(currentPassword, newPassword);
|
|
19
29
|
// Optional: Felder zurücksetzen bei Erfolg (macht dein Parent aktuell so)
|
|
20
30
|
setCurrentPassword('');
|
|
21
31
|
setNewPassword('');
|
|
22
32
|
};
|
|
23
|
-
return (
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
return (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { maxWidth: 500 }, children: [localErrorKey && (_jsx(Box, { sx: { color: 'error.main', fontSize: 14, mb: 2 }, children: t(localErrorKey) })), _jsxs(Stack, { spacing: 2, children: [_jsx(TextField, { label: t('Auth.CURRENT_PASSWORD_LABEL'), type: "password", value: currentPassword, onChange: (e) => setCurrentPassword(e.target.value), required: true, fullWidth: true, disabled: submitting }), _jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL'), type: "password", value: newPassword, onChange: (e) => setNewPassword(e.target.value), required: true, fullWidth: true, disabled: submitting, helperText: passwordRulesHint || t('Auth.PASSWORD_RULES_HINT') }), _jsx(Box, { children: _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
|
|
34
|
+
? t('Auth.CHANGE_PASSWORD_BUTTON_LOADING')
|
|
35
|
+
: t('Auth.CHANGE_PASSWORD_BUTTON') }) })] })] }));
|
|
26
36
|
}
|
|
27
37
|
;
|
|
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
* Simple form to set a new password (once, with confirmation).
|
|
8
8
|
* Caller passes onSubmit(newPassword) and handles redirect / API call.
|
|
9
9
|
*/
|
|
10
|
-
export function PasswordSetForm({ onSubmit, submitting = false }) {
|
|
10
|
+
export function PasswordSetForm({ onSubmit, submitting = false, passwordRulesHint, minPasswordLength = 8, allowNumericPassword = false, }) {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
12
|
const [password1, setPassword1] = useState('');
|
|
13
13
|
const [password2, setPassword2] = useState('');
|
|
@@ -23,11 +23,19 @@ export function PasswordSetForm({ onSubmit, submitting = false }) {
|
|
|
23
23
|
setLocalErrorKey('Auth.PASSWORD_SET_LOCAL_MISMATCH');
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
|
+
if (minPasswordLength > 0 && password1.length < minPasswordLength) {
|
|
27
|
+
setLocalErrorKey('Auth.PASSWORD_TOO_SHORT_LOCAL');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!allowNumericPassword && /^\d+$/.test(password1)) {
|
|
31
|
+
setLocalErrorKey('Auth.PASSWORD_NUMERIC_LOCAL');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
26
34
|
if (onSubmit) {
|
|
27
35
|
onSubmit(password1);
|
|
28
36
|
}
|
|
29
37
|
};
|
|
30
|
-
return (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }, children: [localErrorKey && (_jsx(Box, { sx: { color: 'error.main', fontSize: 14 }, children: t(localErrorKey) })), _jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL'), type: "password", fullWidth: true, autoComplete: "new-password", value: password1, onChange: (e) => setPassword1(e.target.value), disabled: submitting }), _jsx(TextField, { label: t('Auth.PASSWORD_CONFIRM_LABEL'), type: "password", fullWidth: true, autoComplete: "new-password", value: password2, onChange: (e) => setPassword2(e.target.value), disabled: submitting }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
|
|
38
|
+
return (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }, children: [localErrorKey && (_jsx(Box, { sx: { color: 'error.main', fontSize: 14 }, children: t(localErrorKey) })), _jsx(TextField, { label: t('Auth.NEW_PASSWORD_LABEL'), type: "password", fullWidth: true, autoComplete: "new-password", value: password1, onChange: (e) => setPassword1(e.target.value), disabled: submitting, helperText: passwordRulesHint || t('Auth.PASSWORD_RULES_HINT') }), _jsx(TextField, { label: t('Auth.PASSWORD_CONFIRM_LABEL'), type: "password", fullWidth: true, autoComplete: "new-password", value: password2, onChange: (e) => setPassword2(e.target.value), disabled: submitting }), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting
|
|
31
39
|
? t('Auth.PASSWORD_SET_BUTTON_LOADING')
|
|
32
40
|
: t('Auth.PASSWORD_SET_BUTTON') })] }));
|
|
33
41
|
}
|
|
@@ -8,6 +8,22 @@ import { useNeutralChartPalette } from './palette';
|
|
|
8
8
|
// Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
|
|
9
9
|
// dcm ACT-1) — the resolution follows the window and is never an independent
|
|
10
10
|
// caller-supplied control. Every preset lands between a dozen and fifty points.
|
|
11
|
+
// Preset default height (CHART-4): MUI X-Charts' responsive container
|
|
12
|
+
// measures its actual parent height via ResizeObserver and draws nothing
|
|
13
|
+
// if that parent has none — ChartFrame's flex-centered content Box does
|
|
14
|
+
// NOT stretch its child to fill available height (alignItems: 'center'),
|
|
15
|
+
// so BarChart needs its own explicit size, not just ChartFrame's.
|
|
16
|
+
// `height` forwards straight to MuiBarChart's own native height prop
|
|
17
|
+
// (bypassing ResizeObserver-based measurement entirely), matching the
|
|
18
|
+
// confirmed-working dev/entries.jsx BarChartEntry reference.
|
|
19
|
+
// Deliberate, known exception to DESIGN.md #14 ("responsive = container-
|
|
20
|
+
// sized, not fixed px"): width stays responsive (unset, still auto-
|
|
21
|
+
// measured), only height is fixed, and only because MUI X-Charts' own
|
|
22
|
+
// ResizeObserver-based height measurement is what silently produced a
|
|
23
|
+
// zero-height chart in the first place (CHART-4). Do not "fix" this back
|
|
24
|
+
// to a responsive/aspect-ratio height without re-verifying the underlying
|
|
25
|
+
// MUI measurement bug is actually resolved.
|
|
26
|
+
const CHART_HEIGHT = 320;
|
|
11
27
|
const RANGE_OPTIONS = [
|
|
12
28
|
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
13
29
|
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
@@ -33,9 +49,33 @@ export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data,
|
|
|
33
49
|
const palette = useNeutralChartPalette().categorical;
|
|
34
50
|
const seriesConfig = (data === null || data === void 0 ? void 0 : data.series) || [];
|
|
35
51
|
const [range, setRange] = useState(defaultRange);
|
|
36
|
-
// Series identity (keys) is assumed stable
|
|
37
|
-
//
|
|
38
|
-
|
|
52
|
+
// Series identity (keys) is NOT assumed stable at mount: the host commonly
|
|
53
|
+
// mounts with empty data and fills it asynchronously (fetch-after-mount),
|
|
54
|
+
// so visibleKeys must sync as new keys appear rather than being captured
|
|
55
|
+
// once. seenKeys tracks every key ever observed so that a key present in
|
|
56
|
+
// seriesConfig but absent from visibleKeys is recognized as a deliberate
|
|
57
|
+
// user toggle-off (stays off) rather than treated as new (defaulted
|
|
58
|
+
// visible) on the next data update. Both are plain state (not a ref) and
|
|
59
|
+
// updated together in the render body — React's documented pattern for
|
|
60
|
+
// syncing derived state from props — so an interrupted/discarded render
|
|
61
|
+
// (concurrent features) rolls both back together instead of leaving
|
|
62
|
+
// seenKeys mutated while the paired visibleKeys update is dropped.
|
|
63
|
+
const [seenKeys, setSeenKeys] = useState(() => new Set());
|
|
64
|
+
const [visibleKeys, setVisibleKeys] = useState(() => new Set());
|
|
65
|
+
const currentKeys = seriesConfig.map((series) => series.key);
|
|
66
|
+
const newKeys = currentKeys.filter((key) => !seenKeys.has(key));
|
|
67
|
+
if (newKeys.length > 0) {
|
|
68
|
+
setSeenKeys((previous) => {
|
|
69
|
+
const next = new Set(previous);
|
|
70
|
+
newKeys.forEach((key) => next.add(key));
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
setVisibleKeys((previous) => {
|
|
74
|
+
const next = new Set(previous);
|
|
75
|
+
newKeys.forEach((key) => next.add(key));
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
39
79
|
const handleRangeChange = (event) => {
|
|
40
80
|
const nextRange = event.target.value;
|
|
41
81
|
setRange(nextRange);
|
|
@@ -62,6 +102,6 @@ export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data,
|
|
|
62
102
|
color: palette[index % palette.length],
|
|
63
103
|
'&.Mui-checked': { color: palette[index % palette.length] },
|
|
64
104
|
} })), label: series.label }, series.key))) }))] }));
|
|
65
|
-
return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{ data: (data === null || data === void 0 ? void 0 : data.xLabels) || [] }], series: visibleSeries.map((series) => ({ data: series.data, label: series.label })), palette: visiblePalette })) }));
|
|
105
|
+
return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, minHeight: CHART_HEIGHT, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{ data: (data === null || data === void 0 ? void 0 : data.xLabels) || [] }], series: visibleSeries.map((series) => ({ data: series.data, label: series.label })), palette: visiblePalette, height: CHART_HEIGHT })) }));
|
|
66
106
|
}
|
|
67
107
|
export default TimeSeriesChart;
|
|
@@ -882,6 +882,24 @@ export const authTranslations = {
|
|
|
882
882
|
"en": "The passwords do not match.",
|
|
883
883
|
"sw": "Manenosiri hayalingani."
|
|
884
884
|
},
|
|
885
|
+
"Auth.PASSWORD_RULES_HINT": {
|
|
886
|
+
"de": "Mindestens 8 Zeichen, nicht nur Zahlen, kein gängiges Passwort.",
|
|
887
|
+
"fr": "Au moins 8 caractères, pas uniquement des chiffres, pas un mot de passe courant.",
|
|
888
|
+
"en": "At least 8 characters, not entirely numeric, not a commonly used password.",
|
|
889
|
+
"sw": "Angalau herufi 8, si tarakimu tu, si nenosiri linalotumika sana."
|
|
890
|
+
},
|
|
891
|
+
"Auth.PASSWORD_TOO_SHORT_LOCAL": {
|
|
892
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
893
|
+
"fr": "Le mot de passe doit contenir au moins 8 caractères.",
|
|
894
|
+
"en": "The password must be at least 8 characters long.",
|
|
895
|
+
"sw": "Nenosiri linahitajika kuwa na angalau herufi 8."
|
|
896
|
+
},
|
|
897
|
+
"Auth.PASSWORD_NUMERIC_LOCAL": {
|
|
898
|
+
"de": "Das Passwort darf nicht nur aus Zahlen bestehen.",
|
|
899
|
+
"fr": "Le mot de passe ne peut pas être uniquement composé de chiffres.",
|
|
900
|
+
"en": "The password cannot be entirely numeric.",
|
|
901
|
+
"sw": "Nenosiri haliwezi kuwa na tarakimu tu."
|
|
902
|
+
},
|
|
885
903
|
"Auth.PROFILE_LOAD_FAILED": {
|
|
886
904
|
"de": "Profil konnte nicht geladen werden.",
|
|
887
905
|
"fr": "Le profil n’a pas pu être chargé.",
|
|
@@ -1805,5 +1823,80 @@ export const authTranslations = {
|
|
|
1805
1823
|
"fr": "Le mot de passe est incorrect.",
|
|
1806
1824
|
"en": "The password is incorrect.",
|
|
1807
1825
|
"sw": "Nenosiri si sahihi."
|
|
1826
|
+
},
|
|
1827
|
+
// Raw backend auth error codes (allauth-headless + Django password validators).
|
|
1828
|
+
// Keyed by the exact code string so the existing `t(err.code)` call sites resolve
|
|
1829
|
+
// as a drop-in, without any component change. See AUTH-2.
|
|
1830
|
+
"email_password_mismatch": {
|
|
1831
|
+
"de": "E-Mail-Adresse oder Passwort ist falsch.",
|
|
1832
|
+
"fr": "Adresse e-mail ou mot de passe incorrect.",
|
|
1833
|
+
"en": "Email address or password is incorrect.",
|
|
1834
|
+
"sw": "Barua pepe au nenosiri sio sahihi."
|
|
1835
|
+
},
|
|
1836
|
+
"username_password_mismatch": {
|
|
1837
|
+
"de": "Benutzername oder Passwort ist falsch.",
|
|
1838
|
+
"fr": "Nom d'utilisateur ou mot de passe incorrect.",
|
|
1839
|
+
"en": "Username or password is incorrect.",
|
|
1840
|
+
"sw": "Jina la mtumiaji au nenosiri sio sahihi."
|
|
1841
|
+
},
|
|
1842
|
+
"phone_password_mismatch": {
|
|
1843
|
+
"de": "Telefonnummer oder Passwort ist falsch.",
|
|
1844
|
+
"fr": "Numéro de téléphone ou mot de passe incorrect.",
|
|
1845
|
+
"en": "Phone number or password is incorrect.",
|
|
1846
|
+
"sw": "Nambari ya simu au nenosiri sio sahihi."
|
|
1847
|
+
},
|
|
1848
|
+
"too_many_login_attempts": {
|
|
1849
|
+
"de": "Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.",
|
|
1850
|
+
"fr": "Trop de tentatives de connexion échouées. Veuillez réessayer plus tard.",
|
|
1851
|
+
"en": "Too many failed login attempts. Please try again later.",
|
|
1852
|
+
"sw": "Majaribio mengi ya kuingia yameshindwa. Tafadhali jaribu tena baadaye."
|
|
1853
|
+
},
|
|
1854
|
+
"invalid_login": {
|
|
1855
|
+
"de": "Ungültige Anmeldedaten.",
|
|
1856
|
+
"fr": "Données de connexion invalides.",
|
|
1857
|
+
"en": "Invalid login credentials.",
|
|
1858
|
+
"sw": "Taarifa za kuingia si sahihi."
|
|
1859
|
+
},
|
|
1860
|
+
"account_inactive": {
|
|
1861
|
+
"de": "Dieses Konto ist derzeit inaktiv.",
|
|
1862
|
+
"fr": "Ce compte est actuellement inactif.",
|
|
1863
|
+
"en": "This account is currently inactive.",
|
|
1864
|
+
"sw": "Akaunti hii haitumiki kwa sasa."
|
|
1865
|
+
},
|
|
1866
|
+
"enter_current_password": {
|
|
1867
|
+
"de": "Das aktuelle Passwort ist falsch.",
|
|
1868
|
+
"fr": "Le mot de passe actuel est incorrect.",
|
|
1869
|
+
"en": "The current password is incorrect.",
|
|
1870
|
+
"sw": "Nenosiri la sasa si sahihi."
|
|
1871
|
+
},
|
|
1872
|
+
"password_too_short": {
|
|
1873
|
+
"de": "Das Passwort muss mindestens 8 Zeichen lang sein.",
|
|
1874
|
+
"fr": "Le mot de passe doit contenir au moins 8 caractères.",
|
|
1875
|
+
"en": "The password must be at least 8 characters long.",
|
|
1876
|
+
"sw": "Nenosiri linahitajika kuwa na angalau herufi 8."
|
|
1877
|
+
},
|
|
1878
|
+
"password_too_common": {
|
|
1879
|
+
"de": "Dieses Passwort ist zu gängig und leicht zu erraten.",
|
|
1880
|
+
"fr": "Ce mot de passe est trop courant et facile à deviner.",
|
|
1881
|
+
"en": "This password is too common and easy to guess.",
|
|
1882
|
+
"sw": "Nenosiri hili linatumika sana na ni rahisi kubashiri."
|
|
1883
|
+
},
|
|
1884
|
+
"password_entirely_numeric": {
|
|
1885
|
+
"de": "Das Passwort darf nicht nur aus Zahlen bestehen.",
|
|
1886
|
+
"fr": "Le mot de passe ne peut pas être uniquement composé de chiffres.",
|
|
1887
|
+
"en": "The password cannot be entirely numeric.",
|
|
1888
|
+
"sw": "Nenosiri haliwezi kuwa na tarakimu tu."
|
|
1889
|
+
},
|
|
1890
|
+
"password_too_similar": {
|
|
1891
|
+
"de": "Das Passwort ist Ihren persönlichen Daten zu ähnlich.",
|
|
1892
|
+
"fr": "Le mot de passe est trop similaire à vos informations personnelles.",
|
|
1893
|
+
"en": "The password is too similar to your personal information.",
|
|
1894
|
+
"sw": "Nenosiri linafanana sana na taarifa zako binafsi."
|
|
1895
|
+
},
|
|
1896
|
+
"incorrect_code": {
|
|
1897
|
+
"de": "Der eingegebene Code ist falsch.",
|
|
1898
|
+
"fr": "Le code saisi est incorrect.",
|
|
1899
|
+
"en": "The code you entered is incorrect.",
|
|
1900
|
+
"sw": "Msimbo uliouweka si sahihi."
|
|
1808
1901
|
}
|
|
1809
1902
|
};
|
|
@@ -19,6 +19,14 @@ export const notificationsTranslations = {
|
|
|
19
19
|
'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
|
|
20
20
|
'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
|
|
21
21
|
'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
|
|
22
|
+
'NotificationSettings.REACH_TITLE': { de: 'Wie du erreicht wirst', fr: 'Comment vous êtes joint', en: 'How you’re reached', sw: 'Jinsi unavyofikiwa' },
|
|
23
|
+
'NotificationSettings.REACH_ACTIVE': { de: 'Erreicht dich per E-Mail oder Push, je nach deiner Wahl oben.', fr: 'Vous joint par e-mail ou push, selon votre choix ci-dessus.', en: 'Reaches you by email or push, whichever you’ve chosen above.', sw: 'Inakufikia kwa barua pepe au push, kulingana na uchaguzi wako hapo juu.' },
|
|
24
|
+
'NotificationSettings.REACH_PASSIVE': { de: 'Wird angezeigt, wenn du nachschaust – keine E-Mail oder Push.', fr: 'Affiché seulement quand vous regardez – pas d’e-mail ni de push.', en: 'Shown only when you look – no email or push.', sw: 'Inaonyeshwa tu ukiangalia – hakuna barua pepe au push.' },
|
|
25
|
+
'NotificationSettings.REACH_BOTH': { de: 'Erreicht dich aktiv und bleibt zusätzlich sichtbar, wenn du nachschaust.', fr: 'Vous joint activement et reste visible quand vous regardez.', en: 'Reaches you actively, and also stays visible when you look.', sw: 'Inakufikia kikamilifu, na pia inabaki kuonekana ukiangalia.' },
|
|
26
|
+
'NotificationSettings.REACH_NO_ACTIVE_CHANNEL': { de: 'Kein E-Mail oder Push konfiguriert – diese Benachrichtigung erreicht dich derzeit nicht.', fr: 'Aucun e-mail ou push configuré – cette notification ne vous atteint pas actuellement.', en: 'No email or push configured – this notification does not currently reach you.', sw: 'Hakuna barua pepe au push iliyowekwa – arifa hii kwa sasa haikufikii.' },
|
|
27
|
+
'NotificationSettings.SUBSCRIPTIONS_TITLE': { de: 'Abonnements', fr: 'Abonnements', en: 'Subscriptions', sw: 'Usajili' },
|
|
28
|
+
'NotificationSettings.SUBSCRIPTIONS_SUBTITLE': { de: 'Ereignisse, für die du dich freiwillig anmelden kannst.', fr: 'Événements auxquels vous pouvez vous inscrire volontairement.', en: 'Events you can opt in to receive.', sw: 'Matukio unayoweza kujiunga kupokea.' },
|
|
29
|
+
'NotificationSettings.SUBSCRIPTION_ERROR': { de: 'Abonnement konnte nicht aktualisiert werden.', fr: 'Impossible de mettre à jour l’abonnement.', en: 'Subscription could not be updated.', sw: 'Usajili haukuweza kusasishwa.' },
|
|
22
30
|
'PopupSurface.CLOSE': { de: 'Schliessen', fr: 'Fermer', en: 'Close', sw: 'Funga' },
|
|
23
31
|
'PopupSurface.TITLE': { de: 'Benachrichtigung', fr: 'Notification', en: 'Notification', sw: 'Arifa' },
|
|
24
32
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import Alert from '@mui/material/Alert';
|
|
@@ -9,7 +9,20 @@ import FormControlLabel from '@mui/material/FormControlLabel';
|
|
|
9
9
|
import Switch from '@mui/material/Switch';
|
|
10
10
|
import Typography from '@mui/material/Typography';
|
|
11
11
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
12
|
-
import { getNotificationPreferences, getVapidPublicKey, patchNotificationPreferences, removePushSubscription, savePushSubscription, urlBase64ToUint8Array, } from './api';
|
|
12
|
+
import { getNotificationPreferences, getVapidPublicKey, patchNotificationPreferences, removePushSubscription, savePushSubscription, setNotificationCategorySubscription, urlBase64ToUint8Array, } from './api';
|
|
13
|
+
function reachLabelKey(notificationType) {
|
|
14
|
+
const noActiveChannel = notificationType.active && notificationType.has_active_channel === false;
|
|
15
|
+
if (notificationType.active && notificationType.passive) {
|
|
16
|
+
// Scope C's bounded fallback: a "both" type still reaches the user passively
|
|
17
|
+
// (chip) when no active channel is configured -- it is not undelivered, so it
|
|
18
|
+
// must not show the "does not reach you" warning meant for active-only types.
|
|
19
|
+
return noActiveChannel ? 'NotificationSettings.REACH_PASSIVE' : 'NotificationSettings.REACH_BOTH';
|
|
20
|
+
}
|
|
21
|
+
if (notificationType.active) {
|
|
22
|
+
return noActiveChannel ? 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL' : 'NotificationSettings.REACH_ACTIVE';
|
|
23
|
+
}
|
|
24
|
+
return 'NotificationSettings.REACH_PASSIVE';
|
|
25
|
+
}
|
|
13
26
|
function getPushSupport() {
|
|
14
27
|
return typeof navigator !== 'undefined'
|
|
15
28
|
&& typeof window !== 'undefined'
|
|
@@ -26,6 +39,7 @@ function getIosInstallState() {
|
|
|
26
39
|
return isIos && !standalone;
|
|
27
40
|
}
|
|
28
41
|
export function NotificationSettings() {
|
|
42
|
+
var _a, _b;
|
|
29
43
|
const { t } = useTranslation();
|
|
30
44
|
const [preferences, setPreferences] = useState(null);
|
|
31
45
|
const [loading, setLoading] = useState(true);
|
|
@@ -35,6 +49,7 @@ export function NotificationSettings() {
|
|
|
35
49
|
const [pushSubscribed, setPushSubscribed] = useState(false);
|
|
36
50
|
const [error, setError] = useState('');
|
|
37
51
|
const [conflict, setConflict] = useState('');
|
|
52
|
+
const [savingSubscription, setSavingSubscription] = useState(null);
|
|
38
53
|
const pushSupported = getPushSupport();
|
|
39
54
|
const iosNeedsInstall = getIosInstallState();
|
|
40
55
|
useEffect(() => {
|
|
@@ -134,6 +149,26 @@ export function NotificationSettings() {
|
|
|
134
149
|
setSavingPreview(false);
|
|
135
150
|
}
|
|
136
151
|
};
|
|
152
|
+
const handleSubscriptionToggle = async (category, event) => {
|
|
153
|
+
var _a;
|
|
154
|
+
const requested = event.target.checked;
|
|
155
|
+
setSavingSubscription(category);
|
|
156
|
+
setError('');
|
|
157
|
+
try {
|
|
158
|
+
const result = await setNotificationCategorySubscription(category, requested);
|
|
159
|
+
// Reflect what the server actually recorded, not the checkbox's optimistic
|
|
160
|
+
// value -- a 2xx response with a different `subscribed` (e.g. a server-side
|
|
161
|
+
// clamp) must not be silently overridden by what the user clicked.
|
|
162
|
+
const confirmed = (_a = result === null || result === void 0 ? void 0 : result.subscribed) !== null && _a !== void 0 ? _a : requested;
|
|
163
|
+
setPreferences((current) => (Object.assign(Object.assign({}, current), { subscribable_categories: current.subscribable_categories.map((row) => (row.category === category ? Object.assign(Object.assign({}, row), { subscribed: confirmed }) : row)) })));
|
|
164
|
+
}
|
|
165
|
+
catch (_b) {
|
|
166
|
+
setError(t('NotificationSettings.SUBSCRIPTION_ERROR'));
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
setSavingSubscription(null);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
137
172
|
const handleDisablePush = async () => {
|
|
138
173
|
setSavingPush(true);
|
|
139
174
|
setError('');
|
|
@@ -158,6 +193,16 @@ export function NotificationSettings() {
|
|
|
158
193
|
if (loading) {
|
|
159
194
|
return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
|
|
160
195
|
}
|
|
161
|
-
return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: (preferences === null || preferences === void 0 ? void 0 : preferences.push_preview_opt_in) !== false, onChange: handlePreviewToggle, disabled: savingPreview || !(preferences === null || preferences === void 0 ? void 0 : preferences.push_opt_in) })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_PREVIEW_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_PREVIEW_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0, mt: 1.5 } })] })
|
|
196
|
+
return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: (preferences === null || preferences === void 0 ? void 0 : preferences.push_preview_opt_in) !== false, onChange: handlePreviewToggle, disabled: savingPreview || !(preferences === null || preferences === void 0 ? void 0 : preferences.push_opt_in) })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_PREVIEW_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_PREVIEW_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0, mt: 1.5 } })] }), ((_a = preferences === null || preferences === void 0 ? void 0 : preferences.notification_types) === null || _a === void 0 ? void 0 : _a.length) > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('NotificationSettings.REACH_TITLE') }), preferences.notification_types.map((notificationType) => {
|
|
197
|
+
const labelKey = reachLabelKey(notificationType);
|
|
198
|
+
const reachText = t(labelKey);
|
|
199
|
+
// Reserve Alert (a colored banner) for the one case that is actually a
|
|
200
|
+
// warning -- an active-only type nobody can currently reach. Routine
|
|
201
|
+
// reach description is static settings copy, not a status message.
|
|
202
|
+
if (labelKey === 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL') {
|
|
203
|
+
return (_jsxs(Alert, { severity: "warning", sx: { mb: 1 }, children: [_jsx("strong", { children: notificationType.label }), " \u2014 ", reachText] }, notificationType.key));
|
|
204
|
+
}
|
|
205
|
+
return (_jsxs(Box, { sx: { py: 0.5 }, children: [_jsx(Typography, { variant: "body2", children: notificationType.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: reachText })] }, notificationType.key));
|
|
206
|
+
})] })] })), ((_b = preferences === null || preferences === void 0 ? void 0 : preferences.subscribable_categories) === null || _b === void 0 ? void 0 : _b.length) > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('NotificationSettings.SUBSCRIPTIONS_TITLE') }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBSCRIPTIONS_SUBTITLE') }), preferences.subscribable_categories.map((row) => (_jsx(FormControlLabel, { control: (_jsx(Switch, { checked: Boolean(row.subscribed), onChange: (event) => handleSubscriptionToggle(row.category, event), disabled: savingSubscription === row.category })), label: row.label, labelPlacement: "end", sx: { alignItems: 'center', ml: 0, display: 'flex' } }, row.category)))] })] }))] }));
|
|
162
207
|
}
|
|
163
208
|
export default NotificationSettings;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import apiClient from '../auth/apiClient';
|
|
2
2
|
const PREFERENCES_URL = '/api/notifications/preferences/';
|
|
3
3
|
const PUSH_SUBSCRIPTION_URL = `${PREFERENCES_URL}push-subscription/`;
|
|
4
|
+
const SUBSCRIPTIONS_URL = `${PREFERENCES_URL}subscriptions/`;
|
|
4
5
|
export async function getNotificationPreferences() {
|
|
5
6
|
const response = await apiClient.get(PREFERENCES_URL);
|
|
6
7
|
return response.data;
|
|
@@ -9,6 +10,10 @@ export async function patchNotificationPreferences(patch) {
|
|
|
9
10
|
const response = await apiClient.patch(PREFERENCES_URL, patch);
|
|
10
11
|
return response.data;
|
|
11
12
|
}
|
|
13
|
+
export async function setNotificationCategorySubscription(category, subscribed) {
|
|
14
|
+
const response = await apiClient.post(SUBSCRIPTIONS_URL, { category, subscribed });
|
|
15
|
+
return response.data;
|
|
16
|
+
}
|
|
12
17
|
export async function getVapidPublicKey() {
|
|
13
18
|
var _a;
|
|
14
19
|
const response = await apiClient.get(`${PREFERENCES_URL}vapid-public-key/`);
|
package/package.json
CHANGED
|
@@ -6,15 +6,34 @@ import { useTranslation } from 'react-i18next';
|
|
|
6
6
|
* A simplified form to handle password changes.
|
|
7
7
|
* Does not require password confirmation.
|
|
8
8
|
*/
|
|
9
|
-
export function PasswordChangeForm({
|
|
9
|
+
export function PasswordChangeForm({
|
|
10
|
+
onSubmit,
|
|
11
|
+
submitting = false,
|
|
12
|
+
passwordRulesHint,
|
|
13
|
+
minPasswordLength = 8,
|
|
14
|
+
allowNumericPassword = false,
|
|
15
|
+
}) {
|
|
10
16
|
const { t } = useTranslation();
|
|
11
17
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
12
18
|
const [newPassword, setNewPassword] = useState('');
|
|
19
|
+
const [localErrorKey, setLocalErrorKey] = useState(null);
|
|
13
20
|
|
|
14
21
|
const handleSubmit = async (e) => {
|
|
15
22
|
e.preventDefault();
|
|
16
23
|
if (!onSubmit) return;
|
|
17
24
|
|
|
25
|
+
setLocalErrorKey(null);
|
|
26
|
+
|
|
27
|
+
if (minPasswordLength > 0 && newPassword.length < minPasswordLength) {
|
|
28
|
+
setLocalErrorKey('Auth.PASSWORD_TOO_SHORT_LOCAL');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!allowNumericPassword && /^\d+$/.test(newPassword)) {
|
|
33
|
+
setLocalErrorKey('Auth.PASSWORD_NUMERIC_LOCAL');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
// Parent steuert Loading/Errors, hier nur Aufruf
|
|
19
38
|
await onSubmit(currentPassword, newPassword);
|
|
20
39
|
|
|
@@ -25,6 +44,11 @@ export function PasswordChangeForm({ onSubmit, submitting = false }) {
|
|
|
25
44
|
|
|
26
45
|
return (
|
|
27
46
|
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 500 }}>
|
|
47
|
+
{localErrorKey && (
|
|
48
|
+
<Box sx={{ color: 'error.main', fontSize: 14, mb: 2 }}>
|
|
49
|
+
{t(localErrorKey)}
|
|
50
|
+
</Box>
|
|
51
|
+
)}
|
|
28
52
|
<Stack spacing={2}>
|
|
29
53
|
<TextField
|
|
30
54
|
label={t('Auth.CURRENT_PASSWORD_LABEL')}
|
|
@@ -43,6 +67,7 @@ export function PasswordChangeForm({ onSubmit, submitting = false }) {
|
|
|
43
67
|
required
|
|
44
68
|
fullWidth
|
|
45
69
|
disabled={submitting}
|
|
70
|
+
helperText={passwordRulesHint || t('Auth.PASSWORD_RULES_HINT')}
|
|
46
71
|
/>
|
|
47
72
|
<Box>
|
|
48
73
|
<Button
|
|
@@ -7,7 +7,13 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
* Simple form to set a new password (once, with confirmation).
|
|
8
8
|
* Caller passes onSubmit(newPassword) and handles redirect / API call.
|
|
9
9
|
*/
|
|
10
|
-
export function PasswordSetForm({
|
|
10
|
+
export function PasswordSetForm({
|
|
11
|
+
onSubmit,
|
|
12
|
+
submitting = false,
|
|
13
|
+
passwordRulesHint,
|
|
14
|
+
minPasswordLength = 8,
|
|
15
|
+
allowNumericPassword = false,
|
|
16
|
+
}) {
|
|
11
17
|
const { t } = useTranslation();
|
|
12
18
|
|
|
13
19
|
const [password1, setPassword1] = useState('');
|
|
@@ -28,6 +34,16 @@ export function PasswordSetForm({ onSubmit, submitting = false }) {
|
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
if (minPasswordLength > 0 && password1.length < minPasswordLength) {
|
|
38
|
+
setLocalErrorKey('Auth.PASSWORD_TOO_SHORT_LOCAL');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!allowNumericPassword && /^\d+$/.test(password1)) {
|
|
43
|
+
setLocalErrorKey('Auth.PASSWORD_NUMERIC_LOCAL');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
if (onSubmit) {
|
|
32
48
|
onSubmit(password1);
|
|
33
49
|
}
|
|
@@ -53,6 +69,7 @@ export function PasswordSetForm({ onSubmit, submitting = false }) {
|
|
|
53
69
|
value={password1}
|
|
54
70
|
onChange={(e) => setPassword1(e.target.value)}
|
|
55
71
|
disabled={submitting}
|
|
72
|
+
helperText={passwordRulesHint || t('Auth.PASSWORD_RULES_HINT')}
|
|
56
73
|
/>
|
|
57
74
|
|
|
58
75
|
<TextField
|