feat(app) : socle SvelteKit phase 0 — SSO, thème OKI, adapter-node
- Scaffold SvelteKit 2 + Svelte 5 (runes), TypeScript strict, adapter-node - Auth SSO via header Ynh-User (hooks.server) ; accueil public minimal sans header, 401 sur toute autre route ; DEV_USER en dev local uniquement - Design system OKI : tokens, thème sombre par défaut, clair opt-in anti-FOUC, flag-bar, fonts Archivo/Inter self-hébergées, sprite icons.svg (zéro emoji) - CSP via kit.csp (mode nonce) + en-têtes de sécurité ; paths.base configurable au build (BASE_PATH) pour le sous-chemin YunoHost, paths.relative = false - i18n fr (structure prête gcf/en), pages : accueil public/privé, erreurs designées - Tests vitest : résolution SSO + routes publiques (10 cas) - Vérifié : check/lint/test/build verts, test fumée HTTP sur build (SSO, CSP, sous-chemin /veille), zéro emoji et zéro domaine tiers dans le build
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Configuration de veille-ia — copier en .env pour le dev local.
|
||||
# En production (YunoHost), ces variables sont posées par le service systemd / .env d'install.
|
||||
|
||||
# Dossier de données persistant (YAML + dépôt git interne). Défaut dev : ./data
|
||||
# DATA_DIR=./data
|
||||
|
||||
# Endpoint Ollama (optionnel : l'app fonctionne sans, en mode dégradé)
|
||||
# OLLAMA_URL=http://127.0.0.1:11434
|
||||
|
||||
# Token Bearer protégeant POST /api/alerts (généré à l'install YunoHost — jamais commité)
|
||||
# ALERTS_TOKEN=
|
||||
|
||||
# Nom du header HTTP injecté par le SSO (défaut : ynh-user, injecté par SSOwat)
|
||||
# SSO_HEADER=ynh-user
|
||||
|
||||
# Dev local uniquement : simule un utilisateur SSO connecté (ignoré en production)
|
||||
DEV_USER=dev
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.17.0 create --template minimal --types ts --add prettier eslint vitest="usages:unit" sveltekit-adapter="adapter:node" --install npm app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,41 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import path from 'node:path';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig, includeIgnoreFile } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Override or add rule settings here, such as:
|
||||
// 'svelte/button-has-type': 'error'
|
||||
rules: {}
|
||||
}
|
||||
);
|
||||
Generated
+3926
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "veille-ia",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@types/node": "^22",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.1.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
const config = {
|
||||
useTabs: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'none',
|
||||
printWidth: 100,
|
||||
plugins: ['prettier-plugin-svelte'],
|
||||
overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }]
|
||||
};
|
||||
|
||||
export default config;
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
/** Identifiant de l'utilisateur authentifié via le SSO YunoHost, null si non connecté. */
|
||||
user: string | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/archivo-latin-700.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<script src="%sveltekit.assets%/theme.js"></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { error, type Handle } from '@sveltejs/kit';
|
||||
import { config } from '$lib/server/config';
|
||||
import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso';
|
||||
|
||||
/**
|
||||
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
|
||||
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
|
||||
* toute autre route (pages et endpoints API) renvoie 401.
|
||||
*/
|
||||
const authHandle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = resoudreUtilisateur(event.request.headers, {
|
||||
ssoHeader: config.ssoHeader,
|
||||
devUser: config.devUser,
|
||||
dev
|
||||
});
|
||||
|
||||
if (!event.locals.user && !estRoutePublique(event.url.pathname, base)) {
|
||||
error(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
/** En-têtes de sécurité complétant la CSP (gérée par la config SvelteKit, mode nonce). */
|
||||
const securityHeadersHandle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
return response;
|
||||
};
|
||||
|
||||
export const handle = sequence(authHandle, securityHeadersHandle);
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Icône du sprite static/icons.svg (set SVG OKI : 24×24, stroke 2 px, currentColor).
|
||||
* Règle charte : zéro emoji en production — tout pictogramme passe par ici.
|
||||
*/
|
||||
import { asset } from '$app/paths';
|
||||
|
||||
let {
|
||||
nom,
|
||||
taille = 24,
|
||||
label = null
|
||||
}: {
|
||||
/** Identifiant du <symbol> dans icons.svg (ex. "avertissement", "ka"). */
|
||||
nom: string;
|
||||
/** Taille en px (côté du carré). */
|
||||
taille?: number;
|
||||
/** Texte accessible ; null = icône décorative (aria-hidden). */
|
||||
label?: string | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={taille}
|
||||
height={taille}
|
||||
aria-hidden={label === null}
|
||||
aria-label={label}
|
||||
role={label === null ? undefined : 'img'}
|
||||
>
|
||||
<use href="{asset('/icons.svg')}#{nom}" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: -0.2em;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: miter;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Libellés UI — français (locale par défaut).
|
||||
* Structure prête pour créole (gcf) et anglais : un fichier par locale,
|
||||
* sélection future par `data-locale` (pas de routing i18n au MVP).
|
||||
* Aucune chaîne en dur dans les templates : tout passe par ici.
|
||||
*/
|
||||
export const fr = {
|
||||
app: {
|
||||
nom: 'veille-ia',
|
||||
description:
|
||||
'Observatoire personnel de veille sur les modèles IA génératifs : sorties, licences, classements, et recommandations par profils de projets.'
|
||||
},
|
||||
nav: {
|
||||
accueil: 'Accueil',
|
||||
registre: 'Registre',
|
||||
profils: 'Profils',
|
||||
recommander: 'Recommander',
|
||||
alertes: 'Alertes',
|
||||
navigation_principale: 'Navigation principale',
|
||||
aller_au_contenu: 'Aller au contenu'
|
||||
},
|
||||
accueil: {
|
||||
titre_public: 'Observatoire de veille IA',
|
||||
acces_reserve:
|
||||
'Accès réservé. Cette application est privée : connectez-vous via le portail YunoHost pour y accéder.',
|
||||
tableau_de_bord: 'Tableau de bord',
|
||||
bienvenue: 'Connecté en tant que',
|
||||
carte_registre_titre: 'Registre des modèles',
|
||||
carte_registre_texte:
|
||||
'Source de vérité unique : modèles, licences, capacités et scores, versionnés git.',
|
||||
carte_profils_titre: 'Profils de projets',
|
||||
carte_profils_texte:
|
||||
'Vos 5 contextes de production et leurs contraintes juridiques (requiert, exclut, préférences).',
|
||||
carte_recommander_titre: 'Moteur de recommandation',
|
||||
carte_recommander_texte:
|
||||
'Shortlist classée et justifiée, en croisant registre et profil : warnings inclus.',
|
||||
carte_alertes_titre: 'Inbox alertes',
|
||||
carte_alertes_texte:
|
||||
'Événements classifiés (sorties, licences, classements) reçus depuis Node-RED.',
|
||||
phase_a_venir: 'Phase à venir'
|
||||
},
|
||||
erreurs: {
|
||||
parStatut: {
|
||||
401: {
|
||||
titre: 'Authentification requise',
|
||||
texte: 'Cette page nécessite une connexion via le portail YunoHost.'
|
||||
},
|
||||
404: {
|
||||
titre: 'Page introuvable',
|
||||
texte_ka: 'Paj-la pa la.',
|
||||
texte: "La page demandée n'existe pas ou a été déplacée."
|
||||
},
|
||||
500: {
|
||||
titre: 'Erreur interne',
|
||||
texte: "Une erreur inattendue s'est produite. Consultez les journaux du service."
|
||||
}
|
||||
},
|
||||
retour_accueil: "Retour à l'accueil"
|
||||
},
|
||||
badge: {
|
||||
a_verifier: 'à vérifier'
|
||||
},
|
||||
pied: {
|
||||
ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.',
|
||||
registre_vide: 'Aucune donnée pour le moment : le registre sera initialisé en phase 1.'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type Libelles = typeof fr;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
/**
|
||||
* Configuration serveur de veille-ia, lue depuis les variables d'environnement.
|
||||
* Toutes les variables sont documentées dans le README et .env.example.
|
||||
* Aucune valeur sensible n'est commitée : ALERTS_TOKEN est généré à l'install YunoHost.
|
||||
*/
|
||||
export const config = {
|
||||
/** Dossier de données persistant (YAML + dépôt git). Défaut dev : ./data relatif à app/. */
|
||||
dataDir: env.DATA_DIR ?? new URL('../../data/', import.meta.url).pathname,
|
||||
|
||||
/** Endpoint Ollama. L'app doit fonctionner même s'il est injoignable (dégradation gracieuse). */
|
||||
ollamaUrl: env.OLLAMA_URL ?? 'http://127.0.0.1:11434',
|
||||
|
||||
/** Token Bearer protégeant POST /api/alerts (généré à l'install YNH). Jamais de défaut. */
|
||||
alertsToken: env.ALERTS_TOKEN ?? null,
|
||||
|
||||
/** Nom du header HTTP injecté par SSOwat (via proxy_params_with_auth nginx) : utilisateur authentifié. */
|
||||
ssoHeader: (env.SSO_HEADER ?? 'ynh-user').toLowerCase(),
|
||||
|
||||
/**
|
||||
* Dev local uniquement : simule un utilisateur SSO quand le header est absent.
|
||||
* Ignoré en production (voir sso.ts) — ne jamais le renseigner sur le serveur.
|
||||
*/
|
||||
devUser: env.DEV_USER ?? null
|
||||
} as const;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { estRoutePublique, resoudreUtilisateur } from './sso';
|
||||
|
||||
const optionsProd = { ssoHeader: 'ynh-user', devUser: null, dev: false };
|
||||
const optionsDev = { ssoHeader: 'ynh-user', devUser: 'alice', dev: true };
|
||||
|
||||
function entetes(valeur?: string): Headers {
|
||||
const h = new Headers();
|
||||
if (valeur !== undefined) h.set('Ynh-User', valeur);
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('resoudreUtilisateur', () => {
|
||||
it('retourne l’utilisateur du header SSO (insensible à la casse)', () => {
|
||||
expect(resoudreUtilisateur(entetes('bob'), optionsProd)).toBe('bob');
|
||||
});
|
||||
|
||||
it('tronque les espaces autour de la valeur', () => {
|
||||
expect(resoudreUtilisateur(entetes(' bob '), optionsProd)).toBe('bob');
|
||||
});
|
||||
|
||||
it('ignore un header vide', () => {
|
||||
expect(resoudreUtilisateur(entetes(' '), optionsProd)).toBeNull();
|
||||
});
|
||||
|
||||
it('retourne null sans header en production, même avec DEV_USER renseigné', () => {
|
||||
expect(
|
||||
resoudreUtilisateur(entetes(), { ssoHeader: 'ynh-user', devUser: 'alice', dev: false })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('utilise DEV_USER en dev quand le header est absent', () => {
|
||||
expect(resoudreUtilisateur(entetes(), optionsDev)).toBe('alice');
|
||||
});
|
||||
|
||||
it('le header SSO prime sur DEV_USER en dev', () => {
|
||||
expect(resoudreUtilisateur(entetes('bob'), optionsDev)).toBe('bob');
|
||||
});
|
||||
|
||||
it('retourne null en dev sans DEV_USER', () => {
|
||||
expect(
|
||||
resoudreUtilisateur(entetes(), { ssoHeader: 'ynh-user', devUser: null, dev: true })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('estRoutePublique', () => {
|
||||
it('la racine est publique (sans base path)', () => {
|
||||
expect(estRoutePublique('/', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('la racine est publique (avec base path)', () => {
|
||||
expect(estRoutePublique('/veille', '/veille')).toBe(true);
|
||||
expect(estRoutePublique('/veille/', '/veille')).toBe(true);
|
||||
});
|
||||
|
||||
it('les autres routes sont privées', () => {
|
||||
expect(estRoutePublique('/registre', '')).toBe(false);
|
||||
expect(estRoutePublique('/veille/registre', '/veille')).toBe(false);
|
||||
expect(estRoutePublique('/api/alerts', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Résolution de l'utilisateur courant depuis les headers de la requête.
|
||||
* Fonction pure, testable unitairement sans serveur.
|
||||
*
|
||||
* En production, le reverse proxy YunoHost (SSOwat) injecte le header SSO
|
||||
* (par défaut `Ynh-User`, via `proxy_params_with_auth` dans conf/nginx.conf du package).
|
||||
* Il n'est pas spoofable : nginx vide ce header pour les requêtes client avant de
|
||||
* le renseigner après authentification.
|
||||
*/
|
||||
export function resoudreUtilisateur(
|
||||
headers: Headers,
|
||||
options: { ssoHeader: string; devUser: string | null; dev: boolean }
|
||||
): string | null {
|
||||
const brut = headers.get(options.ssoHeader)?.trim();
|
||||
if (brut) return brut;
|
||||
// Dev local : utilisateur simulé, jamais actif en production.
|
||||
if (options.dev && options.devUser) return options.devUser;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Routes publiques (accessibles sans utilisateur SSO) : page d'accueil minimale uniquement. */
|
||||
export function estRoutePublique(pathname: string, base: string): boolean {
|
||||
const chemin = base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
||||
return chemin === '' || chemin === '/';
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/* Base — reset, primitives, gate reduced-motion. Importé après oki-tokens.css. */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.light-theme {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
min-height: 100dvh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--action);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--action-hover);
|
||||
}
|
||||
|
||||
/* Liens dans le texte : soulignés (scopés à main). */
|
||||
main a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav a,
|
||||
a.bouton,
|
||||
a.carte-lien {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Focus visible : outline or 2 px, partout. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--action);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Skip-link ── */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: var(--space-2);
|
||||
z-index: 100;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--action);
|
||||
color: var(--noir-oki);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── Bouton OKI canonique ── */
|
||||
.bouton {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--action);
|
||||
background: transparent;
|
||||
border: var(--border-btn);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--dur-tanbou) var(--ease-ka),
|
||||
border-color var(--dur-tanbou) var(--ease-ka),
|
||||
transform var(--dur-tanbou) var(--ease-ka);
|
||||
}
|
||||
|
||||
.bouton:hover {
|
||||
color: var(--action-hover);
|
||||
border-color: var(--action-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Carte OKI canonique ── */
|
||||
.carte {
|
||||
background: var(--card-bg);
|
||||
border: var(--border-card);
|
||||
border-left: 4px solid var(--action);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
transition:
|
||||
transform var(--dur-tanbou) var(--ease-ka),
|
||||
border-color var(--dur-tanbou) var(--ease-ka);
|
||||
}
|
||||
|
||||
.carte:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Tag OKI canonique (badge « à vérifier » inclus) ── */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.15em 0.6em;
|
||||
font-size: 0.85em;
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--action) 8%, transparent);
|
||||
border: 1px solid var(--action);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tag-succes {
|
||||
background: color-mix(in srgb, var(--succes) 10%, transparent);
|
||||
border-color: var(--succes);
|
||||
}
|
||||
|
||||
.tag-signal {
|
||||
background: color-mix(in srgb, var(--signal) 10%, transparent);
|
||||
border-color: var(--signal);
|
||||
}
|
||||
|
||||
/* ── Layout utilitaire ── */
|
||||
.conteneur {
|
||||
width: min(100% - 2 * var(--space-3), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.grille {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── Gate unique reduced-motion : contenu statique complet ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Tokens OKI — copie versionnée de la charte (doc2sveltekit-transition/charte-oki-design-system.md §2-3).
|
||||
Ne jamais recopier ces valeurs à la main dans les composants : importer ce fichier. */
|
||||
|
||||
:root {
|
||||
/* ── Noyau (thème sombre = défaut) ── */
|
||||
--noir-oki: #0d0d0d; /* background */
|
||||
--noir-profond: #1a0f1a; /* surface */
|
||||
--blanc-creme: #fff8e7; /* foreground */
|
||||
--line: rgba(255, 255, 255, 0.1); /* filets — jamais de gris plein */
|
||||
--or-oki: #fdb813; /* accent : SEULE couleur d'action */
|
||||
--rouge-oki: #ff1654; /* signal — jamais un lien/bouton */
|
||||
--vert-oki: #00d66c; /* succès, validation */
|
||||
--or-clair: #ffe066; /* survol des boutons */
|
||||
--gris-sombre: #2d1b2e;
|
||||
|
||||
/* ── Sémantique dérivée ── */
|
||||
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
|
||||
/* ── Alias applicatifs (rôles jamais permutés : or = agir · vert = valider · rouge = signaler) ── */
|
||||
--bg: var(--noir-oki);
|
||||
--surface: var(--noir-profond);
|
||||
--fg: var(--blanc-creme);
|
||||
--action: var(--or-oki);
|
||||
--action-hover: var(--or-clair);
|
||||
--succes: var(--vert-oki);
|
||||
--signal: var(--rouge-oki);
|
||||
|
||||
/* ── Typographie ── */
|
||||
--font-display: 'Archivo', 'Arial Black', sans-serif; /* 600–900, titres + boutons */
|
||||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* 400–800 */
|
||||
|
||||
/* ── Formes, espacements, layout ── */
|
||||
--radius-sm: 3px; /* boutons, tags, badges */
|
||||
--radius-md: 6px; /* cartes — jamais plus */
|
||||
--border-card: 1px solid var(--line);
|
||||
--border-btn: 2px solid var(--or-oki);
|
||||
--space-1: 0.75rem;
|
||||
--space-2: 1rem;
|
||||
--space-3: 1.5rem;
|
||||
--space-4: 2rem;
|
||||
--space-5: 4rem;
|
||||
--container: 1200px;
|
||||
|
||||
/* ── Motion (cadences gwoka, version sobre back-office) ── */
|
||||
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dur-tanbou: 120ms; /* micro-interactions, hovers */
|
||||
--dur-mesure: 480ms; /* transitions de vues */
|
||||
--dur-phrase: 960ms;
|
||||
}
|
||||
|
||||
/* Thème clair opt-in : accents assombris pour rester WCAG AA. */
|
||||
html.light-theme {
|
||||
--noir-oki: #f7f2e7;
|
||||
--noir-profond: #fffdf6;
|
||||
--blanc-creme: #1a0f1a;
|
||||
--line: rgba(26, 15, 26, 0.15);
|
||||
--or-oki: #9a6a00;
|
||||
--rouge-oki: #c40f42;
|
||||
--vert-oki: #007a3d;
|
||||
--or-clair: #7a5400;
|
||||
--card-bg: rgba(26, 15, 26, 0.04);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
const statut = $derived(page.status);
|
||||
|
||||
const contenu = $derived(
|
||||
statut === 401 || statut === 404 ? fr.erreurs.parStatut[statut] : fr.erreurs.parStatut[500]
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{statut} — {contenu.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="erreur">
|
||||
<Icon nom="ka" taille={64} />
|
||||
<h1>{statut} — {contenu.titre}</h1>
|
||||
{#if statut === 404 && 'texte_ka' in contenu}
|
||||
<p lang="gcf" class="ka">{contenu.texte_ka}</p>
|
||||
{/if}
|
||||
<p>{contenu.texte}</p>
|
||||
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erreur {
|
||||
max-width: 60ch;
|
||||
margin-inline: auto;
|
||||
padding-block: var(--space-5);
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.erreur :global(svg) {
|
||||
margin-inline: auto;
|
||||
color: var(--action);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.ka {
|
||||
color: var(--action);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/oki-tokens.css';
|
||||
import '$lib/styles/base.css';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
// Navigation privée : visible uniquement avec un utilisateur SSO.
|
||||
// Les entrées "bientot" correspondent aux phases 1 et 2 du PRD.
|
||||
const entrees = [
|
||||
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
|
||||
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: true },
|
||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: true },
|
||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: true },
|
||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#contenu">{fr.nav.aller_au_contenu}</a>
|
||||
|
||||
<header class="entete">
|
||||
<nav class="conteneur nav-principale" aria-label={fr.nav.navigation_principale}>
|
||||
<a class="marque" href={resolve('/')}>
|
||||
<Icon nom="ka" taille={28} />
|
||||
<span>{fr.app.nom}</span>
|
||||
</a>
|
||||
{#if data.user}
|
||||
<ul class="liens">
|
||||
{#each entrees as entree (entree.href)}
|
||||
<li>
|
||||
{#if entree.bientot}
|
||||
<span
|
||||
class="lien lien-desactive"
|
||||
aria-disabled="true"
|
||||
title={fr.accueil.phase_a_venir}
|
||||
>
|
||||
<Icon nom={entree.icone} taille={18} />
|
||||
{entree.libelle}
|
||||
</span>
|
||||
{:else}
|
||||
<a class="lien" href={resolve(entree.href)}>
|
||||
<Icon nom={entree.icone} taille={18} />
|
||||
{entree.libelle}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="utilisateur">{data.user}</span>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="flag-bar" aria-hidden="true"></div>
|
||||
</header>
|
||||
|
||||
<main id="contenu" class="conteneur" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="pied conteneur">
|
||||
<p>{fr.pied.ligne}</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.entete {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav-principale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-1);
|
||||
}
|
||||
|
||||
.marque {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
color: var(--action);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.lien {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
min-height: 44px;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
color var(--dur-tanbou) var(--ease-ka),
|
||||
border-color var(--dur-tanbou) var(--ease-ka);
|
||||
}
|
||||
|
||||
.lien:hover {
|
||||
color: var(--action);
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.lien-desactive {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.utilisateur {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Flag-bar OKI : 6 px, 4 segments francs, une occurrence par écran. */
|
||||
.flag-bar {
|
||||
height: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#0d0d0d 0 25%,
|
||||
#fdb813 25% 50%,
|
||||
#00d66c 50% 75%,
|
||||
#ff1654 75% 100%
|
||||
);
|
||||
}
|
||||
|
||||
main {
|
||||
padding-block: var(--space-4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pied {
|
||||
padding-block: var(--space-3);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const cartes = [
|
||||
{
|
||||
titre: fr.accueil.carte_registre_titre,
|
||||
texte: fr.accueil.carte_registre_texte,
|
||||
icone: 'registre'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_profils_titre,
|
||||
texte: fr.accueil.carte_profils_texte,
|
||||
icone: 'profils'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_recommander_titre,
|
||||
texte: fr.accueil.carte_recommander_texte,
|
||||
icone: 'cible'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_alertes_titre,
|
||||
texte: fr.accueil.carte_alertes_texte,
|
||||
icone: 'alerte'
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.app.nom} — {fr.accueil.titre_public}</title>
|
||||
<meta name="description" content={fr.app.description} />
|
||||
</svelte:head>
|
||||
|
||||
{#if data.user}
|
||||
<h1>{fr.accueil.tableau_de_bord}</h1>
|
||||
<p class="bienvenue">{fr.accueil.bienvenue} <strong>{data.user}</strong>.</p>
|
||||
|
||||
<div class="grille">
|
||||
{#each cartes as carte (carte.icone)}
|
||||
<article class="carte">
|
||||
<h2>
|
||||
<Icon nom={carte.icone} taille={22} />
|
||||
{carte.titre}
|
||||
</h2>
|
||||
<p>{carte.texte}</p>
|
||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="note-phase">{fr.pied.registre_vide}</p>
|
||||
{:else}
|
||||
<section class="public">
|
||||
<h1>{fr.accueil.titre_public}</h1>
|
||||
<p>{fr.app.description}</p>
|
||||
<p class="acces-reserve">
|
||||
<Icon nom="cle" taille={20} />
|
||||
{fr.accueil.acces_reserve}
|
||||
</p>
|
||||
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.bienvenue {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.carte h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.carte p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.note-phase {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.public {
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.public p {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.acces-reserve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
color: var(--action);
|
||||
margin-bottom: var(--space-3) !important;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
/* Fonts self-hébergées (woff2, subset latin) — zéro appel externe. */
|
||||
/* Fichiers : Archivo 600/700/800 (display) + Inter 400/600/700 (body). */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('archivo-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('archivo-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url('archivo-latin-800.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('inter-latin-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('inter-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('inter-latin-700.woff2') format('woff2');
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
|
||||
<!-- Set SVG veille-ia (conventions OKI : viewBox 24×24, stroke 2 px, currentColor, angles nets).
|
||||
Usage : <use href="icons.svg#id" /> via le composant Icon.svelte. -->
|
||||
<symbol id="ka" viewBox="0 0 24 24">
|
||||
<!-- Tambour ka : icône maîtresse -->
|
||||
<path d="M7 3h10l2 18H5L7 3z" />
|
||||
<path d="M5 8h14M6 15h12" />
|
||||
<path d="M9 3v5M15 3v5" />
|
||||
</symbol>
|
||||
<symbol id="accueil" viewBox="0 0 24 24">
|
||||
<path d="M3 12l9-9 9 9" />
|
||||
<path d="M5 10v11h14V10" />
|
||||
</symbol>
|
||||
<symbol id="registre" viewBox="0 0 24 24">
|
||||
<path d="M4 4h16v16H4z" />
|
||||
<path d="M8 8h8M8 12h8M8 16h5" />
|
||||
</symbol>
|
||||
<symbol id="profils" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21l2-5h12l2 5" />
|
||||
</symbol>
|
||||
<symbol id="cible" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 1v4M12 19v4M1 12h4M19 12h4" />
|
||||
</symbol>
|
||||
<symbol id="alerte" viewBox="0 0 24 24">
|
||||
<path d="M6 16v-5a6 6 0 0 1 12 0v5l2 3H4l2-3z" />
|
||||
<path d="M10 21a2 2 0 0 0 4 0" />
|
||||
</symbol>
|
||||
<symbol id="cle" viewBox="0 0 24 24">
|
||||
<circle cx="8" cy="12" r="5" />
|
||||
<path d="M13 12h9M18 12v4M21 12v3" />
|
||||
</symbol>
|
||||
<symbol id="avertissement" viewBox="0 0 24 24">
|
||||
<path d="M12 3L22 20H2L12 3z" />
|
||||
<path d="M12 10v4M12 17v0.5" />
|
||||
</symbol>
|
||||
<symbol id="verifie" viewBox="0 0 24 24">
|
||||
<path d="M3 13l6 6L21 5" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
# App privée derrière SSO : aucune indexation.
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,11 @@
|
||||
// Bascule de thème anti-FOUC — script externe (CSP : aucun script inline).
|
||||
// Thème sombre par défaut (identité OKI) ; clair opt-in persisté en localStorage.
|
||||
(function () {
|
||||
try {
|
||||
if (localStorage.getItem('veille-ia-theme') === 'light') {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
}
|
||||
} catch {
|
||||
// localStorage indisponible : thème sombre par défaut, rien à faire.
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
// Sous-chemin de montage (YunoHost sert l'app sous https://domaine.tld/<path>).
|
||||
// Injecté au build par le script d'install YNH (BASE_PATH=/veille-ia npm run build).
|
||||
// Vide en dev local. Doit commencer par '/' et ne pas finir par '/'.
|
||||
const envBasePath = process.env.BASE_PATH ?? '';
|
||||
if (envBasePath !== '' && (!envBasePath.startsWith('/') || envBasePath.endsWith('/'))) {
|
||||
throw new Error(`BASE_PATH invalide : "${envBasePath}" (doit commencer par '/' sans '/' final)`);
|
||||
}
|
||||
const basePath = envBasePath as '' | `/${string}`;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) =>
|
||||
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
adapter: adapter(),
|
||||
paths: {
|
||||
base: basePath,
|
||||
// Obligatoire dès que paths.base est utilisé (évite les URLs relatives cassées).
|
||||
relative: false
|
||||
},
|
||||
csp: {
|
||||
mode: 'nonce',
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self'],
|
||||
// Les transitions Svelte créent des <style> inline : unsafe-inline requis.
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:'],
|
||||
'font-src': ['self'],
|
||||
'connect-src': ['self'],
|
||||
'frame-ancestors': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'form-action': ['self'],
|
||||
'object-src': ['none']
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user