diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index cf9b662..4bcdcc1 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "aa6b358c", - "timestamp": 1783341062192 + "version": "f8f8affc", + "timestamp": 1783434543359 } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 036c253..041d18c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import UsersAdmin from "./pages/UsersAdmin"; import ImportLogs from "./pages/ImportLogs"; import BoiteAIdees from "@/pages/BoiteAIdees"; import RssFeeds from "@/pages/RssFeeds"; +import AzureCallback from "@/pages/AzureCallback"; import { Loader2 } from "lucide-react"; // ─── Guard d'authentification ───────────────────────────────────────────────── @@ -125,6 +126,7 @@ function Router() { return ( + diff --git a/client/src/contexts/LocalAuthContext.tsx b/client/src/contexts/LocalAuthContext.tsx index 64cff64..027ac39 100644 --- a/client/src/contexts/LocalAuthContext.tsx +++ b/client/src/contexts/LocalAuthContext.tsx @@ -14,6 +14,7 @@ interface LocalAuthContextType { loading: boolean; login: (identifier: string, password: string) => Promise; logout: () => Promise; + hydrateUser: (user: LocalUser) => void; isAuthenticated: boolean; } @@ -57,6 +58,11 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) { localStorage.removeItem(LOCAL_USER_KEY); }; + const hydrateUser = (u: LocalUser) => { + setUser(u); + localStorage.setItem(LOCAL_USER_KEY, JSON.stringify(u)); + }; + return ( diff --git a/client/src/pages/AzureCallback.tsx b/client/src/pages/AzureCallback.tsx new file mode 100644 index 0000000..62d5135 --- /dev/null +++ b/client/src/pages/AzureCallback.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { useLocation } from "wouter"; +import { useLocalAuth } from "@/contexts/LocalAuthContext"; +import { Loader2 } from "lucide-react"; + +/** + * Page intermédiaire appelée après le callback Azure AD. + * Elle récupère les infos user depuis le query param, hydrate LocalAuthContext + * (localStorage + state), puis redirige vers /veille. + */ +export default function AzureCallback() { + const [, navigate] = useLocation(); + const { hydrateUser } = useLocalAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const userParam = params.get("user"); + + if (userParam) { + try { + const user = JSON.parse(decodeURIComponent(userParam)); + hydrateUser(user); + navigate("/veille"); + } catch { + navigate("/login?error=" + encodeURIComponent("Erreur lors de la connexion Microsoft")); + } + } else { + navigate("/login?error=" + encodeURIComponent("Réponse Azure invalide")); + } + }, []); + + return ( +
+
+ +

Connexion Microsoft en cours…

+
+
+ ); +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index ac21d1e..a416ac9 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useLocation } from "wouter"; import { useLocalAuth } from "@/contexts/LocalAuthContext"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -11,14 +12,40 @@ import { toast } from "sonner"; const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/itinova-logo_8e653b24.jpg"; const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/santinova-logo_b8de54c4.webp"; +function MicrosoftLogo() { + return ( + + + + + + + ); +} + export default function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); + const [azureLoading, setAzureLoading] = useState(false); const { login } = useLocalAuth(); const [, navigate] = useLocation(); + const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery(); + const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, { enabled: false, retry: false }); + const azureAvailable = azureAvailableQuery.data?.available ?? false; + + // Afficher les erreurs depuis query param (callback Azure) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const error = params.get("error"); + if (error) { + toast.error(decodeURIComponent(error)); + window.history.replaceState({}, "", "/login"); + } + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email || !password) return; @@ -34,6 +61,22 @@ export default function Login() { } }; + const handleMicrosoftLogin = async () => { + setAzureLoading(true); + try { + const result = await azureLoginQuery.refetch(); + if (result.data?.url) { + window.location.href = result.data.url; + } else { + toast.error("Impossible d'obtenir l'URL de connexion Microsoft"); + setAzureLoading(false); + } + } catch { + toast.error("Erreur lors de la connexion Microsoft"); + setAzureLoading(false); + } + }; + return (
{/* Décoration de fond */} @@ -67,6 +110,34 @@ export default function Login() { + {/* Bouton Microsoft 365 — affiché uniquement si Azure AD est configuré */} + {azureAvailable && ( + <> + +
+
+ +
+
+ ou +
+
+ + )} +
diff --git a/drizzle/0011_sharp_gambit.sql b/drizzle/0011_sharp_gambit.sql new file mode 100644 index 0000000..5981978 --- /dev/null +++ b/drizzle/0011_sharp_gambit.sql @@ -0,0 +1,2 @@ +ALTER TABLE `local_users` ADD `azureAdId` varchar(128);--> statement-breakpoint +ALTER TABLE `local_users` ADD CONSTRAINT `local_users_azureAdId_unique` UNIQUE(`azureAdId`); \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..11a92c5 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1051 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "69c08e24-9f7e-4ed9-8c73-5f8736bb4a74", + "prevId": "b5b3cc6f-d9fc-4020-b148-0cbcd8b25335", + "tables": { + "aap_items": { + "name": "aap_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departement": { + "name": "departement", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departements": { + "name": "departements", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateCloture": { + "name": "dateCloture", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "aap_items_id": { + "name": "aap_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "aap_items_dedupKey_unique": { + "name": "aap_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_settings_id": { + "name": "app_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "article_reads": { + "name": "article_reads", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleType": { + "name": "articleType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleId": { + "name": "articleId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "article_reads_id": { + "name": "article_reads_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ideas": { + "name": "ideas", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userName": { + "name": "userName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouvert','en_cours','resolu','ferme')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouvert'" + }, + "reponseAdmin": { + "name": "reponseAdmin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduPar": { + "name": "reponduPar", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduAt": { + "name": "reponduAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ideas_id": { + "name": "ideas_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "import_logs": { + "name": "import_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fileType": { + "name": "fileType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','partial','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalRows": { + "name": "totalRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "newRows": { + "name": "newRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "skippedRows": { + "name": "skippedRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "import_logs_id": { + "name": "import_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_users": { + "name": "local_users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','user','readonly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "azureAdId": { + "name": "azureAdId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "local_users_id": { + "name": "local_users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "local_users_username_unique": { + "name": "local_users_username_unique", + "columns": [ + "username" + ] + }, + "local_users_azureAdId_unique": { + "name": "local_users_azureAdId_unique", + "columns": [ + "azureAdId" + ] + } + }, + "checkConstraint": {} + }, + "processed_dedup_keys": { + "name": "processed_dedup_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "processed_dedup_keys_id": { + "name": "processed_dedup_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "processed_dedup_keys_dedupKey_unique": { + "name": "processed_dedup_keys_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + }, + "rss_feeds": { + "name": "rss_feeds", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultTypeVeille": { + "name": "defaultTypeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','informationnelle')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultCategorieAap": { + "name": "defaultCategorieAap", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoRules": { + "name": "autoRules", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchStatus": { + "name": "lastFetchStatus", + "type": "enum('ok','error','pending')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "lastFetchError": { + "name": "lastFetchError", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_feeds_id": { + "name": "rss_feeds_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rss_settings": { + "name": "rss_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fetchIntervalMinutes": { + "name": "fetchIntervalMinutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 360 + }, + "scheduledTime": { + "name": "scheduledTime", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'06:00'" + }, + "fetchMode": { + "name": "fetchMode", + "type": "enum('interval','scheduled')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "autoFetchEnabled": { + "name": "autoFetchEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_settings_id": { + "name": "rss_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + }, + "veille_items": { + "name": "veille_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "niveau": { + "name": "niveau", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territoire": { + "name": "territoire", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territoires": { + "name": "territoires", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passage": { + "name": "passage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeVeille": { + "name": "typeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','informationnelle')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "veille_items_id": { + "name": "veille_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "veille_items_dedupKey_unique": { + "name": "veille_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2850d1c..6d663d0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1782978530081, "tag": "0010_graceful_nova", "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1783432258494, + "tag": "0011_sharp_gambit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 3346f95..7e606e5 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -36,6 +36,7 @@ export const localUsers = mysqlTable("local_users", { passwordHash: varchar("passwordHash", { length: 255 }).notNull(), role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(), isActive: boolean("isActive").default(true).notNull(), + azureAdId: varchar("azureAdId", { length: 128 }).unique(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), lastSignedIn: timestamp("lastSignedIn"), diff --git a/package.json b/package.json index 177d54d..9bf9525 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/s3-request-presigner": "^3.693.0", + "@azure/msal-node": "^5.3.1", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c4fd0f..f3a188f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.693.0 version: 3.907.0 + '@azure/msal-node': + specifier: ^5.3.1 + version: 5.3.1 '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.64.0(react@19.2.1)) @@ -477,6 +480,14 @@ packages: resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==} engines: {node: '>=18.0.0'} + '@azure/msal-common@16.11.0': + resolution: {integrity: sha512-UikJOtMwkFpZNzTH6Dqk8UTUPbow15zH3e0UjGYZy69lYENW/S05gMLhbxI2eonz66uALhIljvhsSMEb6+O30g==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.3.1': + resolution: {integrity: sha512-sqqv3L1UOI4KDXonNtbxPYUgbSWVXqxvmmb6BUw9n4P/UXgG+cVur3dLWQN4Cz7qQ+UJROCCxMXlksm7gIq0Sw==} + engines: {node: '>=20'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -2481,6 +2492,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2968,6 +2982,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3336,6 +3353,16 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.25: resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==} hasBin: true @@ -3427,6 +3454,27 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4001,6 +4049,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -4949,6 +5002,13 @@ snapshots: '@aws/lambda-invoke-store@0.0.1': {} + '@azure/msal-common@16.11.0': {} + + '@azure/msal-node@5.3.1': + dependencies: + '@azure/msal-common': 16.11.0 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -6953,6 +7013,8 @@ snapshots: node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} busboy@1.6.0: @@ -7347,6 +7409,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.230: {} @@ -7837,6 +7903,30 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.25: dependencies: commander: 8.3.0 @@ -7910,6 +8000,20 @@ snapshots: lodash-es@4.17.21: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash@4.17.21: {} long@5.3.2: {} @@ -8774,6 +8878,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 diff --git a/server/_core/index.ts b/server/_core/index.ts index 31deca1..e5ee2cd 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite"; import { runFullImport } from "../importer"; import uploadRoutes from "../uploadRoutes"; import scheduledRoutes from "../scheduledRoutes"; -import { ensureAdminExists } from "../localAuth"; -import { getSetting, purgeOldArticles } from "../db"; +import { ensureAdminExists, generateLocalToken } from "../localAuth"; +import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth"; +import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db"; import { runRssFetch } from "../rssEngine"; function isPortAvailable(port: number): Promise { @@ -126,6 +127,87 @@ async function startServer() { registerOAuthRoutes(app); app.use(uploadRoutes); app.use(scheduledRoutes); + + // ─── Azure AD OAuth2 callback ───────────────────────────────────────────── + app.get("/api/auth/azure/callback", async (req, res) => { + const code = req.query.code as string | undefined; + const error = req.query.error as string | undefined; + + if (error) { + res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`); + return; + } + if (!code) { + res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant")); + return; + } + if (!isAzureAdConfigured()) { + res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9")); + return; + } + + try { + const azureUser = await handleAzureCallback(code); + + // Chercher par azureAdId puis par email + let user = await getLocalUserByAzureAdId(azureUser.azureAdId); + if (!user) user = await getLocalUserByEmail(azureUser.email); + + if (!user) { + // Cr\u00e9er automatiquement avec r\u00f4le "user" + await upsertLocalUserAzure({ + email: azureUser.email, + name: azureUser.name, + azureAdId: azureUser.azureAdId, + role: "user", + }); + user = await getLocalUserByEmail(azureUser.email); + } else if (!user.azureAdId) { + // Lier le compte existant \u00e0 Azure AD + await upsertLocalUserAzure({ + email: user.email ?? azureUser.email, + azureAdId: azureUser.azureAdId, + }); + } + + if (!user || !user.isActive) { + res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable")); + return; + } + + // Mettre \u00e0 jour lastSignedIn + const db = await (await import("../db")).getDb(); + if (db) { + const { localUsers } = await import("../../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id)); + } + + // G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie + const token = await generateLocalToken(user.id, user.role); + res.cookie("veille_local_auth", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + // Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext + const userPayload = JSON.stringify({ + id: user.id, + name: user.name, + username: user.username ?? null, + email: user.email ?? null, + role: user.role, + }); + // Rediriger vers une page de callback qui hydrate le contexte + res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`); + } catch (err: any) { + console.error("[Azure AD] Erreur callback:", err.message); + res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft")); + } + }); app.use( "/api/trpc", createExpressMiddleware({ router: appRouter, createContext }) diff --git a/server/azureAuth.test.ts b/server/azureAuth.test.ts new file mode 100644 index 0000000..6f799d0 --- /dev/null +++ b/server/azureAuth.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth"; + +describe("Azure AD configuration", () => { + it("should detect Azure AD as configured when env vars are set", () => { + // Les variables sont injectées via webdev_request_secrets + const configured = isAzureAdConfigured(); + expect(configured).toBe(true); + }); + + it("should generate a valid Azure AD auth URL", async () => { + if (!isAzureAdConfigured()) { + console.warn("Azure AD not configured, skipping URL test"); + return; + } + const url = await getAzureAuthUrl(); + expect(url).toContain("login.microsoftonline.com"); + expect(url).toContain("oauth2/v2.0/authorize"); + expect(url).toContain("f496da82-e18f-4567-bf05-8551ae6669b2"); // client_id + }); +}); diff --git a/server/azureAuth.ts b/server/azureAuth.ts new file mode 100644 index 0000000..c275b80 --- /dev/null +++ b/server/azureAuth.ts @@ -0,0 +1,76 @@ +import { ConfidentialClientApplication } from "@azure/msal-node"; + +// ─── Azure AD Authentication ────────────────────────────────────────────────── + +let msalClient: ConfidentialClientApplication | null = null; + +/** + * Vérifie que les 3 variables d'environnement Azure AD sont présentes + */ +export function isAzureAdConfigured(): boolean { + return !!( + process.env.AZURE_AD_TENANT_ID && + process.env.AZURE_AD_CLIENT_ID && + process.env.AZURE_AD_CLIENT_SECRET + ); +} + +/** + * Instancie le client MSAL (lazy, singleton) + */ +function getMsalClient(): ConfidentialClientApplication { + if (!isAzureAdConfigured()) { + throw new Error("Azure AD is not configured"); + } + if (!msalClient) { + msalClient = new ConfidentialClientApplication({ + auth: { + clientId: process.env.AZURE_AD_CLIENT_ID!, + authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET!, + }, + }); + } + return msalClient; +} + +/** + * Retourne l'URL de redirection Azure AD pour l'utilisateur + */ +export async function getAzureAuthUrl(): Promise { + const client = getMsalClient(); + const redirectUri = + process.env.AZURE_AD_REDIRECT_URI || + "http://localhost:3000/api/auth/azure/callback"; + return client.getAuthCodeUrl({ + scopes: ["user.read"], + redirectUri, + }); +} + +/** + * Échange le code OAuth contre un token et retourne les infos utilisateur. + * azureAdId = homeAccountId = "{objectId}.{tenantId}" (~73 caractères) + */ +export async function handleAzureCallback(code: string) { + const client = getMsalClient(); + const redirectUri = + process.env.AZURE_AD_REDIRECT_URI || + "http://localhost:3000/api/auth/azure/callback"; + + const response = await client.acquireTokenByCode({ + code, + scopes: ["user.read"], + redirectUri, + }); + + if (!response || !response.account) { + throw new Error("Failed to acquire token from Azure AD"); + } + + return { + azureAdId: response.account.homeAccountId, // "{objectId}.{tenantId}" + email: response.account.username, // UPN (ex: user@domain.com) + name: response.account.name || response.account.username, + }; +} diff --git a/server/db.ts b/server/db.ts index aa109b4..a63095e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -116,6 +116,51 @@ export async function deleteLocalUser(id: number) { await db.delete(localUsers).where(eq(localUsers.id, id)); } +export async function getLocalUserByAzureAdId(azureAdId: string) { + const db = await getDb(); + if (!db) return null; + const results = await db.select().from(localUsers).where(eq(localUsers.azureAdId, azureAdId)).limit(1); + return results[0] ?? null; +} + +export async function getLocalUserByEmail(email: string) { + const db = await getDb(); + if (!db) return null; + const results = await db.select().from(localUsers).where(eq(localUsers.email, email)).limit(1); + return results[0] ?? null; +} + +export async function upsertLocalUserAzure(data: { + email: string; + name?: string; + azureAdId: string; + role?: "admin" | "user" | "readonly"; +}) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + // Chercher si l'utilisateur existe déjà par email + const existing = await getLocalUserByEmail(data.email); + if (existing) { + // Lier le compte existant à Azure AD + await db.update(localUsers) + .set({ azureAdId: data.azureAdId, ...(data.name && { name: data.name }) }) + .where(eq(localUsers.id, existing.id)); + return existing.id; + } else { + // Créer un nouveau compte (sans mot de passe — connexion Azure uniquement) + const result = await db.insert(localUsers).values({ + name: data.name ?? data.email, + username: data.email, + email: data.email, + passwordHash: "", // Pas de mot de passe local + role: data.role ?? "user", + isActive: true, + azureAdId: data.azureAdId, + }); + return (result as any)[0]?.insertId ?? null; + } +} + // ─── Veille Items ───────────────────────────────────────────────────────────── export interface VeilleFilters { diff --git a/server/routers.ts b/server/routers.ts index ef5b861..d0d6492 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -35,6 +35,7 @@ import { import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; import { scheduleDailyImport } from "./_core/index"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; +import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth"; import { classifyArticle } from "./aiClassifier"; import { getDb } from "./db"; import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema"; @@ -80,6 +81,17 @@ export const appRouter = router({ ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 }); return { success: true }; }), + // Azure AD + isAzureAdAvailable: publicProcedure.query(() => { + return { available: isAzureAdConfigured() }; + }), + getAzureLoginUrl: publicProcedure.query(async () => { + if (!isAzureAdConfigured()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD non configur\u00e9" }); + } + const url = await getAzureAuthUrl(); + return { url }; + }), }), // ─── Veille ─────────────────────────────────────────────────────────────────