feat(ui): connect account controls and processing navigation
This commit is contained in:
@@ -20,6 +20,19 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
test('processing workspace keyboard targets move between filters and jobs without changing filters', async () => {
|
||||
open(); await screen.findByText('Synthetic owner');
|
||||
const filter = screen.getByRole('form', { name: 'Processing filters' });
|
||||
const search = screen.getByLabelText('Search users, jobs or task ID');
|
||||
fireEvent.change(search, { target: { value: 'Unsubmitted search' } });
|
||||
const jobs = screen.getByRole('group', { name: 'Processing results' });
|
||||
jobs.scrollIntoView = jest.fn(); filter.scrollIntoView = jest.fn();
|
||||
const reads = get.mock.calls.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jump to processing jobs' })); expect(jobs).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Return to processing filters' })); expect(filter).toHaveFocus();
|
||||
expect(search).toHaveValue('Unsubmitted search'); expect(get.mock.calls.length).toBe(reads);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['en', 'constructor', 'Other operation', 'Unknown', 'Unregistered worker', 'Not recorded'],
|
||||
['en', 'toString', 'Other operation', 'Unknown', 'Unregistered worker', 'Not recorded'],
|
||||
@@ -57,6 +70,14 @@ test('shows safe job metadata and real events with a copyable correlation ID', a
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('processing details receive focus even when animation frames run before the response render commits', async () => {
|
||||
const frame = jest.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { callback(0); return 0; });
|
||||
try {
|
||||
open(); fireEvent.click(await screen.findByRole('button', { name: /View processing details/ }));
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Processing details' })).toHaveFocus());
|
||||
} finally { frame.mockRestore(); }
|
||||
});
|
||||
|
||||
test('submits diagnostic filters and displays explicit owner, import and unrelated-job metadata', async () => {
|
||||
open();
|
||||
await screen.findByText('Synthetic owner');
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { JobTableColumns } from "./JobTable";
|
||||
import ImportExportJobs from "./ImportExportJobs";
|
||||
@@ -65,7 +65,8 @@ export default function SettingsView({
|
||||
onThemeModeChange,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState(() => new URLSearchParams(location.search).get("section") === "data" ? 3 : 0);
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs | null>(null);
|
||||
|
||||
@@ -2,6 +2,16 @@ export type UiLanguage = "en" | "nb";
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
accountWorkspaceTitle: "Your account",
|
||||
accountWorkspaceBody: "Manage your identity, sign-in connections and security here. Career details remain in Career Profile; exports and account deletion controls are in Settings.",
|
||||
accountWorkspaceNavigation: "Account sections",
|
||||
accountWorkspaceConnections: "Sign-in and connections",
|
||||
accountWorkspaceSecurity: "Password and security",
|
||||
accountWorkspaceData: "Exports and account data",
|
||||
processingFilters: "Processing filters",
|
||||
processingResults: "Processing results",
|
||||
processingJumpResults: "Jump to processing jobs",
|
||||
processingReturnFilters: "Return to processing filters",
|
||||
aiUnavailableTitle: "AI assistance is unavailable",
|
||||
aiUnavailableSettings: "Review AI settings",
|
||||
aiUnavailableBody: "You can continue editing manually. Review your AI settings and usage before trying again.",
|
||||
@@ -3206,6 +3216,16 @@ export const translations = {
|
||||
coverAiReject: "Reject",
|
||||
},
|
||||
nb: {
|
||||
accountWorkspaceTitle: "Kontoen din",
|
||||
accountWorkspaceBody: "Administrer identitet, innloggingstilkoblinger og sikkerhet her. Karriereopplysninger ligger fortsatt i Karriereprofil; eksport og kontosletting finnes under Innstillinger.",
|
||||
accountWorkspaceNavigation: "Kontoseksjoner",
|
||||
accountWorkspaceConnections: "Innlogging og tilkoblinger",
|
||||
accountWorkspaceSecurity: "Passord og sikkerhet",
|
||||
accountWorkspaceData: "Eksport og kontodata",
|
||||
processingFilters: "Behandlingsfiltre",
|
||||
processingResults: "Behandlingsresultater",
|
||||
processingJumpResults: "Gå til behandlingsoppgaver",
|
||||
processingReturnFilters: "Tilbake til behandlingsfiltre",
|
||||
aiUnavailableTitle: "KI-hjelp er utilgjengelig",
|
||||
aiUnavailableSettings: "Se KI-innstillinger",
|
||||
aiUnavailableBody: "Du kan fortsette å redigere manuelt. Se gjennom KI-innstillingene og forbruket ditt før du prøver igjen.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './views/ProfilePage';
|
||||
import CareerProfilePage from './views/CareerProfilePage';
|
||||
import { api } from './api';
|
||||
import { translations } from './i18n/translations';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
|
||||
@@ -86,6 +87,24 @@ function renderPage(section: 'profile' | 'import' = 'profile') {
|
||||
}
|
||||
void ProfilePage;
|
||||
|
||||
test.each(['en', 'nb'] as const)('account workspace navigation preserves identity drafts and focuses existing groups (%s)', async language => {
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
const t = translations[language];
|
||||
renderWith(ProfilePage);
|
||||
const name = await screen.findByRole('textbox', { name: t.profileDisplayName });
|
||||
await waitFor(() => expect(name).toHaveValue('Demo User'));
|
||||
fireEvent.change(name, { target: { value: 'Unsaved account name' } });
|
||||
const navigation = screen.getByRole('navigation', { name: t.accountWorkspaceNavigation });
|
||||
for (const [id, label] of [['account-details', t.profileAccountSection], ['account-connections', t.accountWorkspaceConnections], ['account-security', t.accountWorkspaceSecurity]]) {
|
||||
const target = document.getElementById(id)!;
|
||||
target.scrollIntoView = jest.fn();
|
||||
fireEvent.click(within(navigation).getByRole('button', { name: label }));
|
||||
expect(target).toHaveFocus(); expect(name).toHaveValue('Unsaved account name');
|
||||
}
|
||||
expect(within(navigation).getByRole('link', { name: t.accountWorkspaceData })).toHaveAttribute('href', '/settings?section=data');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
extractionRunsResponse = [{
|
||||
id: 12,
|
||||
|
||||
@@ -26,9 +26,9 @@ jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderView(overrides: Partial<React.ComponentProps<typeof SettingsView>> = {}) {
|
||||
function renderView(overrides: Partial<React.ComponentProps<typeof SettingsView>> = {}, entry = '/settings') {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<SettingsView
|
||||
@@ -103,6 +103,12 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([['data', 'Backup'], ['unknown', 'General']])('account data entry selects a supported initial section (%s)', async (section, label) => {
|
||||
renderView({}, `/settings?section=${section}`);
|
||||
expect(await screen.findByRole('tabpanel', { name: label })).toBeVisible();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('each selected Settings section has a named panel linked to its tab and remains selectable on phones', async () => {
|
||||
renderView();
|
||||
await screen.findByText(/AI privacy/i);
|
||||
|
||||
@@ -46,8 +46,16 @@ export default function AdminProcessingPage() {
|
||||
const [updated, setUpdated] = useState<string | null>(null);
|
||||
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
|
||||
const detailHeading = useRef<HTMLHeadingElement>(null);
|
||||
const filtersBody = useRef<HTMLFormElement>(null);
|
||||
const resultsBody = useRef<HTMLDivElement>(null);
|
||||
const focusDetail = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail || !focusDetail.current || identity !== getAuthUserKey() || isSessionInvalidated()) return;
|
||||
focusDetail.current = false;
|
||||
detailHeading.current?.focus();
|
||||
}, [detail, identity]);
|
||||
|
||||
useEffect(() => {
|
||||
const changed = () => {
|
||||
setListing(null); setDetail(null); setEvents([]); select(null); setBefore(null);
|
||||
@@ -103,10 +111,6 @@ export default function AdminProcessingPage() {
|
||||
if (next.length) cursor = next[next.length - 1].id;
|
||||
} else setEvents([]);
|
||||
setUpdated(new Date().toISOString()); setError(null);
|
||||
if (focusDetail.current && detailResponse) {
|
||||
focusDetail.current = false;
|
||||
requestAnimationFrame(() => { if (current()) detailHeading.current?.focus(); });
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!current() || request.signal.aborted) return;
|
||||
request.abort();
|
||||
@@ -144,12 +148,13 @@ export default function AdminProcessingPage() {
|
||||
</Stack>
|
||||
{error ? <Alert severity="error" action={error === 'unavailable' ? <Button onClick={() => setRefresh(value => value + 1)}>{t('retry')}</Button> : undefined}>{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : 'processingLoadFailed')}</Alert> : null}
|
||||
{busy && !visibleListing ? <Typography role="status">{t('loading')}</Typography> : null}
|
||||
<Box component="form" onSubmit={event => { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}>
|
||||
<Box component="form" ref={filtersBody} tabIndex={-1} aria-label={t('processingFilters')} sx={{ scrollMarginTop: { xs: '9rem', sm: '6rem' } }} onSubmit={event => { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}>
|
||||
<Button type="button" disabled={!visibleListing} onClick={() => { resultsBody.current?.focus({ preventScroll: true }); resultsBody.current?.scrollIntoView({ block: 'start' }); }}>{t('processingJumpResults')}</Button>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={2}>
|
||||
<TextField select label={t('processingStatus')} value={inputs.status} onChange={event => setInputs(value => ({ ...value, status: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<TextField select label={t('processingStatus')} value={inputs.status} onChange={event => setInputs(value => ({ ...value, status: event.target.value }))} slotProps={{ select: { native: true }, inputLabel: { shrink: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{Object.keys(statuses).map(value => <option key={value} value={value}>{state(value)}</option>)}
|
||||
</TextField>
|
||||
<TextField select label={t('processingTask')} value={inputs.taskType} onChange={event => setInputs(value => ({ ...value, taskType: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<TextField select label={t('processingTask')} value={inputs.taskType} onChange={event => setInputs(value => ({ ...value, taskType: event.target.value }))} slotProps={{ select: { native: true }, inputLabel: { shrink: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{Object.keys(tasks).map(value => <option key={value} value={value}>{t(tasks[value])}</option>)}<option value="other">{t('processingOther')}</option>
|
||||
</TextField>
|
||||
<TextField label={t('processingOwnerFilter')} value={inputs.owner} onChange={event => setInputs(value => ({ ...value, owner: event.target.value }))} slotProps={{ htmlInput: { maxLength: 255 } }} sx={{ flex: '1 1 14rem', minWidth: 0 }} />
|
||||
@@ -157,10 +162,10 @@ export default function AdminProcessingPage() {
|
||||
<TextField type="number" label={t('processingApplicationId')} value={inputs.jobId} onChange={event => setInputs(value => ({ ...value, jobId: event.target.value }))} slotProps={{ htmlInput: { min: 1, max: 2147483647 } }} />
|
||||
<TextField type="date" label={t('processingCreatedFrom')} value={inputs.createdFrom} onChange={event => setInputs(value => ({ ...value, createdFrom: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
<TextField type="date" label={t('processingCreatedBefore')} value={inputs.createdBefore} onChange={event => setInputs(value => ({ ...value, createdBefore: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||
<TextField select label={t('processingWorkerFilter')} value={inputs.worker} onChange={event => setInputs(value => ({ ...value, worker: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<TextField select label={t('processingWorkerFilter')} value={inputs.worker} onChange={event => setInputs(value => ({ ...value, worker: event.target.value }))} slotProps={{ select: { native: true }, inputLabel: { shrink: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{['document', 'ai', 'untracked'].map(value => <option key={value} value={value}>{worker(value)}</option>)}
|
||||
</TextField>
|
||||
<TextField select label={t('processingFailureCode')} value={inputs.failureCode} onChange={event => setInputs(value => ({ ...value, failureCode: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<TextField select label={t('processingFailureCode')} value={inputs.failureCode} onChange={event => setInputs(value => ({ ...value, failureCode: event.target.value }))} slotProps={{ select: { native: true }, inputLabel: { shrink: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{failureCodes.map(value => <option key={value} value={value}>{value}</option>)}
|
||||
</TextField>
|
||||
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
|
||||
@@ -169,12 +174,14 @@ export default function AdminProcessingPage() {
|
||||
{visibleListing ? <>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>{Object.entries(visibleListing.counts).map(([key, count]) => <Chip key={key} label={`${state(key)}: ${count}`} />)}</Stack>
|
||||
<PageSection title={t('processingJobs')} surface>
|
||||
<Box ref={resultsBody} tabIndex={-1} role="group" aria-label={t('processingResults')} sx={{ minWidth: 0, scrollMarginTop: { xs: '9rem', sm: '6rem' } }}>
|
||||
<Button onClick={() => { filtersBody.current?.focus({ preventScroll: true }); filtersBody.current?.scrollIntoView({ block: 'start' }); }}>{t('processingReturnFilters')}</Button>
|
||||
{!visibleListing.items.length ? <Typography>{t('processingNoJobs')}</Typography> : <TableContainer tabIndex={0} role="group" aria-label={t('processingJobs')} sx={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<Table aria-label={t('processingJobs')} sx={{ minWidth: 800 }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated', 'processingElapsed'].map(key => <TableCell key={key}>{t(key as TranslationKey)}</TableCell>)}</TableRow></TableHead>
|
||||
<Table aria-label={t('processingJobs')} sx={{ minWidth: 800, '& th:last-child, & td:last-child': { minWidth: 96 } }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated', 'processingElapsed'].map(key => <TableCell key={key}>{t(key as TranslationKey)}</TableCell>)}</TableRow></TableHead>
|
||||
<TableBody>{visibleListing.items.map(item => <TableRow key={item.id} selected={selected === item.id}>
|
||||
<TableCell component="th" scope="row"><Button aria-label={t('processingViewDetails', { id: item.id })} onClick={() => { setDetail(null); setEvents([]); setBefore(null); setCopy(null); focusDetail.current = true; select(item.id); }}>{t(tasks[item.taskType] ?? 'processingOther')}</Button><Typography variant="caption" display="block">{item.job?.title ?? item.document?.name ?? item.id.slice(0, 8)}</Typography></TableCell>
|
||||
<TableCell><Button onClick={() => { const next = { ...filters, owner: item.owner.id }; setInputs(next); setFilters(next); setPage(1); resetSelection(); }}>{item.owner.name ?? item.owner.email ?? item.owner.id}</Button><Typography variant="caption" display="block">{item.owner.email}</Typography></TableCell>
|
||||
<TableCell>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography>{item.failureCode && ['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(item.status) ? <Chip size="small" color="warning" label={failure(item.failureCode)} /> : null}</TableCell>
|
||||
<TableCell>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography>{item.failureCode && ['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(item.status) ? <Chip size="small" color="warning" label={failure(item.failureCode)} sx={{ height: 'auto', maxWidth: '100%', '& .MuiChip-label': { whiteSpace: 'normal', py: 0.25 } }} /> : null}</TableCell>
|
||||
<TableCell>{worker(item.worker)}<Typography variant="caption" display="block">{t(item.workerEnabled ? 'processingEnabled' : 'processingDisabled')}</Typography></TableCell>
|
||||
<TableCell><time dateTime={item.createdAtUtc}>{date(item.createdAtUtc)}</time></TableCell>
|
||||
<TableCell>{t('processingDuration', { count: Math.round(item.durationSeconds) })}</TableCell>
|
||||
@@ -185,6 +192,7 @@ export default function AdminProcessingPage() {
|
||||
<Typography>{t('processingPage', { page, total: Math.max(1, Math.min(1000, Math.ceil(visibleListing.total / 25))) })}</Typography>
|
||||
<Button disabled={page >= 1000 || page * 25 >= visibleListing.total || busy} onClick={() => { resetSelection(); setPage(value => value + 1); }}>{t('processingNext')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</PageSection>
|
||||
<Box component="details"><Box component="summary" sx={{ p: 1, cursor: 'pointer' }}>{t('processingWorkerFlags')}</Box>
|
||||
<Typography variant="body2">{t('processingWorkerFlagsHelp')}</Typography><Typography variant="body2">{t('processingAiDescription')}</Typography>
|
||||
|
||||
@@ -64,6 +64,9 @@ export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const accountDetails = useRef<HTMLDivElement>(null);
|
||||
const accountConnections = useRef<HTMLDivElement>(null);
|
||||
const accountSecurity = useRef<HTMLDivElement>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@@ -136,6 +139,16 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<PageSection surface title={t("accountWorkspaceTitle")} description={t("accountWorkspaceBody")}>
|
||||
<Box component="nav" aria-label={t("accountWorkspaceNavigation")} sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{([
|
||||
[accountDetails, t("profileAccountSection")],
|
||||
[accountConnections, t("accountWorkspaceConnections")],
|
||||
[accountSecurity, t("accountWorkspaceSecurity")],
|
||||
] as const).map(([target, label]) => <Button key={label} variant="outlined" onClick={() => { target.current?.focus({ preventScroll: true }); target.current?.scrollIntoView({ block: "start" }); }}>{label}</Button>)}
|
||||
<Button href="/settings?section=data">{t("accountWorkspaceData")}</Button>
|
||||
</Box>
|
||||
</PageSection>
|
||||
<CropImageDialog
|
||||
open={cropOpen}
|
||||
file={avatarFile}
|
||||
@@ -233,12 +246,15 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
<Box id="account-connections" ref={accountConnections} role="group" aria-label={t("accountWorkspaceConnections")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", scrollMarginTop: { xs: "9rem", sm: "6rem" } }}>
|
||||
{!careerOnly ? <>
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<MicrosoftAuthCard />
|
||||
</> : null}
|
||||
</Box>
|
||||
|
||||
<Box id="account-details" ref={accountDetails} role="group" aria-label={t("profileAccountSection")} tabIndex={-1} sx={{ scrollMarginTop: { xs: "9rem", sm: "6rem" } }}>
|
||||
<PageSection surface title={t("profileAccountSection")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiFormHelperText-root": { overflowWrap: "anywhere" }, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{!careerOnly && !isLocal ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profileReadOnlyInfo")}</Alert> : null}
|
||||
@@ -337,6 +353,8 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
</Box>
|
||||
<Box id="account-security" ref={accountSecurity} role="group" aria-label={t("accountWorkspaceSecurity")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", scrollMarginTop: { xs: "9rem", sm: "6rem" } }}>
|
||||
{!careerOnly ? <PageSection surface title={t("profileChangePassword")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
|
||||
{!careerOnly && !isLocal ? <Typography sx={{ gridColumn: "1 / -1", color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
@@ -378,6 +396,7 @@ export default function ProfilePage() {
|
||||
|
||||
{!careerOnly && isLocal ? <TwoFactorSettingsCard /> : null}
|
||||
{!careerOnly && isLocal ? <SessionsSettingsCard /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user