@byline/admin 5.2.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  66. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  67. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  68. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  69. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  70. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  71. package/dist/presentation/group.jsx +26 -0
  72. package/dist/presentation/row.jsx +23 -0
  73. package/dist/presentation/tabs.jsx +39 -0
  74. package/dist/services/admin-services-context.jsx +17 -0
  75. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  76. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  77. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  78. package/package.json +7 -7
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ /**
10
+ * Self-service change-password drawer form.
11
+ *
12
+ * Distinct from the admin-users `set-password.tsx`:
13
+ *
14
+ * - Requires the current password as defence against
15
+ * session-hijack abuse. The server verifies it against the
16
+ * stored hash before swapping in the new one — wrong current
17
+ * password surfaces as `admin.account.invalidCurrentPassword`.
18
+ * - Confirmation field catches typos before round-trip.
19
+ *
20
+ * Successful password changes end native sessions. Confirmation remains visible
21
+ * until the user reloads the protected account page to reach sign-in.
22
+ */
23
+ import { useMemo, useState } from 'react';
24
+ import { revalidateLogic, useForm } from '@tanstack/react-form-start';
25
+ import { passwordSchema } from '@byline/core/validation';
26
+ import { useTranslation } from '@byline/i18n/react';
27
+ import { Alert, Button, InputPassword, LoaderEllipsis } from '@byline/ui/react';
28
+ import cx from 'clsx';
29
+ import { z } from 'zod';
30
+ import { translateValidationError } from '../../../lib/translate-validation-error.js';
31
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js';
32
+ import styles from './change-password.module.css';
33
+ export function ChangeAccountPassword({ account, onClose, onSuccess }) {
34
+ const { changeAccountPassword } = useBylineAdminServices();
35
+ const { t } = useTranslation('byline-admin');
36
+ const [formError, setFormError] = useState(null);
37
+ const [successMessage, setSuccessMessage] = useState(null);
38
+ // Schema rebuilt per-render so error messages reflect the active
39
+ // locale. `passwordSchema` from `@byline/core/validation` emits stable
40
+ // error codes — `translateValidationError(t, …)` below maps them onto
41
+ // the active locale at render time.
42
+ const changePasswordFormSchema = useMemo(() => z
43
+ .object({
44
+ currentPassword: z
45
+ .string()
46
+ .min(1, { message: t('account.changePassword.errors.currentRequired') }),
47
+ newPassword: passwordSchema,
48
+ confirm: z.string({
49
+ message: t('account.changePassword.errors.confirmRequired'),
50
+ }),
51
+ })
52
+ .refine((v) => v.newPassword === v.confirm, {
53
+ message: t('account.changePassword.errors.mismatch'),
54
+ path: ['confirm'],
55
+ }), [t]);
56
+ const form = useForm({
57
+ defaultValues: { currentPassword: '', newPassword: '', confirm: '' },
58
+ validationLogic: revalidateLogic({
59
+ mode: 'blur',
60
+ modeAfterSubmission: 'change',
61
+ }),
62
+ validators: {
63
+ onDynamic: changePasswordFormSchema,
64
+ },
65
+ onSubmit: async ({ value }) => {
66
+ setFormError(null);
67
+ setSuccessMessage(null);
68
+ try {
69
+ const updated = await changeAccountPassword({
70
+ data: {
71
+ vid: account.vid,
72
+ currentPassword: value.currentPassword,
73
+ newPassword: value.newPassword,
74
+ },
75
+ });
76
+ setSuccessMessage(t('account.changePassword.feedback.updated'));
77
+ form.reset({ currentPassword: '', newPassword: '', confirm: '' });
78
+ onSuccess?.(updated);
79
+ }
80
+ catch (err) {
81
+ const code = getErrorCode(err);
82
+ if (code === 'admin.account.invalidCurrentPassword') {
83
+ const message = t('account.changePassword.errors.currentIncorrect');
84
+ form.setFieldMeta('currentPassword', (meta) => ({
85
+ ...meta,
86
+ errorMap: { ...meta.errorMap, onServer: message },
87
+ errors: [message],
88
+ }));
89
+ return;
90
+ }
91
+ if (code === 'admin.users.versionConflict') {
92
+ setFormError(t('common.errors.versionConflict'));
93
+ return;
94
+ }
95
+ if (code === 'admin.account.notFound') {
96
+ setFormError(t('common.errors.accountNotFound'));
97
+ return;
98
+ }
99
+ setFormError(t('account.changePassword.errors.couldNotChange'));
100
+ }
101
+ },
102
+ });
103
+ if (successMessage) {
104
+ return (<div className={cx('byline-account-change-password-wrap', styles.wrap)}>
105
+ <div role="status">
106
+ <Alert intent="success">{successMessage}</Alert>
107
+ </div>
108
+ <Button type="button" intent="primary" onClick={() => window.location.reload()}>
109
+ {t('auth.signIn.title')}
110
+ </Button>
111
+ </div>);
112
+ }
113
+ return (<div className={cx('byline-account-change-password-wrap', styles.wrap)}>
114
+ <form method="post" noValidate onSubmit={(event) => {
115
+ event.preventDefault();
116
+ event.stopPropagation();
117
+ void form.handleSubmit();
118
+ }} className={cx('byline-account-change-password-form', styles.form)}>
119
+ {formError ? <Alert intent="danger">{formError}</Alert> : null}
120
+ {successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
121
+
122
+ <p className="muted">{t('account.changePassword.intro')}</p>
123
+
124
+ <form.Field name="currentPassword">
125
+ {(field) => (<InputPassword label={t('account.changePassword.fields.current')} id="currentPassword" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} autoComplete="current-password" required/>)}
126
+ </form.Field>
127
+
128
+ <form.Field name="newPassword">
129
+ {(field) => (<InputPassword label={t('account.changePassword.fields.new')} id="newPassword" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={translateValidationError(t, firstError(field.state.meta.errors))} autoComplete="new-password" required/>)}
130
+ </form.Field>
131
+
132
+ <form.Field name="confirm">
133
+ {(field) => (<InputPassword label={t('account.changePassword.fields.confirm')} id="confirm" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} autoComplete="new-password" required/>)}
134
+ </form.Field>
135
+
136
+ <div className={cx('byline-account-change-password-actions', styles.actions)}>
137
+ <Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-account-change-password-action', styles.action)}>
138
+ {successMessage ? t('common.actions.close') : t('common.actions.cancel')}
139
+ </Button>
140
+ <form.Subscribe selector={(state) => ({
141
+ canSubmit: state.canSubmit,
142
+ isSubmitting: state.isSubmitting,
143
+ isDirty: state.isDirty,
144
+ })}>
145
+ {({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-account-change-password-action', styles.action)}>
146
+ {isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
147
+ </Button>)}
148
+ </form.Subscribe>
149
+ </div>
150
+ </form>
151
+ </div>);
152
+ }
153
+ function firstError(errors) {
154
+ for (const err of errors) {
155
+ if (typeof err === 'string')
156
+ return err;
157
+ if (err && typeof err === 'object' && 'message' in err) {
158
+ const msg = err.message;
159
+ if (typeof msg === 'string')
160
+ return msg;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+ function getErrorCode(err) {
166
+ return typeof err?.code === 'string'
167
+ ? err.code
168
+ : null;
169
+ }
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+ import { useState } from 'react';
3
+ import { LocalDateTime } from '@byline/admin/react';
4
+ import { useTranslation } from '@byline/i18n/react';
5
+ import { CloseIcon, Drawer, EditIcon, IconButton } from '@byline/ui/react';
6
+ import cx from 'clsx';
7
+ import { ChangeAccountPassword } from './change-password.js';
8
+ import styles from './container.module.css';
9
+ import { Preferences } from './preferences.js';
10
+ import { ThemeSwitch } from './theme-switch.js';
11
+ import { UpdateAccount } from './update.js';
12
+ const panelComponents = {
13
+ update: UpdateAccount,
14
+ change_password: ChangeAccountPassword,
15
+ preferences: Preferences,
16
+ empty: () => null,
17
+ };
18
+ function ContainerSection({ title, onEdit, editAriaLabel, children, }) {
19
+ return (<div className={cx('byline-account-section', styles.section)}>
20
+ <div className={cx('byline-account-section-head', styles['section-head'])}>
21
+ <h2>{title}</h2>
22
+ {onEdit ? (<IconButton variant="text" onClick={onEdit} aria-label={editAriaLabel ?? title}>
23
+ <EditIcon width="20px" height="20px"/>
24
+ </IconButton>) : null}
25
+ </div>
26
+ <div>{children}</div>
27
+ </div>);
28
+ }
29
+ export function AccountSelfContainer({ account }) {
30
+ const { t } = useTranslation('byline-admin');
31
+ const [currentAccount, setCurrentAccount] = useState(account);
32
+ const [current, setCurrent] = useState('empty');
33
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
34
+ const openDrawer = (key) => () => {
35
+ setCurrent(key);
36
+ setIsDrawerOpen(true);
37
+ };
38
+ const closeDrawer = () => {
39
+ setCurrent('empty');
40
+ setIsDrawerOpen(false);
41
+ };
42
+ const handleSuccess = (updated) => {
43
+ setCurrentAccount(updated);
44
+ };
45
+ const Panel = panelComponents[current];
46
+ const panelTitles = {
47
+ update: t('account.sections.profile'),
48
+ change_password: t('account.sections.password'),
49
+ preferences: t('account.sections.preferences'),
50
+ empty: '',
51
+ };
52
+ const editAriaFor = (section) => t('account.editAriaLabel', { section });
53
+ return (<>
54
+ <div className={cx('byline-account-grid', styles.grid)}>
55
+ <div className={cx('byline-account-column', styles.column)}>
56
+ <ContainerSection title={t('account.sections.profile')} onEdit={openDrawer('update')} editAriaLabel={editAriaFor(t('account.sections.profile'))}>
57
+ <p className={cx('byline-account-line', styles.line)}>
58
+ <span className="muted">{t('account.profile.emailColon')}</span>{' '}
59
+ {currentAccount.email}
60
+ </p>
61
+ <p className={cx('byline-account-line', styles.line)}>
62
+ <span className="muted">{t('account.profile.givenName')}</span>{' '}
63
+ {currentAccount.given_name ?? (<span className={cx('muted', 'byline-account-not-set', styles['not-set'])}>
64
+ {t('common.notSet')}
65
+ </span>)}
66
+ </p>
67
+ <p className={cx('byline-account-line', styles.line)}>
68
+ <span className="muted">{t('account.profile.familyName')}</span>{' '}
69
+ {currentAccount.family_name ?? (<span className={cx('muted', 'byline-account-not-set', styles['not-set'])}>
70
+ {t('common.notSet')}
71
+ </span>)}
72
+ </p>
73
+ <p className={cx('byline-account-line', styles.line)}>
74
+ <span className="muted">{t('account.profile.username')}</span>{' '}
75
+ {currentAccount.username ?? (<span className={cx('muted', 'byline-account-not-set', styles['not-set'])}>
76
+ {t('common.notSet')}
77
+ </span>)}
78
+ </p>
79
+ <div className={cx('muted', 'byline-account-meta', styles.meta)}>
80
+ <p>
81
+ <span className="font-bold">{t('account.profile.created')}&nbsp;</span>
82
+ <LocalDateTime value={currentAccount.created_at}/>
83
+ </p>
84
+ <p>
85
+ <span className="font-bold">{t('account.profile.updated')}&nbsp;</span>
86
+ <LocalDateTime value={currentAccount.updated_at}/>
87
+ </p>
88
+ <p className={cx('byline-account-line', styles.line)}>
89
+ <span className="font-bold">{t('account.profile.lastLogin')}&nbsp;</span>
90
+ <LocalDateTime value={currentAccount.last_login} fallback={t('common.never')}/>
91
+ </p>
92
+ </div>
93
+ </ContainerSection>
94
+
95
+ <ContainerSection title={t('account.sections.preferences')} onEdit={openDrawer('preferences')} editAriaLabel={editAriaFor(t('account.sections.preferences'))}>
96
+ <p className={cx('byline-account-line', styles.line)}>
97
+ <span className="muted">{t('account.preferences.interfaceLanguage')}</span>{' '}
98
+ {currentAccount.preferred_locale ?? (<span className={cx('muted', 'byline-account-not-set', styles['not-set'])}>
99
+ {t('language.useBrowserDefault')}
100
+ </span>)}
101
+ </p>
102
+ <p className={cx('muted', 'byline-account-status-help', styles['status-help'])}>
103
+ {t('account.preferences.help')}
104
+ </p>
105
+ </ContainerSection>
106
+ </div>
107
+
108
+ <div className={cx('byline-account-column', styles.column)}>
109
+ <ContainerSection title={t('account.sections.password')} onEdit={openDrawer('change_password')} editAriaLabel={editAriaFor(t('account.sections.password'))}>
110
+ <p className={cx('byline-account-line', styles.line)}>{t('account.password.intro')}</p>
111
+ </ContainerSection>
112
+
113
+ <ContainerSection title={t('account.sections.appearance')}>
114
+ <ThemeSwitch />
115
+ </ContainerSection>
116
+
117
+ <ContainerSection title={t('account.sections.status')}>
118
+ <p className={cx('byline-account-line', styles.line)}>
119
+ <span className="muted">{t('account.status.superAdmin')}</span>{' '}
120
+ {currentAccount.is_super_admin ? t('common.boolean.yes') : t('common.boolean.no')}
121
+ </p>
122
+ <p className={cx('byline-account-line', styles.line)}>
123
+ <span className="muted">{t('account.status.emailVerified')}</span>{' '}
124
+ {currentAccount.is_email_verified ? t('common.boolean.yes') : t('common.boolean.no')}
125
+ </p>
126
+ <p className={cx('byline-account-line', styles.line)}>
127
+ <span className="muted">{t('account.status.status')}</span>{' '}
128
+ <span className={currentAccount.is_enabled
129
+ ? cx('byline-account-status-on', styles['status-on'])
130
+ : cx('byline-account-status-off', styles['status-off'])}>
131
+ {currentAccount.is_enabled
132
+ ? t('account.status.enabled')
133
+ : t('account.status.disabled')}
134
+ </span>
135
+ </p>
136
+ <p className={cx('muted', 'byline-account-status-help', styles['status-help'])}>
137
+ {t('account.status.help')}
138
+ </p>
139
+ </ContainerSection>
140
+ </div>
141
+ </div>
142
+
143
+ <Drawer id="admin-account-drawer" closeOnOverlayClick={false} width="medium" topOffset="46px" isOpen={isDrawerOpen} onDismiss={closeDrawer} className={cx('byline-account-drawer', styles.drawer)}>
144
+ <Drawer.Container aria-hidden={!isDrawerOpen} className={cx('byline-account-drawer-body', styles['drawer-body'])}>
145
+ <Drawer.TopActions>
146
+ <button type="button" tabIndex={0} className={cx('byline-account-drawer-skip', styles['drawer-skip'])}>
147
+ no action
148
+ </button>
149
+ <IconButton aria-label={t('common.actions.close')} size="sm" onClick={closeDrawer}>
150
+ <CloseIcon width="14px" height="14px" svgClassName="white-icon stroke-white"/>
151
+ </IconButton>
152
+ </Drawer.TopActions>
153
+ <Drawer.Header>
154
+ <h2>{panelTitles[current]}</h2>
155
+ </Drawer.Header>
156
+ <Drawer.Content>
157
+ <div className={cx('byline-account-drawer-scroll', styles['drawer-scroll'])}>
158
+ <Panel account={currentAccount} onClose={closeDrawer} onSuccess={handleSuccess}/>
159
+ </div>
160
+ </Drawer.Content>
161
+ </Drawer.Container>
162
+ </Drawer>
163
+ </>);
164
+ }
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ /**
10
+ * Self-service Preferences form.
11
+ *
12
+ * General-purpose container for the signed-in admin's UI preferences.
13
+ * Today the only setting is the interface locale; future preferences
14
+ * (theme, default landing page, density, etc.) land here as additional
15
+ * `<form.Field>` blocks rather than spinning up a new drawer per
16
+ * setting.
17
+ *
18
+ * Locale write surface mirrors the chrome-bar `<LanguageMenu>` — both
19
+ * route through `setAdminLocale` (the service) →
20
+ * `setAdminLocaleFn` (the host server fn) → the shared
21
+ * cookie + `admin_users.preferred_locale` writes. The dropdown carries
22
+ * an explicit "Use browser default" entry that maps to `null`
23
+ * server-side, clearing the column and re-engaging the detection
24
+ * cascade (cookie → Accept-Language → defaultLocale). The sentinel
25
+ * value `__auto__` is the wire form for that option; the service
26
+ * signature uses `string | null`.
27
+ *
28
+ * On save, the freshened `AccountResponse` returned by the server fn
29
+ * is lifted into the parent container so the read-only Preferences
30
+ * section re-renders without a route reload. Mirrors how
31
+ * `UpdateAccount` threads its result through `onSuccess`.
32
+ */
33
+ import { useMemo, useState } from 'react';
34
+ import { revalidateLogic, useForm } from '@tanstack/react-form-start';
35
+ import { getAdminConfig } from '@byline/core';
36
+ import { useTranslation } from '@byline/i18n/react';
37
+ import { Alert, Button, LoaderEllipsis, Select } from '@byline/ui/react';
38
+ import cx from 'clsx';
39
+ import { z } from 'zod';
40
+ import { useBylineAdminServices } from '../../../services/admin-services-context.js';
41
+ import styles from './preferences.module.css';
42
+ /**
43
+ * Sentinel for the "Use browser default" entry in the locale select.
44
+ * The widget's value type is `string`; we map this sentinel to `null`
45
+ * at submit time. Prefixed with double-underscore to make collision
46
+ * with a real BCP 47 tag structurally impossible (no real locale code
47
+ * contains underscores).
48
+ */
49
+ const AUTO_VALUE = '__auto__';
50
+ const preferencesSchema = z.object({
51
+ // Validation against the configured locale set happens server-side
52
+ // (it's the single source of truth). The form schema just enforces
53
+ // non-emptiness — `AUTO_VALUE` is a valid selection.
54
+ locale: z.string().min(1, { message: 'Select a language' }),
55
+ });
56
+ function defaultsFrom(account) {
57
+ return { locale: account.preferred_locale ?? AUTO_VALUE };
58
+ }
59
+ export function Preferences({ account, onClose, onSuccess }) {
60
+ const { t } = useTranslation('byline-admin');
61
+ const { setAdminLocale } = useBylineAdminServices();
62
+ const [formError, setFormError] = useState(null);
63
+ const [successMessage, setSuccessMessage] = useState(null);
64
+ // Build the dropdown's items from the host's configured interface
65
+ // locales. `localeDefinitions` carries host-authored display names
66
+ // ("Français" vs CLDR's lowercase "français"); when it's not set,
67
+ // fall back to the raw code so the form still functions.
68
+ const localeItems = useMemo(() => {
69
+ const { i18n } = getAdminConfig();
70
+ const definitionByCode = new Map((i18n.admin.localeDefinitions ?? []).map((d) => [d.code, d.nativeName]));
71
+ const items = i18n.admin.locales.map((code) => ({
72
+ value: code,
73
+ label: definitionByCode.get(code) ?? code,
74
+ }));
75
+ return [{ value: AUTO_VALUE, label: t('language.useBrowserDefault') }, ...items];
76
+ }, [t]);
77
+ const form = useForm({
78
+ defaultValues: defaultsFrom(account),
79
+ validationLogic: revalidateLogic({
80
+ mode: 'blur',
81
+ modeAfterSubmission: 'change',
82
+ }),
83
+ validators: {
84
+ onDynamic: preferencesSchema,
85
+ },
86
+ onSubmit: async ({ value }) => {
87
+ setFormError(null);
88
+ setSuccessMessage(null);
89
+ const nextLocale = value.locale === AUTO_VALUE ? null : value.locale;
90
+ if (nextLocale === (account.preferred_locale ?? null)) {
91
+ setSuccessMessage(t('common.feedback.noChanges'));
92
+ return;
93
+ }
94
+ try {
95
+ const result = await setAdminLocale({ data: { locale: nextLocale } });
96
+ // Pre-auth path returns `account: null`; the form is only
97
+ // reachable behind an admin session, so `account` should be
98
+ // populated. Treat absence as a defensive no-op.
99
+ setSuccessMessage(t('common.feedback.saved'));
100
+ if (result.account != null) {
101
+ onSuccess?.(result.account);
102
+ }
103
+ }
104
+ catch {
105
+ setFormError(t('common.errors.couldNotSave'));
106
+ }
107
+ },
108
+ });
109
+ return (<div className={cx('byline-account-preferences-wrap', styles.wrap)}>
110
+ <form method="post" noValidate onSubmit={(event) => {
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ void form.handleSubmit();
114
+ }} className={cx('byline-account-preferences-form', styles.form)}>
115
+ {formError ? <Alert intent="danger">{formError}</Alert> : null}
116
+ {successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
117
+
118
+ <form.Field name="locale">
119
+ {(field) => (<div className={cx('byline-account-preferences-row', styles.row)}>
120
+ <label htmlFor="preferences-locale-select" className={cx('byline-account-preferences-label', styles.label)}>
121
+ {t('account.preferences.languageLabel')}
122
+ </label>
123
+ <Select id="preferences-locale-select" size="sm" ariaLabel={t('account.preferences.languageLabel')} value={field.state.value} items={localeItems} onValueChange={(value) => {
124
+ if (value != null)
125
+ field.handleChange(value);
126
+ }}/>
127
+ <p className={cx('muted', 'byline-account-preferences-help', styles.help)}>
128
+ {t('account.preferences.languageHelp')}
129
+ </p>
130
+ </div>)}
131
+ </form.Field>
132
+
133
+ <div className={cx('byline-account-preferences-actions', styles.actions)}>
134
+ <Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-account-preferences-action', styles.action)}>
135
+ {successMessage ? t('common.actions.close') : t('common.actions.cancel')}
136
+ </Button>
137
+ <form.Subscribe selector={(state) => ({
138
+ canSubmit: state.canSubmit,
139
+ isSubmitting: state.isSubmitting,
140
+ })}>
141
+ {({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-account-preferences-action', styles.action)}>
142
+ {isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
143
+ </Button>)}
144
+ </form.Subscribe>
145
+ </div>
146
+ </form>
147
+ </div>);
148
+ }
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ /**
10
+ * Inline colour-theme switch for the account "Appearance" card — a
11
+ * three-way segmented control (Light / Dark / System). Unlike the other
12
+ * account sections it edits in place rather than through the drawer, so
13
+ * there's no pencil affordance on its card. See `./theme.ts` for the
14
+ * host theme contract this reads and writes.
15
+ */
16
+ import { useEffect, useState } from 'react';
17
+ import { useTranslation } from '@byline/i18n/react';
18
+ import { ButtonGroup, ButtonGroupItem } from '@byline/ui/react';
19
+ import cx from 'clsx';
20
+ import { getThemeMode, setThemeMode } from './theme.js';
21
+ import styles from './theme-switch.module.css';
22
+ export function ThemeSwitch() {
23
+ const { t } = useTranslation('byline-admin');
24
+ // SSR and the first client render both show 'system' — a deterministic
25
+ // value, so there's no hydration mismatch. The actual stored mode is
26
+ // read once after mount (localStorage is client-only).
27
+ const [mode, setMode] = useState('system');
28
+ useEffect(() => {
29
+ setMode(getThemeMode());
30
+ }, []);
31
+ const handleChange = (value) => {
32
+ // `ButtonGroup`'s union props type the callback as `string | string[]`;
33
+ // in single-select mode it's a string. A single-select ToggleGroup
34
+ // also emits '' when the active item is re-clicked (deselect) — a
35
+ // theme is always set, so ignore anything that isn't a known mode.
36
+ const next = Array.isArray(value) ? value[0] : value;
37
+ if (next !== 'light' && next !== 'dark' && next !== 'system')
38
+ return;
39
+ setMode(next);
40
+ setThemeMode(next);
41
+ };
42
+ return (<div className={cx('byline-account-theme-switch', styles.wrap)}>
43
+ <ButtonGroup type="single" size="sm" expandToFit value={mode} onValueChange={handleChange} aria-label={t('account.appearance.colorTheme')} className={cx('byline-account-theme-group', styles.group)}>
44
+ <ButtonGroupItem value="light">{t('account.appearance.light')}</ButtonGroupItem>
45
+ <ButtonGroupItem value="dark">{t('account.appearance.dark')}</ButtonGroupItem>
46
+ <ButtonGroupItem value="system">{t('account.appearance.system')}</ButtonGroupItem>
47
+ </ButtonGroup>
48
+ <p className={cx('muted', 'byline-account-theme-help', styles.help)}>
49
+ {t('account.appearance.help')}
50
+ </p>
51
+ </div>);
52
+ }