feat: require explicit confirmation when JMAP probe finds no session
This commit is contained in:
+87
-11
@@ -473,14 +473,21 @@ function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }:
|
|||||||
|
|
||||||
// ─── Server step ─────────────────────────────────────────────────────────
|
// ─── Server step ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url';
|
||||||
|
|
||||||
function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 'setConfig' | 'onNext'>) {
|
function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 'setConfig' | 'onNext'>) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [probe, setProbe] = useState<string | null>(null);
|
const [probe, setProbe] = useState<{ status: ProbeStatus; message: string; url: string } | null>(null);
|
||||||
const [probing, setProbing] = useState(false);
|
const [probing, setProbing] = useState(false);
|
||||||
|
// When the server is reachable but isn't a JMAP endpoint, the wizard
|
||||||
|
// shows a "looks wrong, are you sure?" inline confirmation. The flag
|
||||||
|
// resets every time the URL changes.
|
||||||
|
const [confirmedNonJmap, setConfirmedNonJmap] = useState(false);
|
||||||
|
|
||||||
async function testJmap() {
|
async function testJmap(): Promise<{ status: ProbeStatus; message: string; url: string } | null> {
|
||||||
setProbe(null);
|
setProbe(null);
|
||||||
setProbing(true);
|
setProbing(true);
|
||||||
|
setConfirmedNonJmap(false);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/setup/test-jmap', {
|
const res = await apiFetch('/api/setup/test-jmap', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -488,15 +495,22 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
body: JSON.stringify({ url: config.jmapServerUrl }),
|
body: JSON.stringify({ url: config.jmapServerUrl }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
let entry: { status: ProbeStatus; message: string; url: string };
|
||||||
if (data.status === 'jmap_detected') {
|
if (data.status === 'jmap_detected') {
|
||||||
setProbe(`JMAP server confirmed at ${data.endpoint}`);
|
entry = { status: 'jmap_detected', message: `JMAP server confirmed at ${data.endpoint}`, url: config.jmapServerUrl };
|
||||||
} else if (data.status === 'reachable_no_jmap') {
|
} else if (data.status === 'reachable_no_jmap') {
|
||||||
setProbe(`Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`);
|
entry = { status: 'reachable_no_jmap', message: `Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`, url: config.jmapServerUrl };
|
||||||
|
} else if (data.status === 'invalid_url') {
|
||||||
|
entry = { status: 'invalid_url', message: data.message ?? 'URL is not valid.', url: config.jmapServerUrl };
|
||||||
} else {
|
} else {
|
||||||
setProbe(data.message ?? 'Could not reach server.');
|
entry = { status: 'unreachable', message: data.message ?? 'Could not reach server.', url: config.jmapServerUrl };
|
||||||
}
|
}
|
||||||
|
setProbe(entry);
|
||||||
|
return entry;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setProbe(humanError(e));
|
const entry = { status: 'unreachable' as ProbeStatus, message: humanError(e), url: config.jmapServerUrl };
|
||||||
|
setProbe(entry);
|
||||||
|
return entry;
|
||||||
} finally {
|
} finally {
|
||||||
setProbing(false);
|
setProbing(false);
|
||||||
}
|
}
|
||||||
@@ -556,6 +570,27 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
if (hasRowErrors) return;
|
if (hasRowErrors) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Auto-probe the URL on Next so the operator can't accidentally
|
||||||
|
// skip past a wrong URL. If the URL changed since the last probe,
|
||||||
|
// re-run; otherwise reuse the cached result.
|
||||||
|
let result = probe && probe.url === config.jmapServerUrl ? probe : null;
|
||||||
|
if (!result) {
|
||||||
|
result = await testJmap();
|
||||||
|
}
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
// Hard-fail on these — no "are you sure" since they can't be right.
|
||||||
|
if (result.status === 'invalid_url' || result.status === 'unreachable') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft warning: server responded but it's not a JMAP endpoint at the
|
||||||
|
// standard paths. Could be legitimate (reverse proxy routing) so we
|
||||||
|
// ask for explicit confirmation rather than blocking.
|
||||||
|
if (result.status === 'reachable_no_jmap' && !confirmedNonJmap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await onNext('server', {
|
await onNext('server', {
|
||||||
appName: config.appName,
|
appName: config.appName,
|
||||||
jmapServerUrl: config.jmapServerUrl,
|
jmapServerUrl: config.jmapServerUrl,
|
||||||
@@ -580,21 +615,49 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={config.jmapServerUrl}
|
value={config.jmapServerUrl}
|
||||||
onChange={(v) => setConfig({ ...config, jmapServerUrl: v })}
|
onChange={(v) => {
|
||||||
|
setConfig({ ...config, jmapServerUrl: v });
|
||||||
|
// Any URL change invalidates the previous probe result.
|
||||||
|
if (probe && probe.url !== v) {
|
||||||
|
setProbe(null);
|
||||||
|
setConfirmedNonJmap(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
placeholder="https://"
|
placeholder="https://"
|
||||||
type="url"
|
type="url"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={testJmap}
|
onClick={() => { void testJmap(); }}
|
||||||
disabled={!config.jmapServerUrl || probing}
|
disabled={!config.jmapServerUrl || probing}
|
||||||
className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted disabled:opacity-50"
|
className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{probing ? 'Testing…' : 'Test'}
|
{probing ? 'Testing…' : 'Test'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{probe && <p className="text-xs text-muted-foreground mt-1.5">{probe}</p>}
|
{probe && probe.url === config.jmapServerUrl && (
|
||||||
|
probe.status === 'reachable_no_jmap' ? (
|
||||||
|
<div className="mt-2 rounded-md border border-warning/30 bg-warning/10 p-3 text-xs">
|
||||||
|
<p className="font-medium text-warning">{probe.message}</p>
|
||||||
|
<p className="text-warning/80 mt-1">
|
||||||
|
This is OK if a reverse proxy routes JMAP traffic separately (e.g. webmail and mail server share a domain), but more often it means the URL is wrong.
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmedNonJmap}
|
||||||
|
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-warning">I'm sure this is the right URL — continue anyway.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : probe.status === 'jmap_detected' ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">✓ {probe.message}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-destructive mt-1.5">{probe.message}</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Additional servers (optional) */}
|
{/* Additional servers (optional) */}
|
||||||
@@ -682,8 +745,21 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Footer>
|
<Footer>
|
||||||
<PrimaryButton type="submit" disabled={submitting || !config.jmapServerUrl || hasRowErrors}>
|
<PrimaryButton
|
||||||
{submitting ? 'Saving…' : 'Next'}
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
!config.jmapServerUrl ||
|
||||||
|
hasRowErrors ||
|
||||||
|
// Once probed, gate the button on the result so the user gets a
|
||||||
|
// visual signal rather than a silent no-op when clicking Next.
|
||||||
|
(probe?.url === config.jmapServerUrl &&
|
||||||
|
((probe.status === 'reachable_no_jmap' && !confirmedNonJmap) ||
|
||||||
|
probe.status === 'invalid_url' ||
|
||||||
|
probe.status === 'unreachable'))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving…' : probing ? 'Testing…' : 'Next'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</Footer>
|
</Footer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user