From d61fe5726f381b1d5d5a57e7f1833bad2862f821 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 20:55:34 -0700 Subject: [PATCH] Migrations: the Migrations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Management › Directory › Migrations (spec: migrations-screen). An administrator moves mailboxes into this server from IMAP, JMAP, Microsoft 365 or Google Workspace, and the server runs inbuxa-migrate for them. "Guided or manual?" each time. The guide is inbuxa-migrate's own five steps: where the mail is now, which accounts (pasted or a CSV), where to, a preview, then the move. Manual is one form, with preview and move from the list. Each migration says what moved, shows a running one's progress, and says something only when it needs attention: a failed run, accounts that didn't finish (with why), a run cut off by a restart, passwords waiting, a run gone quiet. The source's secret is asked for when a run needs it and sent with that action; the server keeps it in memory only. "New secret…" replaces one that was wrong. The new accounts' passwords download once, and so does the report. Closing asks first, and says the copies go. The audit log names a migration run's changes, and the data inventory knows files in the data directory. --- src/components/layout/MainContent.tsx | 4 + src/features/audit/auditLog.ts | 32 +- src/features/audit/labels.ts | 3 +- src/features/compliance/DataInventoryPage.tsx | 11 +- src/features/compliance/inventory.ts | 1 + src/features/migrations/MigrationsPage.tsx | 1353 +++++++++++++++++ src/features/migrations/api.ts | 131 ++ src/features/migrations/model.test.ts | 264 ++++ src/features/migrations/model.ts | 378 +++++ src/help/texts.ts | 23 + src/i18n/en.json | 147 +- src/lib/layout.ts | 2 + src/lib/layoutRules.test.ts | 7 + 13 files changed, 2332 insertions(+), 24 deletions(-) create mode 100644 src/features/migrations/MigrationsPage.tsx create mode 100644 src/features/migrations/api.ts create mode 100644 src/features/migrations/model.test.ts create mode 100644 src/features/migrations/model.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 7b1bc23..a21bed1 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -135,6 +135,10 @@ const CUSTOM_PAGES: Record = { () => import('@/features/relays/RelaysPage'), (m) => m.RelaysPage, ), + Migrations: onDemand( + () => import('@/features/migrations/MigrationsPage'), + (m) => m.MigrationsPage, + ), Journal: onDemand( () => import('@/features/journal/JournalPage'), (m) => m.JournalPage, diff --git a/src/features/audit/auditLog.ts b/src/features/audit/auditLog.ts index 0f49800..0e00b2f 100644 --- a/src/features/audit/auditLog.ts +++ b/src/features/audit/auditLog.ts @@ -17,15 +17,7 @@ export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap'; export const PAGE_SIZE = 50; export type AuditAction = - | 'create' - | 'update' - | 'destroy' - | 'signIn' - | 'signInFailed' - | 'accountAccess' - | 'blobAccess' - | 'export' - | 'verify'; + 'create' | 'update' | 'destroy' | 'signIn' | 'signInFailed' | 'accountAccess' | 'blobAccess' | 'export' | 'verify'; export const AUDIT_ACTIONS: AuditAction[] = [ 'create', @@ -52,7 +44,8 @@ export type AuditVia = | { kind: 'oauth'; client: string } | { kind: 'directory' } | { kind: 'master'; accountId?: string; name: string } - | { kind: 'recovery' }; + | { kind: 'recovery' } + | { kind: 'migration'; id: number }; export interface AuditTarget { kind: string; @@ -114,7 +107,10 @@ export const EMPTY_FILTER: AuditFilterInput = { /** The server's filter object: every condition must hold. */ export function toServerFilter(input: AuditFilterInput): Record { const filter: Record = {}; - if (input.from) filter.after = localDayStart(input.from).toISOString().replace(/\.\d{3}Z$/, 'Z'); + if (input.from) + filter.after = localDayStart(input.from) + .toISOString() + .replace(/\.\d{3}Z$/, 'Z'); if (input.to) { const end = localDayStart(input.to); end.setDate(end.getDate() + 1); @@ -153,11 +149,7 @@ export interface AuditPage { } /** One page of events, newest first. */ -export async function queryEvents( - input: AuditFilterInput, - position: number, - signal?: AbortSignal, -): Promise { +export async function queryEvents(input: AuditFilterInput, position: number, signal?: AbortSignal): Promise { const accountId = getAccountId('x:Account'); const responses = await jmapRequest( [ @@ -208,11 +200,9 @@ export function normalize(raw: Partial & { id: string }): AuditEvent export async function fetchKeepForDays(): Promise { const accountId = getAccountId('x:Account'); - const responses = await jmapRequest( - [['inbuxa:AuditSettings/get', { accountId, ids: null }, '0']], - undefined, - [INBUXA_CAPABILITY], - ); + const responses = await jmapRequest([['inbuxa:AuditSettings/get', { accountId, ids: null }, '0']], undefined, [ + INBUXA_CAPABILITY, + ]); const [name, result] = responses[0] ?? []; if (name !== 'inbuxa:AuditSettings/get') throw methodError(name, result); return (result as { list?: { keepForDays?: number }[] }).list?.[0]?.keepForDays ?? 730; diff --git a/src/features/audit/labels.ts b/src/features/audit/labels.ts index 2868235..089b89b 100644 --- a/src/features/audit/labels.ts +++ b/src/features/audit/labels.ts @@ -50,6 +50,8 @@ export function viaLabel(t: T, via: AuditVia | null): string { return t('audit.via.master', 'as master user {{name}}', { name: via.name }); case 'recovery': return t('audit.via.recovery', 'recovery administrator'); + case 'migration': + return t('audit.via.migration', 'by a migration run'); } } @@ -57,4 +59,3 @@ export function viaLabel(t: T, via: AuditVia | null): string { function shortClient(client: string): string { return client.length > 24 ? `${client.slice(0, 12)}…` : client; } - diff --git a/src/features/compliance/DataInventoryPage.tsx b/src/features/compliance/DataInventoryPage.tsx index e82d686..7d339d0 100644 --- a/src/features/compliance/DataInventoryPage.tsx +++ b/src/features/compliance/DataInventoryPage.tsx @@ -34,7 +34,16 @@ import { } from './inventory'; const CATEGORIES = ['identifier', 'contact', 'network', 'content', 'metadata', 'credential']; -const PLACES = ['data-store', 'blob-store', 'search-store', 'in-memory-store', 'memory', 'log-file', 'external']; +const PLACES = [ + 'data-store', + 'blob-store', + 'search-store', + 'in-memory-store', + 'memory', + 'log-file', + 'data-directory', + 'external', +]; const ANY = '__any'; type Load = { kind: 'loading' } | { kind: 'ready'; inventory: Inventory } | { kind: 'error'; message: string }; diff --git a/src/features/compliance/inventory.ts b/src/features/compliance/inventory.ts index 42a523b..f350aa4 100644 --- a/src/features/compliance/inventory.ts +++ b/src/features/compliance/inventory.ts @@ -192,6 +192,7 @@ const WHERE_NAMES: Record = { 'in-memory-store': 'in-memory store', memory: 'memory', 'log-file': 'log files', + 'data-directory': 'files in the data directory', external: 'sent elsewhere', }; diff --git a/src/features/migrations/MigrationsPage.tsx b/src/features/migrations/MigrationsPage.tsx new file mode 100644 index 0000000..133bbe8 --- /dev/null +++ b/src/features/migrations/MigrationsPage.tsx @@ -0,0 +1,1353 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Directory › Migrations (spec: migrations-screen; + * inbuxa-server docs/spec/features/migrations.md). An administrator moves + * mailboxes into this server from another one, and the server runs + * inbuxa-migrate for them. "Guided or manual?" each time: the guide is + * inbuxa-migrate's own five steps (where the mail is now, which accounts, + * where to, preview, move); manual is one form. Each migration says what it + * moved and, only when something needs attention, what. The source's secret + * is asked for when a run needs it, sent with that action and kept in the + * server's memory only. + */ + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + AlertTriangle, + ArrowRightLeft, + Download, + Eye, + FileUp, + KeyRound, + Loader2, + Pencil, + Plus, + RotateCcw, + Square, + Trash2, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { LaunchChoice } from '@/components/wizard/LaunchChoice'; +import { WizardNote, WizardShell } from '@/components/wizard/WizardShell'; +import { HelpTip } from '@/help/HelpTip'; +import { fieldHelp } from '@/help/texts'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { formatSize } from '@/features/hold/legalHold'; +import { + act, + createMigration, + downloadCsv, + fetchMigrations, + MigrationsUnavailable, + SecretNeeded, + takePasswords, + updateMigration, +} from './api'; +import { + actions, + attention, + emptyForm, + fileName, + formProblem, + looksLikeGoogleKey, + ordered, + parseAccounts, + percent, + resumes, + secretKind, + startable, + toCreate, + toForm, + toPatch, + type Attention, + type FormProblem, + type Migration, + type MigrationForm, + type RunKind, + type SourceKind, +} from './model'; + +type Load = { kind: 'loading' } | { kind: 'ready'; migrations: Migration[] } | { kind: 'error'; message: string }; + +/** While a run goes, the page looks again this often. */ +const RUNNING_REFRESH_MS = 3_000; + +function when(iso?: string | null): string { + return iso ? new Date(iso).toLocaleString() : ''; +} + +function errorText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function failed(title: string, e: unknown) { + toast({ variant: 'destructive', title, description: errorText(e) }); +} + +/** Dialogs open on their first field, not on its ⓘ, whose help would pop open. */ +function focusFirstField(e: Event) { + e.preventDefault(); + (e.target as HTMLElement | null)?.querySelector('input, textarea')?.focus(); +} + +/** A label with the option's ⓘ, keyed `inbuxa:Migration.`. */ +function Field({ field, label, children }: { field: string; label: string; children: ReactNode }) { + const id = `inbuxa:Migration.${field}`; + return ( + + ); +} + +function ToggleField({ + field, + label, + checked, + onChange, + disabled, +}: { + field: string; + label: string; + checked: boolean; + onChange: (on: boolean) => void; + disabled?: boolean; +}) { + const id = `inbuxa:Migration.${field}`; + return ( +
+ + {label} + + + +
+ ); +} + +function kindLabel(t: TFunction, kind: SourceKind): string { + switch (kind) { + case 'imap': + return t('migrations.kind.imap', 'IMAP server'); + case 'jmap': + return t('migrations.kind.jmap', 'JMAP server (inbuxa or Stalwart)'); + case 'microsoft': + return t('migrations.kind.microsoft', 'Microsoft 365'); + case 'google': + return t('migrations.kind.google', 'Google Workspace'); + } +} + +function problemText(t: TFunction, problem: FormProblem): string { + switch (problem) { + case 'imapUrl': + return t('migrations.problem.imapUrl', 'Give the IMAP server as imaps://host, or imap://host.'); + case 'imapLogin': + return t( + 'migrations.problem.imapLogin', + 'The login has to name each account with {email} or {local}, such as {email}*master.', + ); + case 'jmapUrl': + return t('migrations.problem.jmapUrl', 'Give the JMAP server’s address, starting https://.'); + case 'microsoft': + return t('migrations.problem.microsoft', 'Give the Microsoft 365 tenant and the app’s client id.'); + case 'noAccounts': + return t('migrations.problem.noAccounts', 'List the accounts to move.'); + case 'badAccounts': + return t('migrations.problem.badAccounts', 'Some lines aren’t addresses. Fix or remove them.'); + case 'tooManyAccounts': + return t('migrations.problem.tooManyAccounts', 'At most 10,000 accounts in one migration. Split it.'); + case 'parallel': + return t('migrations.problem.parallel', 'Move from 1 to 16 accounts at once.'); + } +} + +function sourceLine(t: TFunction, m: Migration): string { + switch (m.sourceKind) { + case 'imap': + case 'jmap': + return `${kindLabel(t, m.sourceKind)} · ${m.source.url}`; + case 'microsoft': + return `${kindLabel(t, m.sourceKind)} · ${m.source.tenant}`; + case 'google': + return kindLabel(t, m.sourceKind); + } +} + +function runLabel(t: TFunction, run: RunKind): string { + switch (run) { + case 'preview': + return t('migrations.run.preview', 'Preview'); + case 'move': + return t('migrations.run.move', 'Move'); + case 'finalPass': + return t('migrations.run.finalPass', 'Final pass'); + } +} + +function resultLabel(t: TFunction, m: Migration): string | null { + switch (m.result) { + case 'done': + return t('migrations.result.done', 'done'); + case 'unfinished': + return t('migrations.result.unfinished', 'finished, with accounts left over'); + case 'failed': + return t('migrations.result.failed', 'failed'); + case 'stopped': + return t('migrations.result.stopped', 'stopped'); + case 'interrupted': + return t('migrations.result.interrupted', 'cut off'); + default: + return null; + } +} + +function attentionText(t: TFunction, a: Attention): string { + switch (a.kind) { + case 'failed': + return a.error + ? t('migrations.attention.failedWith', 'The last run failed: {{error}}', { error: a.error }) + : t('migrations.attention.failed', 'The last run failed.'); + case 'unfinished': + return t('migrations.attention.unfinished', { + count: a.count, + defaultValue_one: + '{{count}} account didn’t finish. See why below, then run it again: it carries on. If the source refused the login, give it a new secret first.', + defaultValue_other: + '{{count}} accounts didn’t finish. See why below, then run it again: it carries on. If the source refused the login, give it a new secret first.', + }); + case 'interrupted': + return t( + 'migrations.attention.interrupted', + 'The run was cut off when its server restarted. Run it again: it carries on, and asks for the source’s secret again.', + ); + case 'passwords': + return t( + 'migrations.attention.passwords', + 'The new accounts’ passwords are waiting. Download them once: the server then deletes its copy.', + ); + case 'quiet': + return t( + 'migrations.attention.quiet', + 'Nothing heard from the run since {{since}}. If its server went down, it will show as cut off.', + { since: when(a.since) }, + ); + } +} + +// --- The answers -------------------------------------------------------------- + +/** Where the mail is now: the guide's step 1. */ +function SourceFields({ + form, + set, + locked, +}: { + form: MigrationForm; + set: (patch: Partial) => void; + locked?: boolean; +}) { + const { t } = useTranslation(); + return ( +
+ {locked && ( +

+ {t('migrations.sourceLocked', 'Accounts were moved from this source, so it stays as it is.')} +

+ )} + + + + {(form.sourceKind === 'imap' || form.sourceKind === 'jmap') && ( + + set({ url: e.target.value })} + placeholder={form.sourceKind === 'imap' ? 'imaps://mail.example.org' : 'https://mail.example.org'} + disabled={locked} + autoComplete="off" + /> + + )} + {form.sourceKind === 'imap' && ( + <> + + set({ login: e.target.value })} + placeholder="{email}*master" + disabled={locked} + autoComplete="off" + /> + + set({ cleartext })} + disabled={locked} + /> + + )} + {form.sourceKind === 'jmap' && ( + + set({ login: e.target.value })} + disabled={locked} + autoComplete="off" + /> + + )} + {form.sourceKind === 'microsoft' && ( + <> + + set({ tenant: e.target.value })} + placeholder="contoso.onmicrosoft.com" + disabled={locked} + autoComplete="off" + /> + + + set({ clientId: e.target.value })} + disabled={locked} + autoComplete="off" + /> + + + )} + {form.sourceKind === 'google' && ( +

+ {t( + 'migrations.googleNote', + 'The service account’s key is asked for when a run starts, and kept in the server’s memory only. It needs domain-wide delegation for the Gmail scope.', + )} +

+ )} +
+ ); +} + +/** Which accounts: the guide's step 2, pasted or read from a CSV. */ +function AccountsField({ form, set }: { form: MigrationForm; set: (patch: Partial) => void }) { + const { t } = useTranslation(); + const file = useRef(null); + const { accounts, bad } = useMemo(() => parseAccounts(form.accounts), [form.accounts]); + const readFile = async (picked: File | undefined) => { + if (!picked) return; + try { + set({ accounts: await picked.text() }); + } catch (e) { + failed(t('migrations.csvFailed', 'Couldn’t read the file'), e); + } + }; + return ( +
+ +