@byline/admin 5.1.2 → 5.1.3
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/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- 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')} </span>
|
|
82
|
+
<LocalDateTime value={currentAccount.created_at}/>
|
|
83
|
+
</p>
|
|
84
|
+
<p>
|
|
85
|
+
<span className="font-bold">{t('account.profile.updated')} </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')} </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
|
+
}
|