feat(ui): connect account controls and processing navigation

This commit is contained in:
cesnimda
2026-11-03 19:54:03 +01:00
parent f0c8c6d321
commit 773c943165
154 changed files with 3157 additions and 15 deletions
@@ -0,0 +1,95 @@
// Actual exported React views; all API responses controlled. No credentials or real account operations.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/account-admin/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], exportDenials: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
const processingOnly = Boolean(process.env.A3_PROCESSING_ONLY);
try {
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
const session = { id: 'current', deviceLabel: 'Synthetic current device', isCurrentSession: true, createdAtUtc: '2026-11-03T10:00:00Z', lastSeenAtUtc: '2026-11-03T10:00:00Z', expiresAtUtc: '2026-12-03T10:00:00Z' };
const operation = { id: '11111111-1111-4111-8111-111111111111', owner: { id: 'a3-admin-owner', name: 'Alex Sample', email: 'alex@example.test' }, taskType: 'cv.process', status: 'failed', stage: 'cv_extracting', attemptCount: 1, maxAttempts: 3, createdAtUtc: '2026-11-03T10:00:00Z', startedAtUtc: null, completedAtUtc: null, lastHeartbeatAtUtc: null, durationSeconds: 8, worker: 'document', workerEnabled: false, failureCode: 'parser_failed', lastFailureAtUtc: '2026-11-03T10:00:08Z', lastFailureStage: 'cv_extracting', job: { id: 42, title: 'Recorded teacher application', company: 'Synthetic school', canOpen: false }, document: { importId: 7, name: 'Recorded teacher CV.pdf', type: 'application/pdf', bytes: 500, pages: 2, state: 'failed' } };
const listing = { items: [operation], total: 1, counts: { failed: 1 }, workers: [{ name: 'CvDocumentImports', flagEnabled: false, individualOperationsRecorded: true }] };
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
if (req.method() === 'POST' && p === '/api/export/account') {
report.exportDenials.push({ width, language, mode }); return route.fulfill({ status: 403, contentType: 'application/problem+json', body: JSON.stringify({ detail: 'Controlled recent-sign-in denial.' }) });
}
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
const fixtures = {
'/api/auth/config': { requireAuth: true, allowRegistration: false },
'/api/auth/me': { id: 'a3-admin-owner', sessionContext: 'a3-admin-session', provider: 'local', firstName: 'Alex', lastName: 'Sample', userName: 'alex', displayName: 'Alex Sample', email: 'alex@example.test', roles: ['Admin'], plan: 'pro', entitlements: { ai: false, proThemes: true }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en', googleLink: { linked: false }, microsoftLink: { linked: false } },
'/api/auth/email-change': { pendingEmail: null }, '/api/auth/2fa/status': { enabled: true, enabledAtUtc: '2026-11-03T10:00:00Z' }, '/api/auth/2fa/trusted-devices': [],
'/api/auth/sessions': [session, { ...session, id: 'other', deviceLabel: 'Synthetic other device', isCurrentSession: false }],
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 }, '/api/notification-settings': { emailFollowUpRemindersEnabled: true },
'/api/account-lifecycle/status': { deletionEnabled: true, deletionStatus: 'active', requiredConfirmation: 'DELETE alex@example.test', request: null },
'/api/gmail/status': { connected: false, configured: false }, '/api/microsoft-graph/status': { connected: false, configured: false }, '/api/imap/status': { connected: false, configured: false },
'/api/users': [{ id: 'a3-admin-owner', email: 'alex@example.test', userName: 'alex', emailConfirmed: true, roles: ['Admin'], isCurrentUser: true, canRemoveAdmin: false }],
'/api/admin/audit': [], '/api/admin/processing': listing,
[`/api/admin/processing/${operation.id}`]: operation,
[`/api/admin/processing/${operation.id}/events`]: { items: [{ id: '9007199254740993', atUtc: '2026-11-03T10:00:08Z', kind: 'Failed', status: 'failed', stage: 'cv_extracting', attempt: 1, failureCode: 'parser_failed' }], hasMore: false, retentionDays: 30 },
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ detail: 'Controlled offline read unavailable.' }) });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
};
if (!processingOnly) {
await page.goto('http://localhost:4338/profile');
const name = page.getByRole('textbox', { name: t('profileDisplayName'), exact: true }); await expect(name).toHaveValue('Alex Sample'); await name.fill('Unsaved account name');
const nav = page.getByRole('navigation', { name: t('accountWorkspaceNavigation'), exact: true });
for (const [id, key] of [['account-details', 'profileAccountSection'], ['account-connections', 'accountWorkspaceConnections'], ['account-security', 'accountWorkspaceSecurity']]) {
await nav.getByRole('button', { name: t(key), exact: true }).press('Enter'); await expect(page.locator(`#${id}`)).toBeFocused(); await expect(name).toHaveValue('Unsaved account name');
}
await scan('account');
await page.getByRole('button', { name: t('sessionsRevokeOthers'), exact: true }).click(); const confirmation = page.getByRole('dialog');
await expect(confirmation).toContainText(t('sessionsRevokeOthersConfirmTitle')); await scan('session-confirmation'); await confirmation.getByRole('button', { name: t('cancel'), exact: true }).click();
await nav.getByRole('link', { name: t('accountWorkspaceData'), exact: true }).click(); await expect(page.getByRole('tabpanel', { name: t('settingsTabBackup'), exact: true })).toBeVisible();
await page.getByRole('button', { name: t('accountExportDownload'), exact: true }).click(); await expect(page.getByText(t('accountExportFailed'), { exact: true })).toBeVisible();
await page.getByRole('button', { name: t('accountDeleteButton'), exact: true }).click(); const deletion = page.getByRole('dialog'); await expect(deletion).toContainText('DELETE alex@example.test'); await scan('data-confirmation'); await deletion.getByRole('button', { name: t('cancel'), exact: true }).click();
await page.goto('http://localhost:4338/settings/connected-accounts'); await expect(page.getByRole('heading', { name: t('connectedAccounts'), exact: true })).toBeVisible(); await scan('connections');
await page.goto('http://localhost:4338/admin/users'); await expect(page.getByRole('button', { name: t('adminUsersRemoveAdmin'), exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: t('adminUsersDelete'), exact: true })).toBeDisabled(); await scan('last-admin');
await page.goto('http://localhost:4338/admin/audit'); await expect(page.getByText(t('adminAuditNoEvents'), { exact: true })).toBeVisible(); await scan('audit-empty');
await page.goto('http://localhost:4338/admin/system'); await expect(page.getByText(t('adminSystemLoadFailed'), { exact: true })).toBeVisible(); await scan('system-unavailable');
}
await page.goto('http://localhost:4338/admin/processing'); await expect(page.getByRole('button', { name: t('processingViewDetails').replace('{id}', operation.id), exact: true })).toBeVisible();
for (const key of ['processingStatus', 'processingTask', 'processingWorkerFilter', 'processingFailureCode']) {
const control = page.getByRole('combobox', { name: t(key), exact: true });
const label = page.locator(`label[for=${JSON.stringify(await control.getAttribute('id'))}]`);
await expect.poll(async () => { const a = await label.boundingBox(), b = await control.boundingBox(); return a && b && a.y + a.height < b.y + b.height / 2; }, { message: `${key}: label must stay above the selected text` }).toBe(true);
}
assert.ok((await page.getByRole('columnheader', { name: t('processingElapsed'), exact: true }).boundingBox()).width >= 96, 'Duration column remains readable');
await page.getByRole('button', { name: t('processingPause'), exact: true }).click(); await page.getByRole('button', { name: t('processingJumpResults'), exact: true }).press('Enter'); await expect(page.getByRole('group', { name: t('processingResults'), exact: true })).toBeFocused();
await page.getByRole('button', { name: t('processingReturnFilters'), exact: true }).click(); await expect(page.getByRole('form', { name: t('processingFilters'), exact: true })).toBeFocused();
await page.getByRole('button', { name: t('processingViewDetails').replace('{id}', operation.id), exact: true }).click(); await expect(page.getByRole('heading', { name: t('processingDetails'), exact: true })).toBeFocused();
await expect(page.getByRole('link', { name: operation.job.title, exact: true })).toHaveCount(0); await scan('processing-detail');
report.journeys.push({ width, language, mode, draftPreservedAcrossKeyboardSections: processingOnly ? 'NOT RUN' : 'PASS', revocationAndDeletionCancelled: processingOnly ? 'NOT RUN' : 'PASS', recentExportDenial: processingOnly ? 'NOT RUN' : 'PASS', lastAdminActionsDisabled: processingOnly ? 'NOT RUN' : 'PASS', processingKeyboardAndOwnerLink: 'PASS', processingFilterLabelsAndDurationGeometry: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, configurations.length * (processingOnly ? 1 : 8)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.exportDenials.length, processingOnly ? 0 : configurations.length);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures; no mutation requests.`);
} finally {
fs.writeFileSync(path.join(out, processingOnly ? 'PROCESSING.json' : process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Every API response is synthetic. Account export is a controlled 403; revocation/deletion confirmations are cancelled, not executed.', 'System monitor capture is the controlled unavailable state, audit is empty; detailed success/action scenarios are unit tests, not live qualification.', 'No real credentials, provider activation, model, native device, integrated security, production or release acceptance.'] }, null, 2));
await browser.close();
}
@@ -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);
+20
View File
@@ -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.",
+19
View File
@@ -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,
+8 -2
View File
@@ -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>
+19
View File
@@ -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>
);
}