@scalio-cloud/ui 0.15.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+ Same profile page in every app, reference Scalio Nexus (Markus 2026-10-31, scalio-docs ADR 0042 Nachtrag).
5
+ - New `react/profile.js`: `ProfileIdentity` (letters, name, email · role), `ThemeChoice` (Hell, Dunkel, System as
6
+ option cards), `LanguageChoice` (flags, `fixed` for an instance-wide language), `TimezoneChoice` (searchable
7
+ list, "Automatisch" = browser), `PasswordChange` (current, new, confirm; mismatch check, error from the API).
8
+ - Helpers: `setUserTimezone`, `currentTz`, `browserTz`, `listTimezones`, `tzOffset` (apps format dates with
9
+ `currentTz()`), `applyTheme(mode)` / `resolveTheme` / `normalizeTheme` for light, dark and system.
10
+ - Icons `Globe`, `Clock`; labels `profile_` (DE/EN). Default security hint now names authenticator app and passkeys.
11
+ - **Visible change:** none until an app uses the new parts. No token changes.
12
+
3
13
  ## 0.15.0
4
14
  New app Scalio Beacon (service desk, scalio-docs ADR 0043).
5
15
  - Product switcher: entry "Beacon" (icon `RadioTower`) between Citadel and Pulse, available like the others.
package/README.md CHANGED
@@ -188,7 +188,13 @@ import { AuthLayout, AuthHeader, MicrosoftButton, AuthDivider, AuthMessage, Prof
188
188
  <ProfileSection id="connections" lang={lang}>...</ProfileSection> // Verknüpfungen
189
189
  ```
190
190
 
191
- Form fields and buttons stay the app's own (shadcn), so they match the rest of the app.
191
+ Content of the sections (same in every app, reference Nexus): `ProfileIdentity` in Konto, `ThemeChoice` in
192
+ Darstellung, `LanguageChoice` + `TimezoneChoice` in Sprache und Region, `PasswordChange` + `SecurityFactors` in
193
+ Sicherheit. The app stores the time zone (`POST /auth/preferences {timezone}`), calls `setUserTimezone()` with
194
+ the value from `/auth/me` and formats dates with `timeZone: currentTz()`. `applyTheme(mode)` sets the dark class
195
+ for light, dark and system.
196
+
197
+ Other form fields and buttons (e.g. the name field) stay the app's own (shadcn), so they match the rest of the app.
192
198
 
193
199
  ## Two-factor card and license panel (React)
194
200
 
package/auth.css CHANGED
@@ -112,3 +112,70 @@
112
112
  min-width: 0; padding: 1.25rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card));
113
113
  color: hsl(var(--card-foreground)); display: flex; flex-direction: column; gap: 1rem;
114
114
  }
115
+
116
+ /* Profile blocks (react/profile.js): identity, option cards, time zone list, password form. */
117
+ .scalio-profile-identity-wrap { display: flex; flex-direction: column; gap: 1rem; }
118
+ .scalio-profile-identity { display: flex; align-items: center; gap: 1rem; min-width: 0; }
119
+ .scalio-profile-avatar {
120
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 3rem; height: 3rem;
121
+ background: hsl(var(--foreground)); color: hsl(var(--background)); font-size: 0.875rem; font-weight: 700;
122
+ }
123
+ .scalio-profile-who { min-width: 0; }
124
+ .scalio-profile-name { font-size: 0.875rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
125
+ .scalio-profile-mail {
126
+ font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: hsl(var(--muted-foreground));
127
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
128
+ }
129
+ .scalio-profile-options { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
130
+ @media (min-width: 640px) {
131
+ .scalio-profile-options.is-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
132
+ .scalio-profile-options.is-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
133
+ }
134
+ .scalio-profile-option {
135
+ display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.75rem 1rem; text-align: left;
136
+ border: 1px solid hsl(var(--input)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
137
+ font: inherit; cursor: pointer; transition: border-color 0.15s, background-color 0.15s;
138
+ }
139
+ .scalio-profile-option:hover { border-color: hsl(var(--muted-foreground)); }
140
+ .scalio-profile-option.is-on { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.05); }
141
+ .scalio-profile-option:disabled { opacity: 0.6; cursor: default; }
142
+ .scalio-profile-option:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 0; }
143
+ .scalio-profile-option-label { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; font-weight: 600; }
144
+ .scalio-profile-option-label > .scalio-icon { width: 18px; height: 18px; color: hsl(var(--muted-foreground)); }
145
+ .scalio-profile-option.is-on .scalio-profile-option-label > .scalio-icon,
146
+ .scalio-profile-option-check { color: hsl(var(--primary)); width: 18px; height: 18px; }
147
+ .scalio-profile-flag { font-size: 1.25rem; line-height: 1; }
148
+ .scalio-profile-sublabel {
149
+ display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;
150
+ font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: hsl(var(--muted-foreground));
151
+ }
152
+ .scalio-profile-sublabel > .scalio-icon { width: 14px; height: 14px; }
153
+ .scalio-profile-help { margin: 0.5rem 0 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
154
+ .scalio-profile-error { margin: 0; font-size: 0.875rem; color: hsl(var(--destructive)); }
155
+ .scalio-tz { position: relative; }
156
+ .scalio-tz-button { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; width: 100%; text-align: left; cursor: pointer; }
157
+ .scalio-tz-current { display: flex; align-items: center; gap: 0.5rem; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
158
+ .scalio-tz-button .scalio-icon { flex-shrink: 0; color: hsl(var(--muted-foreground)); }
159
+ .scalio-tz-panel {
160
+ position: absolute; z-index: 40; left: 0; right: 0; top: calc(100% + 4px); min-width: 280px; padding: 0.5rem;
161
+ border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--popover, var(--card))); color: hsl(var(--popover-foreground, var(--card-foreground)));
162
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
163
+ }
164
+ .scalio-tz-list { margin: 0.5rem 0 0; padding: 0; list-style: none; max-height: 18rem; overflow-y: auto; }
165
+ .scalio-tz-item {
166
+ display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.375rem 0.5rem; border: 0; background: none;
167
+ color: inherit; font: inherit; font-size: 0.875rem; text-align: left; cursor: pointer;
168
+ }
169
+ .scalio-tz-item:hover, .scalio-tz-item:focus-visible { background: hsl(var(--muted)); outline: none; }
170
+ .scalio-tz-check { visibility: hidden; flex-shrink: 0; }
171
+ .scalio-tz-item.is-on .scalio-tz-check { visibility: visible; }
172
+ .scalio-tz-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
173
+ .scalio-tz-off { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
174
+ .scalio-tz-empty { padding: 0.5rem; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
175
+ .scalio-profile-pw { display: flex; flex-direction: column; gap: 1rem; }
176
+ .scalio-profile-pw-row { display: grid; grid-template-columns: 1fr; gap: 1rem; }
177
+ @media (min-width: 640px) { .scalio-profile-pw-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
178
+ .scalio-profile-field { display: flex; flex-direction: column; gap: 0.375rem; }
179
+ .scalio-profile-field .scalio-field-label { margin-top: 0; }
180
+ .scalio-profile-pw .scalio-profile-help { margin-top: -0.5rem; }
181
+ .scalio-profile-submit { align-self: flex-start; margin-top: 0; padding: 0 1rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
package/react/icons.js CHANGED
@@ -124,7 +124,13 @@ const RadioTower = icon([
124
124
  ["path", { d: "m8 22 4-11 4 11" }],
125
125
  ]);
126
126
  const Info = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 16v-4M12 8h.01" }]]);
127
+ const Globe = icon([
128
+ ["circle", { cx: 12, cy: 12, r: 10 }],
129
+ ["path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
130
+ ["path", { d: "M2 12h20" }],
131
+ ]);
132
+ const Clock = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 6v6l4 2" }]]);
127
133
 
128
134
  module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
129
135
  Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft,
130
- Users, Settings, ScrollText, Info, RadioTower };
136
+ Users, Settings, ScrollText, Info, RadioTower, Globe, Clock };
package/react/index.js CHANGED
@@ -373,9 +373,10 @@ module.exports = {
373
373
  VersionTag,
374
374
  Icons,
375
375
  LABELS,
376
+ labelsFor,
376
377
  TEST_IDS,
377
378
  initials,
378
379
  };
379
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"));
380
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"));
380
381
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
381
382
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -224,11 +224,29 @@ const LABELS = {
224
224
  security: "Sicherheit",
225
225
  connections: "Verknüpfungen",
226
226
  },
227
+ profile_: {
228
+ subtitle: "Persönliche Einstellungen für Ihr Konto",
229
+ language: "Anzeigesprache",
230
+ timezone: "Zeitzone",
231
+ timezoneAuto: "Automatisch (Browser: {tz})",
232
+ timezoneHelp: "Alle Daten und Uhrzeiten werden in dieser Zeitzone angezeigt.",
233
+ timezoneSearch: "Zeitzone suchen …",
234
+ timezoneNone: "Keine Zeitzone gefunden.",
235
+ pwCurrent: "Aktuelles Passwort",
236
+ pwNew: "Neues Passwort",
237
+ pwConfirm: "Neues bestätigen",
238
+ pwMin: "Mindestens {n} Zeichen.",
239
+ pwMismatch: "Die neuen Passwörter stimmen nicht überein.",
240
+ pwSubmit: "Passwort aktualisieren",
241
+ pwChanged: "Passwort geändert.",
242
+ saving: "Speichern …",
243
+ saved: "Einstellung gespeichert",
244
+ },
227
245
  sectionHints: {
228
246
  account: "Ihre Angaben. Die E-Mail-Adresse ist Ihr Anmeldename.",
229
247
  appearance: "Hell, dunkel oder wie Ihr System.",
230
248
  language: "Sprache der Oberfläche und Zeitzone für Zeitangaben.",
231
- security: "Passwort und Zwei-Faktor-Anmeldung.",
249
+ security: "Passwort, Authenticator-App und Passkeys.",
232
250
  connections: "Verbundene Konten für die Anmeldung.",
233
251
  },
234
252
  },
@@ -448,11 +466,29 @@ const LABELS = {
448
466
  security: "Security",
449
467
  connections: "Connections",
450
468
  },
469
+ profile_: {
470
+ subtitle: "Personal settings for your account",
471
+ language: "Display language",
472
+ timezone: "Time zone",
473
+ timezoneAuto: "Automatic (browser: {tz})",
474
+ timezoneHelp: "All dates and times are shown in this time zone.",
475
+ timezoneSearch: "Search time zone …",
476
+ timezoneNone: "No time zone found.",
477
+ pwCurrent: "Current password",
478
+ pwNew: "New password",
479
+ pwConfirm: "Confirm new",
480
+ pwMin: "At least {n} characters.",
481
+ pwMismatch: "The new passwords do not match.",
482
+ pwSubmit: "Update password",
483
+ pwChanged: "Password changed.",
484
+ saving: "Saving …",
485
+ saved: "Preference saved",
486
+ },
451
487
  sectionHints: {
452
488
  account: "Your details. Your email address is your sign-in name.",
453
489
  appearance: "Light, dark or like your system.",
454
490
  language: "Language of the interface and time zone for times.",
455
- security: "Password and two-factor sign-in.",
491
+ security: "Password, authenticator app and passkeys.",
456
492
  connections: "Linked accounts for signing in.",
457
493
  },
458
494
  },
@@ -470,6 +506,7 @@ function labelsFor(lang, overrides) {
470
506
  edition: Object.assign({}, base.edition, o.edition),
471
507
  accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
472
508
  passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
509
+ profile_: Object.assign({}, base.profile_, o.profile_),
473
510
  });
474
511
  }
475
512
 
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ // Profile page blocks, the same in every app (reference: Scalio Nexus, ADR 0042 Nachtrag 2026-10-31).
3
+ // Order on the page: ProfileSection account (ProfileIdentity), appearance (ThemeChoice), language
4
+ // (LanguageChoice + TimezoneChoice), security (PasswordChange + SecurityFactors), connections (app specific).
5
+ // Time zone helpers: an empty time zone means "automatic" (the browser's). Apps call setUserTimezone() with
6
+ // the value from /auth/me and format dates with currentTz().
7
+ const React = require("react");
8
+ const h = React.createElement;
9
+ const Icons = require("./icons");
10
+ const { labelsFor } = require("./labels");
11
+
12
+ function cx() {
13
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
14
+ }
15
+
16
+ function fill(text, values) {
17
+ return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
18
+ }
19
+
20
+ function errorText(err) {
21
+ const d = err && err.response && err.response.data && err.response.data.detail;
22
+ if (typeof d === "string") return d;
23
+ if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
24
+ if (d && typeof d.message === "string") return d.message;
25
+ return (err && err.message) || String(err);
26
+ }
27
+
28
+ // --- time zone ---------------------------------------------------------------------------------------
29
+ let userTz = "";
30
+ function browserTz() {
31
+ try {
32
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
33
+ } catch (e) {
34
+ return "UTC";
35
+ }
36
+ }
37
+ function setUserTimezone(tz) {
38
+ userTz = tz || "";
39
+ }
40
+ function currentTz() {
41
+ return userTz || browserTz();
42
+ }
43
+ const FALLBACK_ZONES = ["UTC", "Europe/London", "Europe/Zurich", "Europe/Berlin", "Europe/Paris", "America/New_York",
44
+ "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore", "Asia/Tokyo",
45
+ "Australia/Sydney"];
46
+ function listTimezones() {
47
+ let list = FALLBACK_ZONES;
48
+ try {
49
+ if (Intl.supportedValuesOf) list = Intl.supportedValuesOf("timeZone");
50
+ } catch (e) {
51
+ // keep the short list
52
+ }
53
+ return list.includes("UTC") ? list : ["UTC"].concat(list);
54
+ }
55
+ function tzOffset(tz) {
56
+ try {
57
+ const p = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date());
58
+ const part = p.find((x) => x.type === "timeZoneName");
59
+ return ((part && part.value) || "").replace("GMT", "UTC");
60
+ } catch (e) {
61
+ return "";
62
+ }
63
+ }
64
+
65
+ // --- theme -------------------------------------------------------------------------------------------
66
+ // mode: light | dark | system. Returns the mode actually shown.
67
+ function resolveTheme(mode) {
68
+ if (mode === "dark" || mode === "light") return mode;
69
+ try {
70
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
71
+ } catch (e) {
72
+ return "light";
73
+ }
74
+ }
75
+ // Sets the "dark" class on <html> for the mode and follows the system while the mode is "system".
76
+ // Returns a function that stops following.
77
+ function applyTheme(mode) {
78
+ const set = () => document.documentElement.classList.toggle("dark", resolveTheme(mode) === "dark");
79
+ set();
80
+ if (mode !== "system" || typeof window === "undefined" || !window.matchMedia) return () => {};
81
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
82
+ if (mq.addEventListener) mq.addEventListener("change", set);
83
+ else if (mq.addListener) mq.addListener(set);
84
+ return () => {
85
+ if (mq.removeEventListener) mq.removeEventListener("change", set);
86
+ else if (mq.removeListener) mq.removeListener(set);
87
+ };
88
+ }
89
+ function normalizeTheme(value) {
90
+ return value === "dark" || value === "system" ? value : "light";
91
+ }
92
+
93
+ // --- blocks ------------------------------------------------------------------------------------------
94
+ function initials(name) {
95
+ return String(name || "?").trim().slice(0, 2).toUpperCase() || "?";
96
+ }
97
+
98
+ // Letters, name, "email · role". children: extra content under it (e.g. a name field).
99
+ function ProfileIdentity({ name, email, role, children, testId }) {
100
+ return h(
101
+ "div",
102
+ { className: "scalio-profile-identity-wrap" },
103
+ h(
104
+ "div",
105
+ { className: "scalio-profile-identity", "data-testid": testId || "profile-identity" },
106
+ h("div", { className: "scalio-profile-avatar", "aria-hidden": "true" }, initials(name || email)),
107
+ h(
108
+ "div",
109
+ { className: "scalio-profile-who" },
110
+ h("div", { className: "scalio-profile-name", "data-testid": "profile-name" }, name || email),
111
+ h("div", { className: "scalio-profile-mail", "data-testid": "profile-email" }, [email, role].filter(Boolean).join(" · "))
112
+ )
113
+ ),
114
+ children || null
115
+ );
116
+ }
117
+
118
+ function Option({ on, onClick, disabled, testId, icon, children }) {
119
+ return h(
120
+ "button",
121
+ {
122
+ type: "button",
123
+ role: "radio",
124
+ "aria-checked": !!on,
125
+ disabled: !!disabled,
126
+ onClick,
127
+ className: cx("scalio-profile-option", on && "is-on"),
128
+ "data-testid": testId,
129
+ },
130
+ h("span", { className: "scalio-profile-option-label" }, icon || null, h("span", null, children)),
131
+ on ? h(Icons.Check, { className: "scalio-icon scalio-profile-option-check" }) : null
132
+ );
133
+ }
134
+
135
+ function SubLabel({ icon, children }) {
136
+ return h("div", { className: "scalio-profile-sublabel" }, icon || null, children);
137
+ }
138
+
139
+ const THEME_ICONS = { light: Icons.Sun, dark: Icons.Moon, system: Icons.Monitor };
140
+
141
+ function ThemeChoice({ theme, onChange, modes, lang, labels, testIdPrefix }) {
142
+ const L = labelsFor(lang, labels);
143
+ const list = modes || ["light", "dark", "system"];
144
+ const pre = testIdPrefix || "theme-option";
145
+ return h(
146
+ "div",
147
+ { className: "scalio-profile-options is-3", role: "radiogroup", "aria-label": L.theme },
148
+ list.map((m) => {
149
+ const I = THEME_ICONS[m];
150
+ return h(Option, { key: m, on: theme === m, onClick: () => onChange(m), testId: `${pre}-${m}`, icon: I ? h(I, { className: "scalio-icon" }) : null }, L[m]);
151
+ })
152
+ );
153
+ }
154
+
155
+ const LANGUAGES = [
156
+ { code: "de", label: "Deutsch", flag: "🇩🇪" },
157
+ { code: "en", label: "English", flag: "🇬🇧" },
158
+ ];
159
+
160
+ // fixed: the language is set for the whole instance (the buttons are disabled, fixedHint explains why).
161
+ function LanguageChoice({ lang, onChange, languages, fixed, fixedHint, labels, testIdPrefix }) {
162
+ const L = labelsFor(lang, labels);
163
+ const pre = testIdPrefix || "lang-option";
164
+ return h(
165
+ "div",
166
+ { "data-testid": "language-section" },
167
+ h(SubLabel, { icon: h(Icons.Globe, { className: "scalio-icon" }) }, L.profile_.language),
168
+ h(
169
+ "div",
170
+ { className: "scalio-profile-options is-2", role: "radiogroup", "aria-label": L.profile_.language },
171
+ (languages || LANGUAGES).map((l) =>
172
+ h(Option, { key: l.code, on: lang === l.code, disabled: fixed, onClick: () => onChange(l.code), testId: `${pre}-${l.code}`,
173
+ icon: h("span", { className: "scalio-profile-flag", "aria-hidden": "true" }, l.flag) }, l.label)
174
+ )
175
+ ),
176
+ fixed && fixedHint ? h("p", { className: "scalio-profile-help", "data-testid": "language-fixed" }, fixedHint) : null
177
+ );
178
+ }
179
+
180
+ // Searchable list of IANA time zones; the first entry "Automatic" saves an empty value.
181
+ function TimezoneChoice({ value, onChange, lang, labels, testId, defaultOpen }) {
182
+ const L = labelsFor(lang, labels);
183
+ const P = L.profile_;
184
+ const id = testId || "timezone-select";
185
+ const [open, setOpen] = React.useState(!!defaultOpen);
186
+ const [q, setQ] = React.useState("");
187
+ const ref = React.useRef(null);
188
+ const zones = React.useMemo(() => listTimezones().map((z) => ({ z, off: tzOffset(z) })), []);
189
+ React.useEffect(() => {
190
+ if (!open) return undefined;
191
+ const away = (e) => {
192
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
193
+ };
194
+ const esc = (e) => {
195
+ if (e.key === "Escape") setOpen(false);
196
+ };
197
+ document.addEventListener("mousedown", away);
198
+ document.addEventListener("keydown", esc);
199
+ return () => {
200
+ document.removeEventListener("mousedown", away);
201
+ document.removeEventListener("keydown", esc);
202
+ };
203
+ }, [open]);
204
+ const auto = fill(P.timezoneAuto, { tz: browserTz() });
205
+ const shown = value ? `${value.replace(/_/g, " ")} (${tzOffset(value)})` : auto;
206
+ const needle = q.trim().toLowerCase().replace(/\s+/g, "_");
207
+ const hits = needle ? zones.filter((x) => `${x.z} ${x.off}`.toLowerCase().includes(needle)) : zones;
208
+ const pick = (v) => {
209
+ setOpen(false);
210
+ setQ("");
211
+ if (v !== value) onChange(v);
212
+ };
213
+ const item = (key, v, label, off, tid) =>
214
+ h(
215
+ "li",
216
+ { key },
217
+ h(
218
+ "button",
219
+ { type: "button", className: cx("scalio-tz-item", v === (value || "") && "is-on"), onClick: () => pick(v), "data-testid": tid },
220
+ h(Icons.Check, { className: "scalio-icon scalio-tz-check" }),
221
+ h("span", { className: "scalio-tz-name" }, label),
222
+ off ? h("span", { className: "scalio-tz-off" }, off) : null
223
+ )
224
+ );
225
+ return h(
226
+ "div",
227
+ { "data-testid": "timezone-section" },
228
+ h(SubLabel, { icon: h(Icons.Clock, { className: "scalio-icon" }) }, P.timezone),
229
+ h(
230
+ "div",
231
+ { className: "scalio-tz", ref },
232
+ h(
233
+ "button",
234
+ { type: "button", className: "scalio-input scalio-tz-button", "aria-haspopup": "listbox", "aria-expanded": open,
235
+ onClick: () => setOpen(!open), "data-testid": id },
236
+ h("span", { className: "scalio-tz-current" }, h(Icons.Globe, { className: "scalio-icon" }), h("span", null, shown)),
237
+ h(Icons.ChevronDown, { className: "scalio-icon" })
238
+ ),
239
+ open
240
+ ? h(
241
+ "div",
242
+ { className: "scalio-tz-panel" },
243
+ h("input", { className: "scalio-input", autoFocus: true, value: q, placeholder: P.timezoneSearch, "aria-label": P.timezoneSearch,
244
+ onChange: (e) => setQ(e.target.value), "data-testid": `${id}-search` }),
245
+ h(
246
+ "ul",
247
+ { className: "scalio-tz-list", role: "listbox" },
248
+ !needle || "automatic automatisch browser".includes(needle) ? item("__auto", "", auto, "", `${id}-auto`) : null,
249
+ hits.slice(0, 400).map((x) => item(x.z, x.z, x.z.replace(/_/g, " "), x.off, `${id}-opt-${x.z}`)),
250
+ hits.length === 0 ? h("li", { className: "scalio-tz-empty" }, P.timezoneNone) : null
251
+ )
252
+ )
253
+ : null
254
+ ),
255
+ h("p", { className: "scalio-profile-help" }, P.timezoneHelp)
256
+ );
257
+ }
258
+
259
+ // onSubmit(current, next) returns a promise; a rejection shows its message under the form.
260
+ function PasswordChange({ onSubmit, onDone, minLength, lang, labels, testIds }) {
261
+ const L = labelsFor(lang, labels);
262
+ const P = L.profile_;
263
+ const T = Object.assign({ form: "change-password-form", current: "cp-current", next: "cp-new", confirm: "cp-confirm", submit: "cp-submit", error: "cp-error" }, testIds);
264
+ const [pw, setPw] = React.useState({ current: "", next: "", confirm: "" });
265
+ const [error, setError] = React.useState("");
266
+ const [busy, setBusy] = React.useState(false);
267
+ const min = minLength || 0;
268
+ const submit = async (e) => {
269
+ e.preventDefault();
270
+ setError("");
271
+ if (pw.next !== pw.confirm) {
272
+ setError(P.pwMismatch);
273
+ return;
274
+ }
275
+ setBusy(true);
276
+ try {
277
+ const res = await onSubmit(pw.current, pw.next);
278
+ setPw({ current: "", next: "", confirm: "" });
279
+ if (onDone) onDone(res);
280
+ } catch (err) {
281
+ setError(errorText(err));
282
+ } finally {
283
+ setBusy(false);
284
+ }
285
+ };
286
+ const set = (k) => (e) => setPw(Object.assign({}, pw, { [k]: e.target.value }));
287
+ const field = (k, label, auto, extra) =>
288
+ h(
289
+ "div",
290
+ { className: "scalio-profile-field" },
291
+ h("label", { className: "scalio-field-label", htmlFor: `pw-${k}` }, label),
292
+ h("input", Object.assign({ id: `pw-${k}`, type: "password", className: "scalio-input", value: pw[k], onChange: set(k), required: true,
293
+ autoComplete: auto, "data-testid": T[k] }, extra))
294
+ );
295
+ return h(
296
+ "form",
297
+ { className: "scalio-profile-pw", onSubmit: submit, "data-testid": T.form },
298
+ field("current", P.pwCurrent, "current-password"),
299
+ h(
300
+ "div",
301
+ { className: "scalio-profile-pw-row" },
302
+ field("next", P.pwNew, "new-password", min ? { minLength: min } : null),
303
+ field("confirm", P.pwConfirm, "new-password", min ? { minLength: min } : null)
304
+ ),
305
+ min ? h("p", { className: "scalio-profile-help" }, fill(P.pwMin, { n: min })) : null,
306
+ error ? h("p", { className: "scalio-profile-error", role: "alert", "data-testid": T.error }, error) : null,
307
+ h("button", { type: "submit", className: "scalio-btn-primary scalio-profile-submit", disabled: busy, "data-testid": T.submit },
308
+ busy ? P.saving : P.pwSubmit)
309
+ );
310
+ }
311
+
312
+ module.exports = {
313
+ ProfileIdentity,
314
+ ThemeChoice,
315
+ LanguageChoice,
316
+ TimezoneChoice,
317
+ PasswordChange,
318
+ PROFILE_LANGUAGES: LANGUAGES,
319
+ browserTz,
320
+ setUserTimezone,
321
+ currentTz,
322
+ listTimezones,
323
+ tzOffset,
324
+ resolveTheme,
325
+ applyTheme,
326
+ normalizeTheme,
327
+ };