Phase 2.1 : preparation au deploiement public

Rend une instance exposee sur Internet exploitable sans la transformer en
infrastructure : le produit reste local-first, sans compte et sans telemetrie.

Protection du service : limitation de debit en memoire par client et par
famille d'endpoint (health et ready jamais limites), interrupteur
SHARING_ENABLED, capacite maximale de partages, taille propre aux partages.
X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors
la derniere valeur, celle ajoutee par le proxy.

Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien
expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire
peut prolonger ; aucune purge automatique.

Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots
sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des
jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens
restent valides).

Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est
estampillee puis migree : verifie sur la base reelle de developpement, 4
partages inchanges et expires_at NULL.

Administration par CLI locale au conteneur (stats, liste, suppression, purge,
sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface
/admin exposee.

Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis
reimporte depuis un autre navigateur, apres verification aupres du serveur. Il
n'est toujours jamais ajoute a un export .mmap.

Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve
en observant les logs : Alembic, lance dans le processus applicatif,
reappliquait la configuration de logs d'alembic.ini et desactivait les loggers
existants, ce qui faisait disparaitre les journaux d'acces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:22:17 +02:00
parent e3c530348e
commit 49870dcd56
51 changed files with 3341 additions and 110 deletions

View File

@@ -0,0 +1,7 @@
# Les liens de partage sont non repertories : ils ne doivent pas etre indexes.
# Ce fichier reste une convention respectee par les robots corrects ; ce qui
# autorise reellement la lecture d'une carte, c'est la connaissance du jeton.
User-agent: *
Disallow: /s/
Disallow: /api/
Allow: /

View File

@@ -4,6 +4,7 @@ import { Toasts } from '@/components/Toasts';
import { DashboardPage } from '@/features/maps/DashboardPage';
import { EditorPage } from '@/features/editor/EditorPage';
import { SharedMapPage } from '@/features/sharing/SharedMapPage';
import { PrivacyPage } from '@/features/privacy/PrivacyPage';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate, useRoute } from '@/hooks/useRoute';
@@ -36,6 +37,7 @@ export function App() {
{route.name === 'dashboard' ? <DashboardPage /> : null}
{route.name === 'editor' ? <EditorPage mapId={route.mapId} /> : null}
{route.name === 'shared' ? <SharedMapPage token={route.token} /> : null}
{route.name === 'privacy' ? <PrivacyPage /> : null}
{route.name === 'notFound' ? <NotFoundPage /> : null}
</main>
<Toasts />

View File

@@ -0,0 +1,94 @@
/**
* Texte de la page Confidentialite.
*
* Centralise ici pour qu'un exploitant puisse l'adapter a son instance sans
* fouiller dans les composants. Volontairement factuel : ce sont des faits
* verifiables sur le fonctionnement de l'application, pas un texte juridique
* invente.
*
* `{app}` est remplace par le nom de l'instance.
*/
export interface PrivacySection {
title: string;
paragraphs: string[];
bullets?: string[];
}
export const PRIVACY_INTRO =
"{app} fonctionne d'abord dans votre navigateur. Cette page decrit ce qui reste sur " +
'votre appareil, ce qui part vers le serveur, et a quel moment.';
export const PRIVACY_SECTIONS: PrivacySection[] = [
{
title: 'Vos cartes restent sur cet appareil',
paragraphs: [
"Les cartes que vous creez sont enregistrees dans l'IndexedDB de ce navigateur. " +
"Elles ne sont pas envoyees au serveur, qui n'en a donc aucune connaissance.",
'Consequence directe : vider les donnees du site, changer de navigateur ou de ' +
'machine vous fait perdre ces cartes. Exportez-les en .mmap pour en garder une ' +
'copie portable.',
],
},
{
title: 'Le partage est une action explicite',
paragraphs: [
"Quand vous creez un lien de partage, et seulement a ce moment, une copie de la " +
'carte est envoyee au serveur et enregistree dans sa base de donnees.',
],
bullets: [
'Le lien obtenu est non repertorie : il ne figure dans aucune liste publique.',
"Il n'est pas protege par un mot de passe : toute personne qui connait le lien " +
'peut consulter la carte.',
'La copie serveur reste figee tant que vous ne demandez pas sa mise a jour.',
"Elle est conservee jusqu'a son expiration, ou jusqu'a ce que vous la supprimiez.",
],
},
{
title: 'La cle de gestion',
paragraphs: [
'Mettre a jour ou supprimer un partage demande une cle de gestion, generee au ' +
'moment du partage. Le serveur ne stocke que son empreinte : il ne peut donc ' +
'pas la retrouver, et personne ne peut vous la redonner.',
"Elle reste dans ce navigateur. Vous pouvez l'exporter dans un fichier pour la " +
'conserver ailleurs, puis la reimporter depuis un autre navigateur.',
],
},
{
title: 'Aucun compte, aucune publicite, aucune telemetrie',
paragraphs: [
"{app} ne demande aucune inscription et ne cree aucun profil utilisateur. " +
"L'application n'affiche aucune publicite et n'envoie aucune statistique " +
"d'usage : il n'existe pas de code de mesure d'audience dans le projet.",
"Les seules requetes reseau sont celles vers cette instance : la configuration " +
"publique, et les partages que vous creez ou consultez. Aucune ressource n'est " +
'chargee depuis un service tiers.',
],
},
{
title: 'Journaux du serveur',
paragraphs: [
"Le serveur journalise les requetes qu'il recoit pour permettre son exploitation. " +
'Les jetons de partage y sont remplaces par [REDACTED], et la cle de gestion ' +
"n'y apparait jamais.",
"Un reverse proxy place devant cette instance possede ses propres journaux, qui " +
"ne dependent pas d'{app} : leur contenu releve de son exploitant.",
],
},
{
title: 'Reprendre la main',
paragraphs: [
'Vous pouvez a tout moment supprimer un partage depuis la carte correspondante : ' +
'le lien cesse alors de fonctionner et la copie serveur est effacee. Une carte ' +
'locale se supprime depuis le tableau de bord.',
],
},
];
/** Phrase affichee discretement sur le tableau de bord. */
export const LOCAL_FIRST_NOTE =
'Vos cartes restent sur cet appareil tant que vous ne les partagez pas.';
export function applyAppName(text: string, appName: string): string {
return text.split('{app}').join(appName);
}

View File

@@ -32,6 +32,7 @@ import {
resolveSpacing,
} from '@/features/editor/layout';
import { THEMES } from '@/features/editor/themes';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { selectCanRedo, selectCanUndo, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
@@ -73,6 +74,9 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
const collapseToLevel = useEditorStore((state) => state.collapseToLevel);
const balanceSides = useEditorStore((state) => state.balanceSides);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const serverReachable = useAppConfigStore((state) => state.serverReachable);
const colorMode = useUiStore((state) => state.colorMode);
const toggleColorMode = useUiStore((state) => state.toggleColorMode);
const pushToast = useUiStore((state) => state.pushToast);
@@ -395,10 +399,24 @@ export function EditorToolbar({ onOpenShare, onToggleSearch }: EditorToolbarProp
<div className="mx-1 h-6 w-px bg-zinc-200 dark:bg-zinc-700" />
<Button size="sm" onClick={onOpenShare}>
<ShareIcon className="h-4 w-4" />
<span className="hidden md:inline">Partager</span>
</Button>
{/*
Instance privee (le serveur repond que le partage est coupe) : le
bouton disparait. Serveur injoignable : il reste visible mais inactif,
car l'etat du partage est simplement inconnu.
*/}
{sharingEnabled || !serverReachable ? (
<Button
size="sm"
onClick={onOpenShare}
disabled={!serverReachable}
title={
serverReachable ? undefined : 'Serveur de partage injoignable : travail local en cours'
}
>
<ShareIcon className="h-4 w-4" />
<span className="hidden md:inline">Partager</span>
</Button>
) : null}
<ExportMenu />
</div>
</header>

View File

@@ -30,7 +30,8 @@ import {
import { MapFormatError, readMapFile, serializeMap } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useUiStore } from '@/stores/uiStore';
import { buildMapPath, navigate } from '@/hooks/useRoute';
import { LOCAL_FIRST_NOTE } from '@/config/privacy';
import { buildMapPath, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export function DashboardPage() {
@@ -157,6 +158,7 @@ export function DashboardPage() {
<p className="mt-1 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
{APP_CONFIG.tagline}
</p>
<p className="mt-1 text-xs text-zinc-400">{LOCAL_FIRST_NOTE}</p>
</div>
<IconButton
label={colorMode === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
@@ -283,7 +285,14 @@ export function DashboardPage() {
<footer className="mt-10 border-t border-zinc-200 pt-4 text-xs leading-relaxed text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
Vos cartes sont enregistrees <strong>dans ce navigateur</strong> (IndexedDB). Elles ne sont
envoyees au serveur que si vous creez explicitement un lien de partage. Exportez-les en{' '}
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.
{APP_CONFIG.fileExtension} pour en conserver une sauvegarde portable.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-700 dark:hover:text-zinc-200"
onClick={() => navigate(PRIVACY_PATH)}
>
En savoir plus sur la confidentialite
</button>
</footer>
<TemplateGallery

View File

@@ -0,0 +1,58 @@
import { Button } from '@/components/Button';
import { ArrowLeftIcon } from '@/components/Icons';
import { PRIVACY_INTRO, PRIVACY_SECTIONS, applyAppName } from '@/config/privacy';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { navigate } from '@/hooks/useRoute';
/**
* Page /privacy.
*
* Le texte vit dans `config/privacy.ts` : cette page ne fait que le mettre en
* forme, pour qu'un exploitant puisse l'adapter a son instance en un seul
* endroit.
*/
export function PrivacyPage() {
const appName = useAppConfigStore((state) => state.name);
return (
<div className="mx-auto flex h-full w-full max-w-3xl flex-col overflow-y-auto px-5 py-8 sm:px-8">
<header className="mb-6">
<Button onClick={() => navigate('/')}>
<ArrowLeftIcon className="h-4 w-4" />
Retour
</Button>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Confidentialite
</h1>
<p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{applyAppName(PRIVACY_INTRO, appName)}
</p>
</header>
<div className="space-y-7 pb-10">
{PRIVACY_SECTIONS.map((section) => (
<section key={section.title}>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
{section.title}
</h2>
{section.paragraphs.map((paragraph) => (
<p
key={paragraph.slice(0, 40)}
className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
>
{applyAppName(paragraph, appName)}
</p>
))}
{section.bullets ? (
<ul className="mt-2 list-inside list-disc space-y-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{section.bullets.map((bullet) => (
<li key={bullet.slice(0, 40)}>{applyAppName(bullet, appName)}</li>
))}
</ul>
) : null}
</section>
))}
</div>
</div>
);
}

View File

@@ -1,19 +1,25 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CheckIcon, CopyIcon, SpinnerIcon } from '@/components/Icons';
import { CheckIcon, CopyIcon, DownloadIcon, ImportIcon, SpinnerIcon } from '@/components/Icons';
import { type ShareRecord } from '@/types/map';
import { ShareApiError, createShare, deleteShare, updateShare } from '@/services/shareApi';
import {
deleteShareRecord,
getShareRecord,
saveShareRecord,
} from '@/services/shareRepository';
ShareApiError,
createShare,
deleteShare,
setShareExpiry,
updateShare,
verifyManagementToken,
} from '@/services/shareApi';
import { ShareKeyError, SHARE_KEY_EXTENSION, readShareKeyFile, serializeShareKey } from '@/services/shareKey';
import { deleteShareRecord, getShareRecord, saveShareRecord } from '@/services/shareRepository';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { buildShareUrl } from '@/hooks/useRoute';
import { buildShareUrl, navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
import { downloadBlob, toSafeFilename } from '@/utils/download';
export interface ShareDialogProps {
open: boolean;
@@ -21,15 +27,63 @@ export interface ShareDialogProps {
onShareStateChange?: (shared: boolean) => void;
}
/** `null` represente l'option "Jamais". */
type ExpiryChoice = number | null;
function expiryLabel(days: ExpiryChoice): string {
if (days === null) return 'Jamais';
if (days === 1) return '24 heures';
if (days === 7) return '7 jours';
if (days === 30) return '30 jours';
if (days === 90) return '90 jours';
return `${days} jours`;
}
function serializeChoice(days: ExpiryChoice): string {
return days === null ? 'never' : String(days);
}
function parseChoice(value: string): ExpiryChoice {
return value === 'never' ? null : Number(value);
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Duree a afficher pour un partage existant.
*
* Le serveur ne stocke qu'une date absolue : la duree choisie a l'origine est
* perdue. On rapproche donc le temps restant de l'option la plus proche, la
* date exacte etant de toute facon rappelee juste en dessous du menu.
*/
function choiceForExpiry(expiresAt: string | null, options: ExpiryChoice[]): ExpiryChoice {
if (!expiresAt) return null;
const remaining = (new Date(expiresAt).getTime() - Date.now()) / DAY_MS;
const durations = options.filter((value): value is number => value !== null);
if (durations.length === 0) return null;
return durations.find((value) => value >= remaining) ?? (durations.at(-1) as number);
}
export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogProps) {
const map = useEditorStore((state) => state.map);
const pushToast = useUiStore((state) => state.pushToast);
const appName = useAppConfigStore((state) => state.name);
const sharingEnabled = useAppConfigStore((state) => state.sharingEnabled);
const allowPermanent = useAppConfigStore((state) => state.allowPermanentShares);
const defaultExpiryDays = useAppConfigStore((state) => state.defaultShareExpiryDays);
const expiryOptions = useAppConfigStore((state) => state.shareExpiryOptions);
const [record, setRecord] = useState<ShareRecord | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [choice, setChoice] = useState<ExpiryChoice>(null);
const keyInputRef = useRef<HTMLInputElement>(null);
// Les options refusees par l'instance ne sont pas proposees.
const options = expiryOptions.filter((value) => value !== null || allowPermanent);
const refresh = useCallback(async (mapId: string) => {
setLoading(true);
@@ -43,16 +97,32 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
void refresh(map.id);
}, [open, map, refresh]);
useEffect(() => {
if (!open) return;
if (record) {
setChoice(choiceForExpiry(record.expiresAt ?? null, options));
return;
}
setChoice(defaultExpiryDays > 0 ? defaultExpiryDays : allowPermanent ? null : 30);
// `options` est recalcule a chaque rendu : le comparer relancerait l'effet en boucle.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, record, defaultExpiryDays, allowPermanent]);
const handleError = (error: unknown, fallback: string) => {
pushToast(error instanceof ShareApiError ? error.message : fallback, 'error');
};
const persist = async (next: ShareRecord) => {
await saveShareRecord(next);
setRecord(next);
};
const handleCreate = async () => {
if (!map || busy) return;
setBusy(true);
try {
const response = await createShare(map, map.name);
const created: ShareRecord = {
const response = await createShare(map, map.name, choice);
await persist({
mapId: map.id,
shareToken: response.shareToken,
managementToken: response.managementToken,
@@ -60,9 +130,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title: response.title,
createdAt: response.createdAt,
updatedAt: response.updatedAt,
};
await saveShareRecord(created);
setRecord(created);
expiresAt: response.expiresAt,
});
onShareStateChange?.(true);
pushToast('Lien de partage cree.', 'success');
} catch (error) {
@@ -76,15 +145,13 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
if (!map || !record || busy) return;
setBusy(true);
try {
const response = await updateShare(
record.shareToken,
record.managementToken,
map,
map.name,
);
const updated = { ...record, title: map.name, updatedAt: response.updatedAt };
await saveShareRecord(updated);
setRecord(updated);
const response = await updateShare(record.shareToken, record.managementToken, map, map.name);
await persist({
...record,
title: map.name,
updatedAt: response.updatedAt,
expiresAt: response.expiresAt,
});
pushToast('Le lien public affiche maintenant la version actuelle.', 'success');
} catch (error) {
handleError(error, "La mise a jour du partage n'a pas abouti.");
@@ -93,6 +160,24 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
}
};
const handleExpiryChange = async (next: ExpiryChoice) => {
setChoice(next);
if (!record || busy) return;
setBusy(true);
try {
const response = await setShareExpiry(record.shareToken, record.managementToken, next);
await persist({ ...record, expiresAt: response.expiresAt, updatedAt: response.updatedAt });
pushToast(
next === null ? "Ce partage n'expire plus." : `Expiration fixee a ${expiryLabel(next)}.`,
'success',
);
} catch (error) {
handleError(error, "L'expiration n'a pas pu etre modifiee.");
} finally {
setBusy(false);
}
};
const handleDelete = async () => {
if (!map || !record || busy) return;
setConfirmDelete(false);
@@ -116,9 +201,8 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
const handleCopy = async () => {
if (!record) return;
const url = buildShareUrl(record.shareToken);
try {
await navigator.clipboard.writeText(url);
await navigator.clipboard.writeText(buildShareUrl(record.shareToken));
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
@@ -126,8 +210,55 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
}
};
/** Sauvegarde volontaire du secret de gestion, hors du fichier .mmap. */
const handleExportKey = () => {
if (!record) return;
const blob = new Blob([serializeShareKey(record, window.location.origin)], {
type: 'application/json;charset=utf-8',
});
downloadBlob(blob, toSafeFilename(record.title || 'partage', SHARE_KEY_EXTENSION));
pushToast('Cle de gestion exportee. Conservez ce fichier en lieu sur.', 'success');
};
const handleImportKey = async (file: File) => {
if (!map || busy) return;
setBusy(true);
try {
const key = await readShareKeyFile(file);
// La cle n'est acceptee qu'apres verification par le serveur concerne.
const owner = await verifyManagementToken(key.shareToken, key.managementToken);
await persist({
mapId: map.id,
shareToken: owner.shareToken,
managementToken: key.managementToken,
sharePath: `/s/${owner.shareToken}`,
title: owner.title,
createdAt: owner.createdAt,
updatedAt: owner.updatedAt,
expiresAt: owner.expiresAt,
});
onShareStateChange?.(true);
pushToast('Cle de gestion importee : vous pouvez a nouveau gerer ce partage.', 'success');
} catch (error) {
if (error instanceof ShareKeyError) pushToast(error.message, 'error');
else handleError(error, "Cette cle de gestion n'a pas pu etre verifiee.");
} finally {
setBusy(false);
}
};
const shareUrl = record ? buildShareUrl(record.shareToken) : '';
const disabledBody = (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>Le partage est desactive sur cette instance.</p>
<p className="text-zinc-500 dark:text-zinc-400">
Vos cartes continuent de fonctionner normalement : elles restent enregistrees dans ce
navigateur, et vous pouvez toujours les exporter en fichier.
</p>
</div>
);
return (
<>
<Modal
@@ -135,13 +266,11 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
title={record ? 'Carte partagee' : 'Partager cette carte'}
onClose={onClose}
footer={
record ? (
!sharingEnabled ? (
<Button onClick={onClose}>Fermer</Button>
) : record ? (
<>
<Button
variant="danger"
onClick={() => setConfirmDelete(true)}
disabled={busy}
>
<Button variant="danger" onClick={() => setConfirmDelete(true)} disabled={busy}>
Supprimer le partage
</Button>
<Button variant="primary" onClick={() => void handleUpdate()} disabled={busy}>
@@ -162,12 +291,23 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
)
}
>
{loading ? (
{!sharingEnabled ? (
disabledBody
) : loading ? (
<p className="text-sm text-zinc-500">Chargement...</p>
) : record ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-1.5">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Partage non repertorie
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Lecture seule
</span>
</div>
<div>
<span className="field-label">Lien public (lecture seule)</span>
<span className="field-label">Lien public</span>
<div className="flex items-center gap-2">
<input
readOnly
@@ -183,6 +323,30 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div>
</div>
<div>
<label className="field-label" htmlFor="share-expiry">
Expiration
</label>
<select
id="share-expiry"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => void handleExpiryChange(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-zinc-400">
{record.expiresAt
? `Le lien cessera de fonctionner le ${formatDateTime(record.expiresAt)}.`
: "Ce partage n'expire pas automatiquement."}
</p>
</div>
<dl className="rounded-lg border border-zinc-100 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex justify-between gap-4 py-0.5">
<dt className="text-zinc-500">Publie le</dt>
@@ -198,29 +362,93 @@ export function ShareDialog({ open, onClose, onShareStateChange }: ShareDialogPr
</div>
</dl>
<div>
<Button size="sm" onClick={handleExportKey} disabled={busy}>
<DownloadIcon className="h-3.5 w-3.5" />
Exporter la cle de gestion
</Button>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Ce fichier contient le secret qui permet de mettre a jour ou de supprimer ce
partage. Il n&apos;est jamais inclus dans un export {'.mmap'}. Conservez-le si vous
comptez gerer ce partage depuis un autre navigateur.
</p>
</div>
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Le lien est <strong>non repertorie</strong> : il n&apos;apparait dans aucune liste
publique, mais toute personne qui le connait peut consulter la carte. Les
<strong>Toute personne disposant de ce lien peut consulter cette carte.</strong> Les
modifications faites ici ne sont visibles publiquement qu&apos;apres un
<em> Mettre a jour le partage</em>.
</p>
</div>
) : (
<div className="space-y-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<div className="space-y-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
<p>
Cette carte est actuellement <strong>uniquement dans votre navigateur</strong>. Pour
generer un lien, une copie de la carte sera envoyee au serveur et stockee dans sa base
de donnees.
</p>
<div>
<label className="field-label" htmlFor="share-expiry-new">
Expiration
</label>
<select
id="share-expiry-new"
className="field-input"
disabled={busy}
value={serializeChoice(choice)}
onChange={(event) => setChoice(parseChoice(event.target.value))}
>
{options.map((value) => (
<option key={serializeChoice(value)} value={serializeChoice(value)}>
{expiryLabel(value)}
</option>
))}
</select>
</div>
<ul className="list-inside list-disc space-y-1 text-zinc-500 dark:text-zinc-400">
<li>Le lien obtenu est non repertorie et en lecture seule.</li>
<li>Aucun compte n&apos;est necessaire pour le consulter.</li>
<li>Toute personne disposant de ce lien peut consulter cette carte.</li>
<li>Les cartes non partagees restent uniquement dans votre navigateur.</li>
<li>Vous pourrez mettre a jour ou supprimer ce partage a tout moment.</li>
<li>
Le secret permettant de le modifier reste dans ce navigateur : depuis un autre
appareil, vous ne pourrez pas gerer ce partage.
</li>
</ul>
<div className="border-t border-zinc-100 pt-3 dark:border-zinc-800">
<Button size="sm" onClick={() => keyInputRef.current?.click()} disabled={busy}>
<ImportIcon className="h-3.5 w-3.5" />
Importer une cle de gestion
</Button>
<input
ref={keyInputRef}
type="file"
accept={`${SHARE_KEY_EXTENSION},application/json`}
className="sr-only"
aria-label="Importer une cle de gestion"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportKey(file);
}}
/>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Cette carte a deja ete partagee depuis un autre navigateur ? Reimportez le fichier{' '}
{SHARE_KEY_EXTENSION} exporte a l&apos;epoque pour reprendre la main dessus.
</p>
</div>
<p className="text-[11px] text-zinc-400">
<button
type="button"
className="underline underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-300"
onClick={() => {
onClose();
navigate(PRIVACY_PATH);
}}
>
Comment {appName} traite vos donnees
</button>
</p>
</div>
)}
</Modal>

View File

@@ -18,8 +18,8 @@ import { MapFormatError } from '@/services/mapSerialization';
import { useAppConfigStore } from '@/stores/appConfigStore';
import { useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { navigate } from '@/hooks/useRoute';
import { formatDateTime } from '@/utils/date';
import { navigate, PRIVACY_PATH } from '@/hooks/useRoute';
import { formatDate, formatDateTime } from '@/utils/date';
export interface SharedMapPageProps {
token: string;
@@ -27,8 +27,25 @@ export interface SharedMapPageProps {
type Status =
| { kind: 'loading' }
| { kind: 'ready'; title: string; updatedAt: string }
| { kind: 'error'; message: string };
| { kind: 'ready'; title: string; updatedAt: string; expiresAt: string | null }
| { kind: 'error'; message: string; expired?: boolean };
/**
* Un lien de partage est une capacite de lecture : il ne doit pas se retrouver
* dans un index public. L'en-tete X-Robots-Tag est pose par le serveur ; cette
* balise couvre le cas d'un robot qui ne lirait que le document.
*/
function useNoIndex(): void {
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = 'noindex, nofollow, noarchive';
document.head.appendChild(meta);
return () => {
meta.remove();
};
}, []);
}
function SharedMapContent({ token }: SharedMapPageProps) {
const appName = useAppConfigStore((state) => state.name);
@@ -40,6 +57,7 @@ function SharedMapContent({ token }: SharedMapPageProps) {
const { zoomIn, zoomOut } = useReactFlow();
const [status, setStatus] = useState<Status>({ kind: 'loading' });
useNoIndex();
useEffect(() => {
let cancelled = false;
@@ -49,7 +67,12 @@ function SharedMapContent({ token }: SharedMapPageProps) {
.then((response) => {
if (cancelled) return;
loadMap(response.map, { readOnly: true });
setStatus({ kind: 'ready', title: response.title, updatedAt: response.updatedAt });
setStatus({
kind: 'ready',
title: response.title,
updatedAt: response.updatedAt,
expiresAt: response.expiresAt,
});
document.title = `${response.title} - ${appName}`;
})
.catch((error: unknown) => {
@@ -58,7 +81,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
error instanceof ShareApiError || error instanceof MapFormatError
? error.message
: 'Cette carte partagee est introuvable.';
setStatus({ kind: 'error', message });
const expired = error instanceof ShareApiError && error.status === 410;
setStatus({ kind: 'error', message, expired });
});
return () => {
@@ -82,7 +106,9 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MapIcon className="h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">{status.message}</h1>
<p className="max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Le lien a peut-etre ete supprime par son auteur, ou l&apos;adresse est incorrecte.
{status.expired
? "Son auteur avait fixe une date de fin. Lui seul peut le prolonger ou le republier."
: "Le lien a peut-etre ete supprime par son auteur, ou l'adresse est incorrecte."}
</p>
<Button variant="primary" onClick={() => navigate('/')}>
Ouvrir {appName}
@@ -100,7 +126,8 @@ function SharedMapContent({ token }: SharedMapPageProps) {
{status.title}
</h1>
<p className="truncate text-[11px] text-zinc-400">
Carte partagee - lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
Carte partagee en lecture seule - mise a jour le {formatDateTime(status.updatedAt)}
{status.expiresAt ? ` - expire le ${formatDate(status.expiresAt)}` : ''}
</p>
</div>
@@ -122,8 +149,11 @@ function SharedMapContent({ token }: SharedMapPageProps) {
<MindMapCanvas readOnly showMiniMap={false} />
</div>
<footer className="no-print flex h-8 shrink-0 items-center justify-center border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
Carte partagee via {appName} - aucune inscription necessaire
<footer className="no-print flex h-8 shrink-0 items-center justify-center gap-2 border-t border-zinc-200 bg-white text-[11px] text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900">
<span>Carte partagee via {appName} - aucune inscription necessaire</span>
<a href={PRIVACY_PATH} className="underline underline-offset-2 hover:text-zinc-600">
Confidentialite
</a>
</footer>
</div>
);

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { PRIVACY_PATH, parseRoute } from '@/hooks/useRoute';
describe('routes', () => {
it('reconnait le tableau de bord', () => {
expect(parseRoute('/')).toEqual({ name: 'dashboard' });
expect(parseRoute('')).toEqual({ name: 'dashboard' });
});
it('reconnait la page de confidentialite', () => {
expect(parseRoute(PRIVACY_PATH)).toEqual({ name: 'privacy' });
expect(parseRoute('/privacy/')).toEqual({ name: 'privacy' });
});
it('reconnait une carte locale', () => {
expect(parseRoute('/map/abc-123')).toEqual({ name: 'editor', mapId: 'abc-123' });
});
it('reconnait un partage', () => {
expect(parseRoute('/s/OZGrnetO4RPbqPtS')).toEqual({
name: 'shared',
token: 'OZGrnetO4RPbqPtS',
});
});
it('rejette le reste', () => {
expect(parseRoute('/inconnu')).toEqual({ name: 'notFound' });
expect(parseRoute('/s/')).toEqual({ name: 'notFound' });
});
});

View File

@@ -5,14 +5,18 @@ export type Route =
| { name: 'dashboard' }
| { name: 'editor'; mapId: string }
| { name: 'shared'; token: string }
| { name: 'privacy' }
| { name: 'notFound' };
const NAVIGATION_EVENT = 'openmind:navigate';
export const PRIVACY_PATH = '/privacy';
export function parseRoute(pathname: string): Route {
const clean = pathname.replace(/\/+$/, '') || '/';
if (clean === '/' || clean === '') return { name: 'dashboard' };
if (clean === PRIVACY_PATH) return { name: 'privacy' };
const editorMatch = clean.match(/^\/map\/([^/]+)$/);
if (editorMatch?.[1]) return { name: 'editor', mapId: decodeURIComponent(editorMatch[1]) };

View File

@@ -19,6 +19,7 @@ export interface ShareCreatedResponse {
title: string;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
export interface SharedMapResponse {
@@ -27,6 +28,29 @@ export interface SharedMapResponse {
map: MindMap;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
/** Fiche d'un partage sans aucune donnee de carte, reservee au proprietaire. */
export interface ShareOwnerResponse {
shareToken: string;
title: string;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
/**
* Duree de vie demandee pour un partage.
* `null` = sans expiration ; `undefined` = laisser l'instance decider.
*/
export type ShareExpiry = number | null | undefined;
function expiryBody(expiry: ShareExpiry): Record<string, unknown> {
if (expiry === undefined) return {};
// Le serveur distingue "aucune preference" de "explicitement permanent" :
// un `null` JSON seul ne permettrait pas de faire la difference.
return expiry === null ? { permanent: true } : { expires_in_days: expiry };
}
const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
@@ -34,8 +58,15 @@ const MANAGEMENT_TOKEN_HEADER = 'X-Management-Token';
function messageForStatus(status: number, serverDetail?: string): string {
if (status === 404) return "Ce partage n'existe plus.";
if (status === 403) return "Vous n'avez pas le droit de modifier ce partage depuis ce navigateur.";
if (status === 410) return serverDetail || 'Ce partage a expire.';
if (status === 413) return serverDetail || 'Cette carte est trop volumineuse pour etre partagee.';
if (status === 422) return serverDetail || "Cette carte n'a pas pu etre validee par le serveur.";
if (status === 429) {
return serverDetail || 'Trop de requetes envoyees recemment. Reessayez dans un moment.';
}
// 503 porte un message explicite (partage desactive, instance pleine) :
// il est plus utile a l'utilisateur que le message generique des 5xx.
if (status === 503) return serverDetail || 'Le service de partage est indisponible.';
if (status >= 500) return 'Le serveur de partage est momentanement indisponible.';
return serverDetail || 'Le partage a echoue. Reessayez dans un instant.';
}
@@ -88,6 +119,7 @@ interface RawShareCreated {
title: string;
created_at: string;
updated_at: string;
expires_at: string | null;
}
interface RawSharePublic {
@@ -97,13 +129,36 @@ interface RawSharePublic {
map: unknown;
created_at: string;
updated_at: string;
expires_at: string | null;
}
interface RawShareOwner {
share_token: string;
title: string;
created_at: string;
updated_at: string;
expires_at: string | null;
}
function toOwner(raw: RawShareOwner): ShareOwnerResponse {
return {
shareToken: raw.share_token,
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
};
}
/** Publie un snapshot de la carte. Appele uniquement sur action explicite. */
export async function createShare(map: MindMap, title: string): Promise<ShareCreatedResponse> {
export async function createShare(
map: MindMap,
title: string,
expiry: ShareExpiry = undefined,
): Promise<ShareCreatedResponse> {
const raw = await request<RawShareCreated>('/api/shares', {
method: 'POST',
body: JSON.stringify({ title, map }),
body: JSON.stringify({ title, map, ...expiryBody(expiry) }),
});
return {
@@ -113,6 +168,7 @@ export async function createShare(map: MindMap, title: string): Promise<ShareCre
title: raw.title,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
};
}
@@ -121,8 +177,8 @@ export async function updateShare(
managementToken: string,
map: MindMap,
title: string,
): Promise<{ updatedAt: string }> {
const raw = await request<{ updated_at: string }>(
): Promise<{ updatedAt: string; expiresAt: string | null }> {
const raw = await request<{ updated_at: string; expires_at: string | null }>(
`/api/shares/${encodeURIComponent(shareToken)}`,
{
method: 'PUT',
@@ -130,7 +186,41 @@ export async function updateShare(
body: JSON.stringify({ title, map }),
},
);
return { updatedAt: raw.updated_at };
return { updatedAt: raw.updated_at, expiresAt: raw.expires_at ?? null };
}
/** Change la date d'expiration d'un partage. Necessite la cle de gestion. */
export async function setShareExpiry(
shareToken: string,
managementToken: string,
expiry: number | null,
): Promise<ShareOwnerResponse> {
const raw = await request<RawShareOwner>(`/api/shares/${encodeURIComponent(shareToken)}`, {
method: 'PATCH',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
body: JSON.stringify(expiryBody(expiry)),
});
return toOwner(raw);
}
/**
* Verifie qu'une cle de gestion correspond bien a ce partage.
*
* La cle n'est envoyee qu'a cette API, dans l'en-tete prevu pour elle, et
* jamais dans une URL. La reponse ne contient aucune donnee de carte.
*/
export async function verifyManagementToken(
shareToken: string,
managementToken: string,
): Promise<ShareOwnerResponse> {
const raw = await request<RawShareOwner>(
`/api/shares/${encodeURIComponent(shareToken)}/verify`,
{
method: 'POST',
headers: { [MANAGEMENT_TOKEN_HEADER]: managementToken },
},
);
return toOwner(raw);
}
export async function deleteShare(shareToken: string, managementToken: string): Promise<void> {
@@ -149,6 +239,7 @@ export async function fetchSharedMap(shareToken: string): Promise<SharedMapRespo
map: normalizeMapDocument(raw.map, { source: 'share' }),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
expiresAt: raw.expires_at ?? null,
};
}
@@ -156,7 +247,12 @@ export interface PublicAppConfig {
appName: string;
maxNodes: number;
maxMapSizeMb: number;
maxShareSizeMb: number;
sharingEnabled: boolean;
allowPermanentShares: boolean;
defaultShareExpiryDays: number;
/** Durees proposees dans l'interface ; `null` signifie "jamais". */
shareExpiryOptions: Array<number | null>;
}
export async function fetchAppConfig(): Promise<PublicAppConfig> {

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { type ShareRecord } from '@/types/map';
import {
SHARE_KEY_VERSION,
ShareKeyError,
buildShareKeyFile,
parseShareKey,
serializeShareKey,
} from '@/services/shareKey';
function record(): ShareRecord {
return {
mapId: 'map-1',
shareToken: 'OZGrnetO4RPbqPtS',
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
sharePath: '/s/OZGrnetO4RPbqPtS',
title: 'Infrastructure',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
expiresAt: null,
};
}
describe('export d une cle de gestion', () => {
it('contient les deux jetons et la version', () => {
const key = buildShareKeyFile(record(), 'https://mind.example.com');
expect(key.version).toBe(SHARE_KEY_VERSION);
expect(key.shareToken).toBe('OZGrnetO4RPbqPtS');
expect(key.managementToken).toBe('wYq3-Zx8_secret-de-gestion-tres-long-1234');
expect(key.title).toBe('Infrastructure');
expect(key.origin).toBe('https://mind.example.com');
});
it('ne contient aucune donnee de carte', () => {
const serialized = serializeShareKey(record());
const parsed = JSON.parse(serialized) as Record<string, unknown>;
expect(Object.keys(parsed).sort()).toEqual([
'exportedAt',
'managementToken',
'shareToken',
'title',
'version',
]);
expect(serialized).not.toContain('nodes');
expect(serialized).not.toContain('mapId');
});
it('produit un fichier relisable', () => {
const parsed = parseShareKey(JSON.parse(serializeShareKey(record())));
expect(parsed.shareToken).toBe(record().shareToken);
expect(parsed.managementToken).toBe(record().managementToken);
});
});
describe('validation stricte a l import', () => {
const valid = {
version: 1,
shareToken: 'OZGrnetO4RPbqPtS',
managementToken: 'wYq3-Zx8_secret-de-gestion-tres-long-1234',
};
it('accepte un fichier minimal', () => {
expect(parseShareKey(valid).shareToken).toBe('OZGrnetO4RPbqPtS');
});
it('refuse ce qui n est pas un objet', () => {
for (const value of [null, 42, 'texte', [], undefined]) {
expect(() => parseShareKey(value)).toThrow(ShareKeyError);
}
});
it('refuse un fichier sans version', () => {
expect(() => parseShareKey({ ...valid, version: undefined })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, version: 'un' })).toThrow(ShareKeyError);
});
it('refuse une version future', () => {
expect(() => parseShareKey({ ...valid, version: 99 })).toThrow(
/version plus recente/,
);
});
it('refuse un jeton manquant ou mal forme', () => {
expect(() => parseShareKey({ ...valid, shareToken: '' })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, managementToken: 'court' })).toThrow(ShareKeyError);
expect(() => parseShareKey({ ...valid, shareToken: 'avec espace et /' })).toThrow(
ShareKeyError,
);
expect(() => parseShareKey({ ...valid, managementToken: 42 })).toThrow(ShareKeyError);
});
it('ignore les champs superflus plutot que de les recopier', () => {
const parsed = parseShareKey({ ...valid, mechant: 'valeur', origin: 'https://ailleurs' });
expect(Object.keys(parsed).sort()).toEqual(['managementToken', 'shareToken', 'version']);
});
it('borne le titre importe', () => {
const parsed = parseShareKey({ ...valid, title: 'x'.repeat(500) });
expect(parsed.title).toHaveLength(200);
});
});

View File

@@ -0,0 +1,108 @@
import { type ShareRecord } from '@/types/map';
/**
* Cle de gestion d'un partage, exportable et reimportable.
*
* Le probleme resolu : le secret qui permet de mettre a jour ou de supprimer un
* partage vit uniquement dans l'IndexedDB du navigateur d'origine. Changer de
* machine, ou perdre son profil, revenait a perdre la main sur son propre lien.
*
* La reponse reste sans compte : un petit fichier que le proprietaire range ou
* il veut. L'export est une action explicite, et ce secret n'est jamais ajoute
* automatiquement a un fichier `.mmap`.
*/
export const SHARE_KEY_VERSION = 1;
export const SHARE_KEY_EXTENSION = '.openmind-share';
export interface ShareKeyFile {
version: number;
shareToken: string;
managementToken: string;
title?: string;
/** Informatif : d'ou vient ce fichier. Jamais utilise pour appeler quoi que ce soit. */
origin?: string;
exportedAt?: string;
}
/** Erreur de lecture d'un fichier de cle, deja formulee pour l'utilisateur. */
export class ShareKeyError extends Error {
constructor(message: string) {
super(message);
this.name = 'ShareKeyError';
}
}
const MAX_FILE_SIZE = 64 * 1024;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,256}$/;
export function buildShareKeyFile(record: ShareRecord, origin?: string): ShareKeyFile {
return {
version: SHARE_KEY_VERSION,
shareToken: record.shareToken,
managementToken: record.managementToken,
...(record.title ? { title: record.title } : {}),
...(origin ? { origin } : {}),
exportedAt: new Date().toISOString(),
};
}
export function serializeShareKey(record: ShareRecord, origin?: string): string {
return JSON.stringify(buildShareKeyFile(record, origin), null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Valide strictement un fichier de cle.
*
* Aucune tolerance : ce fichier porte un secret, et une structure approximative
* signalerait plutot une erreur de manipulation qu'un fichier a rattraper.
*/
export function parseShareKey(raw: unknown): ShareKeyFile {
if (!isRecord(raw)) {
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide.");
}
if (typeof raw.version !== 'number' || !Number.isFinite(raw.version) || raw.version < 1) {
throw new ShareKeyError("Ce fichier ne precise aucune version de cle de gestion.");
}
if (raw.version > SHARE_KEY_VERSION) {
throw new ShareKeyError(
"Cette cle a ete produite par une version plus recente d'OpenMind.",
);
}
const shareToken = typeof raw.shareToken === 'string' ? raw.shareToken.trim() : '';
const managementToken =
typeof raw.managementToken === 'string' ? raw.managementToken.trim() : '';
if (!TOKEN_PATTERN.test(shareToken) || !TOKEN_PATTERN.test(managementToken)) {
throw new ShareKeyError('Cette cle de gestion est incomplete ou mal formee.');
}
const title = typeof raw.title === 'string' ? raw.title.slice(0, 200) : undefined;
return {
version: SHARE_KEY_VERSION,
shareToken,
managementToken,
...(title ? { title } : {}),
};
}
/** Lit un fichier `.openmind-share` choisi par l'utilisateur. */
export async function readShareKeyFile(file: File): Promise<ShareKeyFile> {
if (file.size > MAX_FILE_SIZE) {
throw new ShareKeyError("Ce fichier est trop volumineux pour etre une cle de gestion.");
}
let raw: unknown;
try {
raw = JSON.parse(await file.text());
} catch {
throw new ShareKeyError("Ce fichier n'est pas une cle de gestion valide (JSON illisible).");
}
return parseShareKey(raw);
}

View File

@@ -6,11 +6,23 @@ interface AppConfigState {
name: string;
maxNodes: number;
maxMapSizeMb: number;
maxShareSizeMb: number;
/**
* Le partage serveur est-il ouvert sur cette instance ?
* Faux tant que le backend n'a pas repondu : mieux vaut ne pas proposer une
* action qui echouerait que l'inverse.
*/
sharingEnabled: boolean;
allowPermanentShares: boolean;
defaultShareExpiryDays: number;
shareExpiryOptions: Array<number | null>;
/** Faux tant que le backend n'a pas repondu : l'app reste utilisable hors ligne. */
serverReachable: boolean;
load: () => Promise<void>;
}
const DEFAULT_EXPIRY_OPTIONS: Array<number | null> = [1, 7, 30, 90, null];
/**
* Le nom du produit vient de la configuration serveur (APP_NAME), avec repli
* sur `APP_CONFIG.name`. Aucun composant ne doit ecrire le nom en dur.
@@ -19,6 +31,11 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
name: APP_CONFIG.name,
maxNodes: APP_CONFIG.maxNodes,
maxMapSizeMb: APP_CONFIG.maxMapSizeMb,
maxShareSizeMb: APP_CONFIG.maxMapSizeMb,
sharingEnabled: false,
allowPermanentShares: true,
defaultShareExpiryDays: 30,
shareExpiryOptions: DEFAULT_EXPIRY_OPTIONS,
serverReachable: false,
load: async () => {
@@ -28,6 +45,14 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
name: config.appName || APP_CONFIG.name,
maxNodes: config.maxNodes || APP_CONFIG.maxNodes,
maxMapSizeMb: config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
maxShareSizeMb: config.maxShareSizeMb || config.maxMapSizeMb || APP_CONFIG.maxMapSizeMb,
sharingEnabled: config.sharingEnabled !== false,
allowPermanentShares: config.allowPermanentShares !== false,
defaultShareExpiryDays: config.defaultShareExpiryDays ?? 30,
shareExpiryOptions:
Array.isArray(config.shareExpiryOptions) && config.shareExpiryOptions.length > 0
? config.shareExpiryOptions
: DEFAULT_EXPIRY_OPTIONS,
serverReachable: true,
});
if (typeof document !== 'undefined') {
@@ -35,7 +60,8 @@ export const useAppConfigStore = create<AppConfigState>((set) => ({
}
} catch {
// Hors ligne : on garde les valeurs par defaut, sans bloquer l'interface.
set({ serverReachable: false });
// Le partage reste presente comme indisponible plutot que d'echouer au clic.
set({ serverReachable: false, sharingEnabled: false });
}
},
}));

View File

@@ -288,4 +288,6 @@ export interface ShareRecord {
title: string;
createdAt: string;
updatedAt: string;
/** Date d'expiration ISO, ou `null` pour un partage sans expiration. */
expiresAt?: string | null;
}