Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
409 lines
17 KiB
TypeScript
409 lines
17 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 { useRef, useState } from "react";
|
|
|
|
import type { ServerExposureMode } from "@/domain";
|
|
import { Button, Field, Input, Panel, cn } from "@/shared";
|
|
import { useDeployment } from "./useDeployment";
|
|
import { useI18n } from "@/i18n";
|
|
|
|
type Tr = (french: string, english: string) => string;
|
|
|
|
interface ModeOption {
|
|
mode: ServerExposureMode;
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
/** The three exposure choices, in increasing order of reach. */
|
|
const modeOptions = (tr: Tr): ModeOption[] => [
|
|
{
|
|
mode: "localOnly",
|
|
title: tr("Cet ordinateur uniquement", "This computer only"),
|
|
description:
|
|
tr("Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.", "Use IdeA only on this computer. Remote devices cannot connect."),
|
|
},
|
|
{
|
|
mode: "remoteProxyLocal",
|
|
title: tr("Accès distant, proxy sur cet ordinateur", "Remote access, proxy on this computer"),
|
|
description:
|
|
tr("À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.", "Use when the HTTPS proxy runs on the same machine as IdeA Desktop."),
|
|
},
|
|
{
|
|
mode: "remoteProxyOtherMachine",
|
|
title: tr("Accès distant, proxy sur une autre machine", "Remote access, proxy on another machine"),
|
|
description:
|
|
tr("À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.", "Use when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy."),
|
|
},
|
|
];
|
|
|
|
const stateLabel = (state: string, tr: Tr): string => ({
|
|
stopped: tr("Arrêté", "Stopped"), starting: tr("Démarrage…", "Starting…"),
|
|
running: tr("En cours d'exécution", "Running"), stopping: tr("Arrêt…", "Stopping…"), failed: tr("Échec", "Failed"),
|
|
}[state] ?? state);
|
|
|
|
/** Copy-to-clipboard button; degrades to disabled where the API is absent. */
|
|
function CopyButton({ value, label }: { value: string; label: string }) {
|
|
const { tr } = useI18n();
|
|
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 ? tr("Copié", "Copied") : tr("Copier", "Copy")}
|
|
</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();
|
|
const { tr } = useI18n();
|
|
// "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the
|
|
// simplest correct answer for a cross-panel affordance on one scrolled page.
|
|
const portInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
if (!vm.ready || !vm.settings) {
|
|
return (
|
|
<Panel aria-label={tr("paramètres de déploiement", "deployment settings")} title={tr("Déploiement", "Deployment")}>
|
|
<p className="text-sm text-muted">{tr("Chargement…", "Loading…")}</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={tr("paramètres de déploiement", "deployment settings")} className="flex flex-col gap-4">
|
|
{/* ── Status ────────────────────────────────────────────────────────── */}
|
|
<Panel
|
|
title={tr("Serveur", "Server")}
|
|
actions={
|
|
running || status.state === "stopping" ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.stop()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
{tr("Arrêter", "Stop")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.start()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
{tr("Démarrer", "Start")}
|
|
</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">{stateLabel(status.state, tr)}</span>
|
|
</p>
|
|
|
|
{status.localUrl && (
|
|
<p className="text-xs text-muted">
|
|
{tr("URL locale", "Local URL")} : <code className="font-mono text-content">{status.localUrl}</code>
|
|
</p>
|
|
)}
|
|
{status.publicUrl && (
|
|
<p className="text-xs text-muted">
|
|
{tr("URL publique", "Public URL")} : <code className="font-mono text-content">{status.publicUrl}</code>
|
|
</p>
|
|
)}
|
|
|
|
{/* A failure carries the backend's message: it is the correction.
|
|
A port conflict (#89 — most often surfaced by auto-start at
|
|
launch, but shown the same way for a manual start) gets a
|
|
dedicated, actionable message instead of the generic one. */}
|
|
{status.state === "failed" && status.error && (
|
|
status.error.code === "PORT_IN_USE" ? (
|
|
<div className="flex flex-col gap-2">
|
|
<p role="alert" className="text-sm text-danger">
|
|
{tr("Le serveur n'a pas démarré automatiquement.", "The server did not start automatically.")} {status.error.message}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
portInputRef.current?.scrollIntoView?.({
|
|
behavior: "smooth",
|
|
block: "center",
|
|
});
|
|
portInputRef.current?.focus();
|
|
}}
|
|
>
|
|
{tr("Modifier le port", "Change port")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.start()}
|
|
disabled={vm.busy}
|
|
>
|
|
{tr("Réessayer", "Retry")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
{/* Auto-start at launch (#89) — a persistence-only toggle, never a
|
|
start trigger; it never runs the server right now. */}
|
|
<label className="flex cursor-pointer items-start gap-2 pt-2">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5"
|
|
aria-label={tr("Démarrer le serveur au lancement d'IdeA", "Start the server when IdeA launches")}
|
|
checked={settings.autoStart}
|
|
disabled={vm.autoStartBusy}
|
|
onChange={(e) => void vm.setAutoStart(e.target.checked)}
|
|
/>
|
|
<span className="flex flex-col gap-0.5">
|
|
<span className="text-sm text-content">
|
|
{tr("Démarrer le serveur au lancement d'IdeA", "Start the server when IdeA launches")}
|
|
</span>
|
|
<span className="text-xs text-muted">
|
|
{tr("IdeA utilisera les réglages réseau enregistrés ci-dessous au prochain démarrage de l'application desktop.", "IdeA will use the network settings saved below the next time the desktop application starts.")}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* ── Exposure (ticket #78 — "Accès réseau" per UX) ────────────────── */}
|
|
<Panel title={tr("Accès réseau", "Network access")}>
|
|
<div className="flex flex-col gap-4">
|
|
<div
|
|
role="radiogroup"
|
|
aria-label={tr("mode d'accès réseau", "network access mode")}
|
|
className="flex flex-col gap-2"
|
|
>
|
|
{modeOptions(tr).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={tr("Port", "Port")}>
|
|
{({ id }) => (
|
|
<Input
|
|
ref={portInputRef}
|
|
id={id}
|
|
type="number"
|
|
value={String(settings.port)}
|
|
onChange={(e) => vm.setPort(Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
{remote && (
|
|
<Field label={tr("Origine publique", "Public origin")} hint={tr("Exemple : https://idea.example.com", "Example: https://idea.example.com")}>
|
|
{({ id, describedBy }) => (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder={tr("https://idea.example.com", "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={tr("Adresse LAN d'écoute", "LAN listen address")}
|
|
hint={tr("L'adresse de cette machine à laquelle le proxy se connectera.", "The address on this machine that the proxy will connect to.")}
|
|
>
|
|
{({ 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="">{tr("Sélectionner une adresse…", "Select an address…")}</option>
|
|
{vm.candidateLanAddresses.map((address) => (
|
|
<option key={address} value={address}>
|
|
{address}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder={tr("192.168.1.42", "192.168.1.42")}
|
|
value={settings.lanBindAddress ?? ""}
|
|
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
|
/>
|
|
)
|
|
}
|
|
</Field>
|
|
|
|
<Field
|
|
label={tr("IP/CIDR du proxy autorisé", "Authorized proxy IP/CIDR")}
|
|
hint={tr("Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter IdeA.", "This is not IdeA's listen address. It is the machine authorized to contact IdeA.")}
|
|
>
|
|
{({ id, describedBy }) => (
|
|
<Input
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
placeholder={tr("203.0.113.7 ou 203.0.113.0/24", "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">
|
|
{tr("Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode. Sinon, le proxy peut expirer sans erreur IdeA.", "If your proxy is not on this computer, choose this mode. Otherwise the proxy may time out without an IdeA error.")}
|
|
</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={tr("Configuration du proxy", "Proxy configuration")}>
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-xs text-muted">
|
|
{tr("Faites pointer votre proxy inverse HTTPS vers cet upstream.", "Point your HTTPS reverse proxy to this upstream.")}
|
|
</p>
|
|
{vm.upstreamUrl ? (
|
|
<ReadOnlyValue value={vm.upstreamUrl} copyLabel={tr("copier l'URL upstream", "copy upstream URL")} />
|
|
) : (
|
|
<p className="text-xs text-faint">
|
|
{tr("Complétez les paramètres ci-dessus pour obtenir l'URL upstream.", "Complete the settings above to obtain the upstream URL.")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
|
|
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
|
|
<Panel title={tr("Appairage", "Pairing")}>
|
|
<p className="text-sm text-muted">
|
|
{tr("L'appairage est géré dans", "Pairing is managed in")}{" "}
|
|
<span className="text-content">{tr("Paramètres → Appareils", "Settings → Devices")}</span>, {tr("où vous pouvez générer un code et révoquer des appareils.", "where you can generate a code and revoke devices.")}
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|