fix: normaliser les URLs des connecteurs web
This commit is contained in:
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -158,12 +159,20 @@ export default function WebImportSources() {
|
|||||||
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
|
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const portalUrl = normalizeWebPortalUrl(form.portalUrl);
|
||||||
|
if (!isValidWebPortalUrl(portalUrl)) {
|
||||||
|
toast.error("URL de portail invalide", {
|
||||||
|
description: "Saisissez une adresse HTTP(S), par exemple https://espaceclient.exemple.fr.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editSource) {
|
if (editSource) {
|
||||||
const updateData: any = { id: editSource.id, ...form };
|
const updateData: any = { id: editSource.id, ...form, portalUrl };
|
||||||
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
|
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
|
||||||
updateMutation.mutate(updateData);
|
updateMutation.mutate(updateData);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(form);
|
createMutation.mutate({ ...form, portalUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +325,7 @@ export default function WebImportSources() {
|
|||||||
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Le préfixe https:// est ajouté automatiquement si nécessaire.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Identifiant (email ou login)</Label>
|
<Label>Identifiant (email ou login)</Label>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { COOKIE_NAME } from "@shared/const";
|
import { COOKIE_NAME } from "@shared/const";
|
||||||
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
||||||
import { calculateInvoiceQualityScore } from "@shared/invoiceQuality";
|
import { calculateInvoiceQualityScore } from "@shared/invoiceQuality";
|
||||||
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
field: string;
|
field: string;
|
||||||
@@ -2577,7 +2578,7 @@ export const appRouter = router({
|
|||||||
saveSettings: protectedProcedure
|
saveSettings: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
portalUrl: z.string().url().optional(),
|
portalUrl: webPortalUrlSchema.optional(),
|
||||||
loginEmail: z.string().email().optional().or(z.literal('')),
|
loginEmail: z.string().email().optional().or(z.literal('')),
|
||||||
loginPassword: z.string().optional(), // vide = ne pas changer
|
loginPassword: z.string().optional(), // vide = ne pas changer
|
||||||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||||||
@@ -2723,7 +2724,7 @@ export const appRouter = router({
|
|||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
connectorType: z.string().min(1).max(50),
|
connectorType: z.string().min(1).max(50),
|
||||||
portalUrl: z.string().url(),
|
portalUrl: webPortalUrlSchema,
|
||||||
loginEmail: z.string().min(1),
|
loginEmail: z.string().min(1),
|
||||||
loginPassword: z.string().min(1),
|
loginPassword: z.string().min(1),
|
||||||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
|
||||||
|
|||||||
49
server/webPortalUrl.test.ts
Normal file
49
server/webPortalUrl.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
|
||||||
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
|
||||||
|
describe("normalizeWebPortalUrl", () => {
|
||||||
|
it("ajoute HTTPS aux adresses de portail saisies sans protocole", () => {
|
||||||
|
expect(normalizeWebPortalUrl("www.sfr.fr/mon-espace-client")).toBe(
|
||||||
|
"https://www.sfr.fr/mon-espace-client",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retire les espaces sans modifier une URL HTTPS déjà valide", () => {
|
||||||
|
expect(normalizeWebPortalUrl(" https://pro.free.fr/ ")).toBe("https://pro.free.fr/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne transforme pas un protocole non web en URL HTTPS", () => {
|
||||||
|
expect(normalizeWebPortalUrl("ftp://example.org/factures")).toBe("ftp://example.org/factures");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidWebPortalUrl", () => {
|
||||||
|
it("accepte exclusivement les URLs HTTP ou HTTPS avec un hôte", () => {
|
||||||
|
expect(isValidWebPortalUrl("https://espaceclient.orange.fr/")).toBe(true);
|
||||||
|
expect(isValidWebPortalUrl("http://intranet.local/factures")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse les valeurs vides, incomplètes ou avec un protocole non web", () => {
|
||||||
|
expect(isValidWebPortalUrl("")).toBe(false);
|
||||||
|
expect(isValidWebPortalUrl("https://")).toBe(false);
|
||||||
|
expect(isValidWebPortalUrl("ftp://example.org/factures")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("webPortalUrlSchema", () => {
|
||||||
|
it("normalise l’URL reçue par l’API avant de la persister", () => {
|
||||||
|
expect(webPortalUrlSchema.parse("espaceclient.orange.fr")).toBe(
|
||||||
|
"https://espaceclient.orange.fr",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne un message métier pour une URL de portail non HTTP(S)", () => {
|
||||||
|
const result = webPortalUrlSchema.safeParse("ftp://example.org/factures");
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0]?.message).toContain("URL HTTP(S) valide");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
13
server/webPortalUrl.ts
Normal file
13
server/webPortalUrl.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise les domaines saisis sans protocole puis impose une URL HTTP(S).
|
||||||
|
* Le même schéma est utilisé à la création et à la mise à jour d’un connecteur.
|
||||||
|
*/
|
||||||
|
export const webPortalUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.transform(normalizeWebPortalUrl)
|
||||||
|
.refine(isValidWebPortalUrl, {
|
||||||
|
message: "Saisissez une URL HTTP(S) valide, par exemple https://espaceclient.exemple.fr.",
|
||||||
|
});
|
||||||
24
shared/webPortalUrl.ts
Normal file
24
shared/webPortalUrl.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Ajoute HTTPS aux adresses de portail saisies sous forme de domaine simple.
|
||||||
|
* Les protocoles explicites sont conservés afin que la validation puisse refuser
|
||||||
|
* proprement ceux qui ne sont pas HTTP(S).
|
||||||
|
*/
|
||||||
|
export function normalizeWebPortalUrl(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
||||||
|
if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || /^(?:mailto|tel|data):/i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérifie qu’une URL de portail est une adresse HTTP(S) avec un hôte. */
|
||||||
|
export function isValidWebPortalUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user