From 5b30bacf10f329a612e9931296fd9bf5c4c149d8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 10 May 2026 01:09:00 +0200 Subject: [PATCH] feat: redesign review step with grouped summary and advanced toggle --- app/setup/page.tsx | 208 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 167 insertions(+), 41 deletions(-) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 5e67d8a5..bc6fb665 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { CheckCircle2, AlertTriangle, AlertCircle } from 'lucide-react'; +import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -675,6 +675,21 @@ function ServerStep({ config, setConfig, onNext }: Pick + {isInsecureHttpUrl(config.jmapServerUrl) && ( +
+
+ +
+
+

+ This URL uses plain HTTP. +

+

+ Passwords and email contents will travel unencrypted between users and your server. Use https:// in production — terminate TLS on the mail server or a reverse proxy in front of it. +

+
+
+ )} {probe && probe.url === config.jmapServerUrl && ( probe.status === 'jmap_detected' ? (
@@ -1346,50 +1361,143 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack } } + const passwordsMatch = adminConfirm.length > 0 && adminPassword === adminConfirm; + const passwordTooShort = adminPassword.length > 0 && adminPassword.length < 8; + const canSubmit = + !submitting && + adminPassword.length >= 8 && + passwordsMatch; + return ( -
- -
- - - {config.jmapServers.length > 0 && ( - + + + {/* Summary card with grouped sections */} +
+ } title="Server"> + + + {config.jmapServers.length > 0 && ( + + )} + + + + } title="Authentication"> + s.id).join(', ') + - (config.jmapServerAutoPickByDomain ? ' (auto-pick by domain)' : '') + config.oauthEnabled + ? config.oauthOnly + ? 'OAuth only' + : 'Password + OAuth' + : 'Password only' } /> - )} - - - - - - + {config.oauthEnabled && config.oauthClientId && ( + + )} + + + } title="Security"> + + + + + + } title="Logging"> + + + + + } title="Branding"> + + {config.loginCompanyName && ( + + )} +
- - - - - - + {/* Admin password card */} +
+
+
+ +
+
+
Choose an admin password
+

+ You'll use this to sign in at /admin. Minimum 8 characters. +

+
+
+
+
+ + + {passwordTooShort && ( +

At least 8 characters.

+ )} +
+
+ + + {adminConfirm.length > 0 && !passwordsMatch && ( +

Passwords don't match.

+ )} + {passwordsMatch && adminPassword.length >= 8 && ( +

Looks good.

+ )} +
+
+
- + {/* Advanced */} +
+ + Advanced + Show + Hide + +
+ +
+
{localError && ( -

{localError}

+
+
+ +
+
+

{localError}

+
+
)}
Back - + {submitting ? 'Applying…' : 'Apply & Finish'}
@@ -1397,6 +1505,29 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack ); } +function SummaryGroup({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { + return ( +
+
+ {icon} + {title} +
+
{children}
+
+ ); +} + +function SummaryRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value || } + +
+ ); +} + // ─── Atoms ──────────────────────────────────────────────────────────────── function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { @@ -1520,15 +1651,6 @@ function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; ); } -function Row({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - // ─── Helpers ────────────────────────────────────────────────────────────── function mergePartial(prev: WizardConfig, partial: Record): WizardConfig { @@ -1603,6 +1725,10 @@ function hasAnyBranding(c: WizardConfig): boolean { ); } +function isInsecureHttpUrl(url: string): boolean { + return /^http:\/\//i.test(url.trim()); +} + function humanError(e: unknown): string { if (e instanceof Error) return e.message; if (typeof e === 'string') return e;