UX decision (carnet #78): the human UI of IdeA is French by default, uniform per surface — proper nouns and technical acronyms (URL, LAN, IP/CIDR, HTTPS, API, CLI…) stay as-is. Settings mixed English (AI Profiles, Deployment) with French (Appareils, frozen by UX for #77). Renames the Settings menu/nav, the Profils IA and Déploiement panels (titles, actions, states, help text) to French, per the carnet's exhaustive list. Updates the affected tests accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
|
* `Settings → Deployment` (ticket #68) — turn IdeA Desktop into a server other
|
|
* devices can reach, without a command line.
|
|
*
|
|
* Pure presentation over {@link useDeployment}; no `invoke()`, and no address is
|
|
* ever derived here — LAN candidates and the upstream URL come from the backend
|
|
* preview (`DesktopServerGateway`).
|
|
*
|
|
* Two UX invariants this screen exists to protect:
|
|
*
|
|
* - **The exposure mode is a decision, not a setting.** It is rendered as radio
|
|
* *cards* with plain-language consequences, never a technical select, because
|
|
* choosing wrong (proxy elsewhere, mode "on this computer") fails as a silent
|
|
* proxy timeout with no IdeA error to read.
|
|
* - **The authorized-proxy field is not a listen address.** That confusion is
|
|
* the whole reason this screen is worded the way it is; the help text under
|
|
* the field says so explicitly.
|
|
*
|
|
* This screen no longer shows a pairing code (#77). A code is not a property of
|
|
* a running server — it exists only when someone asks for one — so it lives in
|
|
* the Appareils surface next to the devices it authorises. What is left here is
|
|
* a signpost: starting the server from this screen and finding no way to pair is
|
|
* a dead end.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
|
|
import type { ServerExposureMode } from "@/domain";
|
|
import { Button, Field, Input, Panel, cn } from "@/shared";
|
|
import { useDeployment } from "./useDeployment";
|
|
|
|
interface ModeOption {
|
|
mode: ServerExposureMode;
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
/** The three exposure choices, in increasing order of reach. */
|
|
const MODE_OPTIONS: ModeOption[] = [
|
|
{
|
|
mode: "localOnly",
|
|
title: "Cet ordinateur uniquement",
|
|
description:
|
|
"Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.",
|
|
},
|
|
{
|
|
mode: "remoteProxyLocal",
|
|
title: "Accès distant, proxy sur cet ordinateur",
|
|
description:
|
|
"À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.",
|
|
},
|
|
{
|
|
mode: "remoteProxyOtherMachine",
|
|
title: "Accès distant, proxy sur une autre machine",
|
|
description:
|
|
"À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.",
|
|
},
|
|
];
|
|
|
|
const STATE_LABEL: Record<string, string> = {
|
|
stopped: "Arrêté",
|
|
starting: "Démarrage…",
|
|
running: "En cours d'exécution",
|
|
stopping: "Arrêt…",
|
|
failed: "Échec",
|
|
};
|
|
|
|
/** Copy-to-clipboard button; degrades to disabled where the API is absent. */
|
|
function CopyButton({ value, label }: { value: string; label: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const supported =
|
|
typeof navigator !== "undefined" && Boolean(navigator.clipboard);
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-label={label}
|
|
disabled={!supported}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(value).then(
|
|
() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
},
|
|
() => setCopied(false),
|
|
);
|
|
}}
|
|
>
|
|
{copied ? "Copié" : "Copier"}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/** A read-only value the user is meant to copy elsewhere (never editable). */
|
|
function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 truncate rounded-md border border-border bg-raised px-2 py-1.5 font-mono text-xs text-content">
|
|
{value}
|
|
</code>
|
|
<CopyButton value={value} label={copyLabel} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DeploymentSettings() {
|
|
const vm = useDeployment();
|
|
|
|
if (!vm.ready || !vm.settings) {
|
|
return (
|
|
<Panel aria-label="deployment settings" title="Déploiement">
|
|
<p className="text-sm text-muted">Chargement…</p>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
const { settings, status } = vm;
|
|
const running = status.state === "running";
|
|
const remote = settings.mode !== "localOnly";
|
|
const otherMachine = settings.mode === "remoteProxyOtherMachine";
|
|
const transitioning = status.state === "starting" || status.state === "stopping";
|
|
|
|
return (
|
|
<div aria-label="deployment settings" className="flex flex-col gap-4">
|
|
{/* ── Status ────────────────────────────────────────────────────────── */}
|
|
<Panel
|
|
title="Serveur"
|
|
actions={
|
|
running || status.state === "stopping" ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.stop()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
Arrêter
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.start()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
Démarrer
|
|
</Button>
|
|
)
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<p className="flex items-center gap-2 text-sm">
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"size-2 rounded-full",
|
|
running && "bg-success",
|
|
status.state === "failed" && "bg-danger",
|
|
(status.state === "stopped" || transitioning) && "bg-faint",
|
|
)}
|
|
/>
|
|
<span className="text-content">{STATE_LABEL[status.state]}</span>
|
|
</p>
|
|
|
|
{status.localUrl && (
|
|
<p className="text-xs text-muted">
|
|
URL locale : <code className="font-mono text-content">{status.localUrl}</code>
|
|
</p>
|
|
)}
|
|
{status.publicUrl && (
|
|
<p className="text-xs text-muted">
|
|
URL publique : <code className="font-mono text-content">{status.publicUrl}</code>
|
|
</p>
|
|
)}
|
|
|
|
{/* A failure carries the backend's message: it is the correction. */}
|
|
{status.state === "failed" && status.error && (
|
|
<p role="alert" className="text-sm text-danger">
|
|
{status.error.message}
|
|
</p>
|
|
)}
|
|
{vm.actionError && (
|
|
<p role="alert" className="text-sm text-danger">
|
|
{vm.actionError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* ── Exposure (ticket #78 — "Accès réseau" per UX) ────────────────── */}
|
|
<Panel title="Accès réseau">
|
|
<div className="flex flex-col gap-4">
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="mode d'accès réseau"
|
|
className="flex flex-col gap-2"
|
|
>
|
|
{MODE_OPTIONS.map((option) => {
|
|
const selected = settings.mode === option.mode;
|
|
return (
|
|
<label
|
|
key={option.mode}
|
|
className={cn(
|
|
"flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors",
|
|
selected
|
|
? "border-border-strong bg-raised"
|
|
: "border-border hover:bg-raised",
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="exposure-mode"
|
|
className="mt-1"
|
|
checked={selected}
|
|
onChange={() => vm.setMode(option.mode)}
|
|
/>
|
|
<span className="flex flex-col gap-1">
|
|
<span className="text-sm font-semibold text-content">
|
|
{option.title}
|
|
</span>
|
|
<span className="text-xs text-muted">{option.description}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Field label="Port">
|
|
{({ id }) => (
|
|
<Input
|
|
id={id}
|
|
type="number"
|
|
value={String(settings.port)}
|
|
onChange={(e) => vm.setPort(Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
{remote && (
|
|
<Field label="Origine publique" hint="Exemple : https://idea.example.com">
|
|
{({ id, describedBy }) => (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder="https://idea.example.com"
|
|
value={settings.publicOrigin ?? ""}
|
|
onChange={(e) => vm.setPublicOrigin(e.target.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
)}
|
|
|
|
{otherMachine && (
|
|
<>
|
|
{/* Addresses come from the backend probe — never invented here. */}
|
|
<Field
|
|
label="Adresse LAN d'écoute"
|
|
hint="L'adresse de cette machine à laquelle le proxy se connectera."
|
|
>
|
|
{({ id, describedBy }) =>
|
|
vm.candidateLanAddresses.length > 0 ? (
|
|
<select
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-content"
|
|
value={settings.lanBindAddress ?? ""}
|
|
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
|
>
|
|
<option value="">Sélectionner une adresse…</option>
|
|
{vm.candidateLanAddresses.map((address) => (
|
|
<option key={address} value={address}>
|
|
{address}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder="192.168.1.42"
|
|
value={settings.lanBindAddress ?? ""}
|
|
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
|
/>
|
|
)
|
|
}
|
|
</Field>
|
|
|
|
<Field
|
|
label="IP/CIDR du proxy autorisé"
|
|
hint="Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA."
|
|
>
|
|
{({ id, describedBy }) => (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder="203.0.113.7 or 203.0.113.0/24"
|
|
value={settings.trustedProxies.join(", ")}
|
|
onChange={(e) => vm.setTrustedProxies(e.target.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
|
Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode.
|
|
Sinon, le proxy peut expirer sans erreur IdeA.
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{/* Warnings inform; they never block a start. */}
|
|
{vm.warnings.map((warning) => (
|
|
<p key={warning.code} className="text-xs text-warning">
|
|
{warning.message}
|
|
</p>
|
|
))}
|
|
|
|
{vm.validationError && (
|
|
<p role="alert" className="text-sm text-danger">
|
|
{vm.validationError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
|
|
{remote && (
|
|
<Panel title="Configuration du proxy">
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-xs text-muted">
|
|
Faites pointer votre proxy inverse HTTPS vers cet upstream.
|
|
</p>
|
|
{vm.upstreamUrl ? (
|
|
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copier l'URL upstream" />
|
|
) : (
|
|
<p className="text-xs text-faint">
|
|
Complétez les paramètres ci-dessus pour obtenir l'URL upstream.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
|
|
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
|
|
<Panel title="Appairage">
|
|
<p className="text-sm text-muted">
|
|
L'appairage est géré dans{" "}
|
|
<span className="text-content">Paramètres → Appareils</span>, où
|
|
vous pouvez générer un code et révoquer des appareils.
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|