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 ( +
+ +