(int) db()->query('SELECT COUNT(*) FROM utilisateurs')->fetchColumn() === 0; function reponse_json(array $donnees, int $code = 200): never { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($donnees, JSON_UNESCAPED_UNICODE); exit; } function vider_import_en_attente(): void { $imp = $_SESSION['import'] ?? null; unset($_SESSION['import']); // Un CV du lot garde ses fichiers : seul le lot les retire if ($imp && empty($imp['lot'])) { vider_dossier_import((string) $imp['dossier']); } } /** Termine une connexion : session ouverte, échecs oubliés, connexion notée, alerte si nouveau réseau pour un gestionnaire. */ function finaliser_connexion(array $u): void { ouvrir_session($u); effacer_echecs($u['identifiant']); db()->prepare('UPDATE utilisateurs SET derniere_connexion = NOW() WHERE id = ?')->execute([$u['id']]); $nouveauReseau = noter_connexion((int) $u['id'], $u['identifiant'], 'reussie'); if ($u['role'] === 'admin') { if ($nouveauReseau) { alerter_nouveau_reseau($u); } // Ménage fait à la connexion d'un gestionnaire, sans attendre la tâche planifiée try { purger_corbeille(); purger_connexions(); } catch (Throwable $ex) { consigner_erreur(get_class($ex), $ex->getMessage(), $ex->getFile(), $ex->getLine()); } } } /** Après la connexion : la page demandée avant, si elle est de ce site, sinon le Kanban. */ function rediriger_apres_connexion(): never { $retour = (string) ($_SESSION['retour'] ?? ''); unset($_SESSION['retour']); header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban'))); exit; } /** Filtres de la recherche lus dans l'adresse. */ function filtres_recherche(): array { return [ 'statut' => (string) ($_GET['statut'] ?? ''), 'seniorite' => (string) ($_GET['seniorite'] ?? ''), 'langue' => (string) ($_GET['langue'] ?? ''), 'nationalite' => in_array($_GET['nationalite'] ?? '', ['ue', 'hors_ue', 'inconnue'], true) ? (string) $_GET['nationalite'] : '', 'permis' => ($_GET['permis'] ?? '') === 'expire' ? 'expire' : '', 'archives' => in_array($_GET['archives'] ?? '', ['inclus', 'seuls'], true) ? (string) $_GET['archives'] : '', ]; } /** Après un changement qui ferme les sessions du compte : la session en cours suit la nouvelle version. */ function garder_session_courante(int $utilisateurId): void { $req = db()->prepare('SELECT session_version FROM utilisateurs WHERE id = ?'); $req->execute([$utilisateurId]); $_SESSION['sv'] = (int) $req->fetchColumn(); session_regenerate_id(true); } /** Redirige vers une page, à une section donnée. */ function rediriger_ancre(string $page, string $ancre, array $params = []): never { header('Location: ' . url($page, $params) . '#' . $ancre); exit; } /** Remet à plat un champ de fichiers multiples : une entrée par fichier, comme pour un champ simple. */ function fichiers_multiples(?array $champ): array { $fichiers = []; foreach ((array) ($champ['name'] ?? []) as $i => $nom) { if (($champ['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { continue; } $fichiers[] = ['name' => $nom, 'type' => $champ['type'][$i] ?? '', 'tmp_name' => $champ['tmp_name'][$i] ?? '', 'error' => $champ['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $champ['size'][$i] ?? 0]; } return $fichiers; } switch ($page) { // ----------------------------------------------------------------------- // Premier lancement : création du compte administrateur // ----------------------------------------------------------------------- case 'installation': if (!$aucunCompte()) { rediriger('connexion'); } $erreur = null; if ($post) { try { if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) { throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.')); } $id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'admin'); // Le premier compte active aussi la double authentification avant d'entrer commencer_etape($id); rediriger('double_authentification'); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('installation', ['titre' => t('Installation'), 'erreur' => $erreur]); break; // ----------------------------------------------------------------------- // Connexion, étape 1 : identifiant et mot de passe // ----------------------------------------------------------------------- case 'connexion': if ($aucunCompte()) { rediriger('installation'); } if (utilisateur()) { rediriger('kanban'); } $erreur = null; $info = null; if (!empty($_SESSION['session_expiree']) && !$post) { unset($_SESSION['session_expiree']); $info = t('Votre session est terminée. Reconnectez-vous.'); } if ($post) { unset($_SESSION['etape']); $identifiant = mb_strtolower(trim((string) ($_POST['identifiant'] ?? ''))); $mdp = (string) ($_POST['mot_de_passe'] ?? ''); $attente = minutes_blocage($identifiant); if ($attente > 0) { $req = db()->prepare('SELECT id FROM utilisateurs WHERE identifiant = ?'); $req->execute([$identifiant]); noter_connexion(($id = $req->fetchColumn()) ? (int) $id : null, $identifiant, 'bloquee'); $erreur = t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente); } else { $req = db()->prepare('SELECT * FROM utilisateurs WHERE identifiant = ?'); $req->execute([$identifiant]); $u = $req->fetch() ?: null; // Vérification à durée constante, y compris si le compte n'existe pas $empreinte = $u['mot_de_passe'] ?? password_hash(random_bytes(12), algorithme_mot_de_passe()); $bon = password_verify(mb_substr($mdp, 0, LONGUEUR_MAX_MOT_DE_PASSE), $empreinte) && $u; if ($bon && $u['actif']) { // Ancien hachage (bcrypt) : il passe en Argon2id sans rien demander if (password_needs_rehash($u['mot_de_passe'], algorithme_mot_de_passe())) { db()->prepare('UPDATE utilisateurs SET mot_de_passe = ? WHERE id = ?')->execute([hacher_mot_de_passe($mdp), $u['id']]); } commencer_etape((int) $u['id']); rediriger(suite_connexion($u)); } if ($bon) { // Le mot de passe est le bon : la personne peut savoir où en est son compte noter_connexion((int) $u['id'], $identifiant, $u['en_attente'] ? 'attente' : 'desactive'); $erreur = $u['en_attente'] ? t('Votre demande de compte attend l\'accord du gestionnaire. Vous pourrez vous connecter ensuite.') : t('Ce compte est désactivé. Adressez-vous au gestionnaire de Vivier.'); } else { noter_echec($identifiant); noter_connexion($u ? (int) $u['id'] : null, $identifiant, 'mot_de_passe'); usleep(random_int(300000, 700000)); $erreur = t('Identifiant ou mot de passe incorrect.'); } } } vue('connexion', ['titre' => t('Connexion'), 'erreur' => $erreur, 'info' => $info]); break; // ----------------------------------------------------------------------- // Connexion, étape 2 : code de l'application, ou code de secours // ----------------------------------------------------------------------- case 'verification': $e = etape_connexion(); $u = $e ? compte_par_id((int) $e['uid']) : null; if (!$u || !$u['actif'] || suite_connexion($u) !== 'verification') { rediriger('connexion'); } $erreur = null; if ($post) { $attente = minutes_blocage($u['identifiant']); if ($attente > 0) { noter_connexion((int) $u['id'], $u['identifiant'], 'bloquee'); unset($_SESSION['etape']); flash(t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente), 'erreur'); rediriger('connexion'); } $type = verifier_second_facteur($u, (string) ($_POST['code'] ?? '')); if ($type !== null) { finaliser_connexion($u); if ($type === 'secours') { $reste = codes_secours_restants(compte_par_id((int) $u['id']) ?? []); flash(tn($reste, 'Code de secours accepté. Il vous en reste %d.', 'Code de secours accepté. Il vous en reste %d.') . ($reste <= 3 ? ' ' . t('Générez-en de nouveaux dans Mon compte.') : ''), $reste <= 3 ? 'erreur' : 'info'); } rediriger_apres_connexion(); } noter_echec($u['identifiant']); noter_connexion((int) $u['id'], $u['identifiant'], 'code'); $erreur = t('Code incorrect. Vérifiez l\'heure de votre téléphone, ou utilisez un code de secours.'); } vue('verification', ['titre' => t('Vérification'), 'erreur' => $erreur]); break; // ----------------------------------------------------------------------- // Double authentification : première activation, ou nouvelle application pour un compte connecté // ----------------------------------------------------------------------- case 'double_authentification': $moi = utilisateur(); $e = $moi ? null : etape_connexion(); $u = $moi ? compte_par_id((int) $moi['id']) : ($e ? compte_par_id((int) $e['uid']) : null); if (!$u || !$u['actif'] || (!$moi && suite_connexion($u) !== 'double_authentification')) { rediriger('connexion'); } // La clé proposée reste la même tant que la page n'est pas confirmée if (empty($_SESSION['totp_nouvelle']) || (int) ($_SESSION['totp_nouvelle']['uid'] ?? 0) !== (int) $u['id']) { $_SESSION['totp_nouvelle'] = ['uid' => (int) $u['id'], 'cle' => Totp::nouvelleCle()]; } $cle = $_SESSION['totp_nouvelle']['cle']; $erreur = null; if ($post) { try { if ($moi) { confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? '')); } $pas = Totp::verifier($cle, (string) ($_POST['code'] ?? ''), null); if ($pas === null) { throw new InvalidArgumentException(t('Code incorrect. Vérifiez que l\'heure de votre téléphone est juste, puis saisissez le code affiché maintenant.')); } activer_double_authentification((int) $u['id'], $cle, $pas); unset($_SESSION['totp_nouvelle']); $codes = generer_codes_secours((int) $u['id']); if ($moi) { // Nouvelle application : les autres sessions se ferment, celle-ci reste ouverte fermer_sessions((int) $u['id'], true); journaliser(null, 'securite', 'nouvelle_application', null, $u['identifiant']); } else { finaliser_connexion(compte_par_id((int) $u['id'])); } $_SESSION['codes_a_montrer'] = $codes; rediriger('codes_secours'); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('double_authentification', [ 'titre' => t('Double authentification'), 'cle' => $cle, 'adresse' => Totp::adresse($cle, $u['identifiant'], (string) (config('nom_app') ?: 'Vivier')), 'erreur' => $erreur, 'connecte' => $moi !== null, ]); break; // Codes de secours : montrés une seule fois, juste après leur création case 'codes_secours': $moi = exiger_connexion(); $codes = $_SESSION['codes_a_montrer'] ?? null; unset($_SESSION['codes_a_montrer']); if (!$codes) { rediriger('compte'); } vue('codes_secours', ['titre' => t('Codes de secours'), 'codes' => $codes, 'moi' => $moi]); break; // ----------------------------------------------------------------------- // Mot de passe donné par le gestionnaire : la personne le remplace avant d'entrer // ----------------------------------------------------------------------- case 'nouveau_mot_de_passe': $e = etape_connexion(); $u = $e ? compte_par_id((int) $e['uid']) : null; if (!$u || !$u['actif'] || suite_connexion($u) !== 'nouveau_mot_de_passe') { rediriger('connexion'); } $erreur = null; if ($post) { try { $nouveau = (string) ($_POST['nouveau'] ?? ''); if ($nouveau !== ($_POST['confirmation'] ?? '')) { throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.')); } if (password_verify($nouveau, $u['mot_de_passe'])) { throw new InvalidArgumentException(t('Choisissez un mot de passe différent de celui que vous avez reçu.')); } changer_mot_de_passe((int) $u['id'], $nouveau); $u = compte_par_id((int) $u['id']); commencer_etape((int) $u['id']); rediriger(suite_connexion($u)); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('nouveau_mot_de_passe', ['titre' => t('Nouveau mot de passe'), 'erreur' => $erreur]); break; // ----------------------------------------------------------------------- // Invitation : la personne crée son compte avec le lien reçu du gestionnaire // ----------------------------------------------------------------------- case 'invitation': if ($aucunCompte()) { rediriger('installation'); } $jeton = (string) ($_GET['jeton'] ?? $_POST['jeton'] ?? ''); $inv = invitation($jeton); $erreur = null; if ($inv && $post) { try { if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) { throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.')); } $id = accepter_invitation($inv, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? '')); unset($_SESSION['uid']); commencer_etape($id); rediriger('double_authentification'); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('invitation', ['titre' => t('Rejoindre Vivier'), 'inv' => $inv, 'jeton' => $jeton, 'erreur' => $erreur]); break; // L'ancienne page de demande de compte mène désormais à une explication case 'inscription': vue('inscription', ['titre' => t('Obtenir un accès')]); break; // ----------------------------------------------------------------------- // Guide Sécurité : lisible sans être connecté, depuis la page de connexion // ----------------------------------------------------------------------- case 'securite': $langue = (string) ($_GET['langue'] ?? ''); $aide = aide('securite', isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante()); vue('aide', ['titre' => t(DOCUMENTS_AIDE['securite']), 'guide' => 'securite', 'aide' => $aide]); break; case 'deconnexion': if ($post) { vider_import_en_attente(); $_SESSION = []; session_destroy(); } rediriger('connexion'); // ----------------------------------------------------------------------- // Kanban // ----------------------------------------------------------------------- case 'kanban': exiger_connexion(); vue('kanban', ['titre' => t('Kanban'), 'colonnes' => candidats_kanban()]); break; case 'statut': exiger_ecriture(); $ajax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch'; $id = (int) ($_POST['id'] ?? 0); try { if (!$post) { throw new InvalidArgumentException(t('Requête invalide.')); } $statut = (string) ($_POST['statut'] ?? ''); db()->beginTransaction(); changer_statut($id, $statut, $_POST['motif'] ?? null); if (isset($_POST['ordre']) && $_POST['ordre'] !== '') { ordonner_colonne($statut, array_filter(array_map('intval', explode(',', (string) $_POST['ordre'])))); } db()->commit(); if ($ajax) { reponse_json(['ok' => true]); } flash(t('Statut mis à jour.'), 'succes'); } catch (InvalidArgumentException $ex) { if (db()->inTransaction()) { db()->rollBack(); } if ($ajax) { reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 422); } flash($ex->getMessage(), 'erreur'); } rediriger('profil', ['id' => $id]); // ----------------------------------------------------------------------- // Import d'un profil // ----------------------------------------------------------------------- case 'import': exiger_ecriture(); $erreurs = []; if ($post && ($_POST['mode'] ?? '') === 'auto') { vider_import_en_attente(); try { if (!Claude::configure()) { throw new RuntimeException(t('L\'analyse automatique n\'est pas configurée.')); } $cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'odt', 'md', 'txt']); $analyse = Claude::analyser($cv); $erreurs = Import::valider($analyse['profil']); if ($erreurs) { array_unshift($erreurs, t('L\'analyse automatique a produit un profil non conforme. Relancez-la, ou passez par les fichiers déjà analysés.')); } else { $jeton = bin2hex(random_bytes(16)); $dossier = dossier_stockage('tmp/' . $jeton); file_put_contents($dossier . '/profil.json', json_encode($analyse['profil'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $cv['chemin_tmp'] = $dossier . '/cv.' . $cv['ext']; move_uploaded_file($cv['tmp'], $cv['chemin_tmp']); $rapport = null; if ($analyse['rapport'] !== '') { $base = preg_replace('/[^a-z0-9]+/', '-', Recherche::normaliser($analyse['profil']['identite']['nom'] . ' ' . $analyse['profil']['identite']['prenom'])); $rapport = ['nom' => 'rapport-' . trim((string) $base, '-') . '.md', 'ext' => 'md', 'mime' => 'text/markdown', 'taille' => strlen($analyse['rapport']), 'chemin_tmp' => $dossier . '/rapport.md']; file_put_contents($rapport['chemin_tmp'], $analyse['rapport']); } $photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']); if ($photo !== null) { file_put_contents($dossier . '/photo.jpg', $photo); } $_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'auto' => $analyse['modele'], 'photo' => $photo !== null]; rediriger('import_relecture'); } } catch (RuntimeException $ex) { $erreurs = [$ex->getMessage()]; } } elseif ($post) { vider_import_en_attente(); try { $json = controler_fichier($_FILES['profil'] ?? [], ['json']); $cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']); $rapport = fichier_fourni($_FILES['rapport'] ?? null) ? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx']) : null; $profil = json_decode((string) file_get_contents($json['tmp']), true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide : %s.', json_last_error_msg())); } $erreurs = Import::valider($profil); if (!$erreurs) { $jeton = bin2hex(random_bytes(16)); $dossier = dossier_stockage('tmp/' . $jeton); move_uploaded_file($json['tmp'], $dossier . '/profil.json'); $ranger = static function (?array $f, string $cle) use ($dossier): ?array { if ($f !== null) { $f['chemin_tmp'] = $dossier . '/' . $cle . '.' . $f['ext']; move_uploaded_file($f['tmp'], $f['chemin_tmp']); } return $f; }; $cv = $ranger($cv, 'cv'); $rapport = $ranger($rapport, 'rapport'); $photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']); if ($photo !== null) { file_put_contents($dossier . '/photo.jpg', $photo); } $_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'photo' => $photo !== null]; rediriger('import_relecture'); } } catch (RuntimeException $ex) { $erreurs = [$ex->getMessage()]; } } vue('import', ['titre' => t('Importer un profil'), 'erreurs' => $erreurs, 'auto' => Claude::configure(), 'lot' => Lot::nombre()]); break; case 'import_relecture': exiger_ecriture(); $imp = $_SESSION['import'] ?? null; $fichier = $imp ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/profil.json' : null; if (!$imp || !is_file($fichier)) { rediriger('import'); } $profil = json_decode((string) file_get_contents($fichier), true); $doublons = Import::doublons(db(), $profil); // Même adresse email : c'est la même personne, l'enregistrement est refusé. Téléphone ou nom : à confirmer. $bloquant = (bool) array_filter($doublons, static fn ($d) => in_array('email', $d['motifs'], true)); $enLot = !empty($imp['lot']); if ($post && ($_POST['action'] ?? '') === 'annuler') { vider_import_en_attente(); if ($enLot) { rediriger('import_lot'); } flash(t('Import annulé.')); rediriger('import'); } if ($post && !$bloquant) { if ($doublons && empty($_POST['confirmer_doublon'])) { flash(t('Un profil ressemblant existe déjà. Cochez la case pour confirmer qu\'il s\'agit d\'une autre personne.'), 'erreur'); rediriger('import_relecture'); } $id = enregistrer_import($profil, $imp, !empty($_POST['garder_photo'])); if ($enLot) { Lot::retirer((int) $imp['lot']); } vider_import_en_attente(); flash(t('Profil enregistré dans la colonne Reçu.'), 'succes'); if ($enLot && Lot::nombre() > 0) { rediriger('import_lot'); } rediriger('profil', ['id' => $id]); } vue('import_relecture', [ 'titre' => t('Relecture avant enregistrement'), 'profil' => $profil, 'seniorite' => Import::seniorite($profil), 'doublons' => $doublons, 'bloquant' => $bloquant, 'imp' => $imp, ]); break; // ----------------------------------------------------------------------- // Import en lot // ----------------------------------------------------------------------- case 'import_lot': exiger_ecriture(); $action = (string) ($_POST['action'] ?? ''); if ($post && $action === 'deposer') { // CV à analyser automatiquement $fichiers = fichiers_multiples($_FILES['cv'] ?? null); $places = LOT_MAX - Lot::nombre(); $deposes = 0; $refus = []; foreach ($fichiers as $f) { if ($deposes >= $places) { $refus[] = t('%s : le lot est plein (%d CV au plus).', basename((string) $f['name']), LOT_MAX); continue; } try { Lot::deposer(controler_fichier($f, ['pdf', 'docx', 'odt', 'md', 'txt'])); $deposes++; } catch (RuntimeException $ex) { $refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage(); } } if ($deposes) { flash(tn($deposes, '%d CV déposé.', '%d CV déposés.'), 'succes'); } elseif (!$refus) { flash(t('Aucun fichier reçu.'), 'erreur'); } foreach ($refus as $m) { flash($m, 'erreur'); } rediriger('import_lot'); } if ($post && $action === 'deposer_analyses') { // Profils déjà analysés : JSON, CV et rapports déposés ensemble, associés par leurs noms $controles = []; $refus = []; foreach (fichiers_multiples($_FILES['fichiers'] ?? null) as $f) { try { $controles[] = controler_fichier($f, ['json', 'pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']); } catch (RuntimeException $ex) { $refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage(); } } $r = Lot::apparier($controles); $places = LOT_MAX - Lot::nombre(); $deposes = 0; foreach ($r['paires'] as $paire) { if ($deposes >= $places) { $refus[] = t('%s : le lot est plein (%d CV au plus).', $paire['cv']['nom'], LOT_MAX); continue; } Lot::deposerAnalyse($paire['json'], $paire['cv'], $paire['rapport']); $deposes++; } if ($deposes) { flash(tn($deposes, '%d profil déposé.', '%d profils déposés.'), 'succes'); } elseif (!$refus && !$r['erreurs']) { flash(t('Aucun fichier reçu.'), 'erreur'); } foreach ([...$refus, ...$r['erreurs']] as $m) { flash($m, 'erreur'); } rediriger('import_lot'); } if ($post && $action === 'analyser') { $l = Lot::analyser((int) ($_POST['id'] ?? 0)); flash($l['etat'] === 'pret' ? t('%s : analyse terminée.', $l['nom_fichier']) : $l['nom_fichier'] . ' : ' . $l['erreur'], $l['etat'] === 'pret' ? 'succes' : 'erreur'); rediriger('import_lot'); } if ($post && $action === 'relire') { $l = Lot::ligne((int) ($_POST['id'] ?? 0)); if ($l && $l['etat'] === 'pret') { vider_import_en_attente(); $_SESSION['import'] = Lot::enAttente($l); rediriger('import_relecture'); } rediriger('import_lot'); } if ($post && $action === 'retirer') { // La relecture en cours porte peut-être sur ce CV if ((int) ($_SESSION['import']['lot'] ?? 0) === (int) ($_POST['id'] ?? 0)) { unset($_SESSION['import']); } Lot::retirer((int) ($_POST['id'] ?? 0)); flash(t('CV retiré du lot.'), 'succes'); rediriger('import_lot'); } if ($post && $action === 'tout') { unset($_SESSION['import']); $r = Lot::toutEnregistrer(); if ($r['enregistres']) { flash(tn($r['enregistres'], '%d profil enregistré dans la colonne Reçu.', '%d profils enregistrés dans la colonne Reçu.'), 'succes'); } if ($r['a_relire']) { flash(tn($r['a_relire'], '%d profil ressemble à un profil existant : relisez-le avant de l\'enregistrer.', '%d profils ressemblent à des profils existants : relisez-les avant de les enregistrer.'), 'info'); } if (!$r['enregistres'] && !$r['a_relire']) { flash(t('Aucun profil prêt à enregistrer.'), 'info'); } rediriger(Lot::nombre() > 0 ? 'import_lot' : 'kanban'); } vue('import_lot', ['titre' => t('Import en lot'), 'lignes' => Lot::liste(), 'auto' => Claude::configure()]); break; case 'import_lot_analyse': // Appelé par le JavaScript de la page du lot : un CV par requête exiger_ecriture(); if (!$post) { reponse_json(['ok' => false, 'erreur' => t('Requête invalide.')], 405); } // L'analyse dure une à deux minutes : la session est rendue tout de suite, pour ne pas bloquer les autres pages session_write_close(); try { $l = Lot::analyser((int) ($_POST['id'] ?? 0)); } catch (RuntimeException $ex) { reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 404); } reponse_json(['ok' => $l['etat'] === 'pret', 'etat' => $l['etat'], 'candidat' => $l['candidat'], 'erreur' => $l['erreur']]); // ----------------------------------------------------------------------- // Fiche profil, commentaires, documents // ----------------------------------------------------------------------- case 'profil': exiger_connexion(); $c = candidat((int) ($_GET['id'] ?? 0)); if (!$c) { http_response_code(404); vue('introuvable', ['titre' => t('Profil introuvable')]); break; } // Le contenu s'affiche dans la langue de l'interface quand une traduction à jour existe ; « original=1 » montre le texte d'origine $original = ($_GET['original'] ?? '') === '1'; $affiche = $original ? $c + ['traduction' => traduction_profil($c, langue_courante())] : profil_affiche($c, langue_courante()); vue('profil', [ 'titre' => $c['prenom'] . ' ' . $c['nom'], 'c' => $affiche, 'original' => $original, 'enFile' => profil_en_file((int) $c['id'], langue_courante()), 'besoinsOuverts' => besoins_ouverts(), ]); break; // ----------------------------------------------------------------------- // Suivi : archivage, entretiens, relances, consentement, export des données // ----------------------------------------------------------------------- case 'archiver': exiger_ecriture(); $c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null; if (!$c) { rediriger('kanban'); } $archiver = ($_POST['action'] ?? '') !== 'desarchiver'; archiver_candidat($c, $archiver); flash($archiver ? t('Profil archivé. Il reste dans Profils, filtre « Archivés ».') : t('Profil désarchivé. Il est de retour dans le Kanban.'), 'succes'); rediriger('profil', ['id' => $c['id']]); case 'entretien': exiger_ecriture(); $action = (string) ($_POST['action'] ?? ''); $ancien = in_array($action, ['modifier', 'supprimer'], true) ? entretien((int) ($_POST['entretien_id'] ?? 0)) : null; $c = $post ? candidat((int) ($ancien['candidat_id'] ?? $_POST['id'] ?? 0)) : null; if (!$c) { rediriger('kanban'); } try { if ($action === 'supprimer' && $ancien) { supprimer_entretien($ancien); flash(t('Entretien supprimé.'), 'succes'); } elseif ($action === 'modifier' && $ancien) { modifier_entretien($ancien, $_POST); flash(t('Entretien mis à jour.'), 'succes'); } elseif ($action === 'ajouter') { db()->beginTransaction(); ajouter_entretien($c, $_POST); // Premier entretien : le profil avance dans le Kanban si la case est cochée if (!empty($_POST['avancer']) && in_array($c['statut'], ['recu', 'en_cours'], true) && $c['archive_le'] === null) { changer_statut((int) $c['id'], 'entretiens', null); } db()->commit(); flash(t('Entretien enregistré.'), 'succes'); } } catch (InvalidArgumentException $ex) { if (db()->inTransaction()) { db()->rollBack(); } flash($ex->getMessage(), 'erreur'); } header('Location: ' . url('profil', ['id' => $c['id']]) . '#entretiens'); exit; case 'relance': exiger_ecriture(); $action = (string) ($_POST['action'] ?? ''); $r = in_array($action, ['faite', 'supprimer'], true) ? relance((int) ($_POST['relance_id'] ?? 0)) : null; $c = $post ? candidat((int) ($r['candidat_id'] ?? $_POST['id'] ?? 0)) : null; if (!$c) { rediriger('suivi'); } try { if ($action === 'faite' && $r) { terminer_relance($r); flash(t('Relance faite.'), 'succes'); } elseif ($action === 'supprimer' && $r) { supprimer_relance($r); flash(t('Relance supprimée.'), 'succes'); } elseif ($action === 'ajouter') { ajouter_relance($c, $_POST); flash(t('Relance enregistrée.'), 'succes'); } } catch (InvalidArgumentException $ex) { flash($ex->getMessage(), 'erreur'); } if (($_POST['retour'] ?? '') === 'suivi') { rediriger('suivi'); } header('Location: ' . url('profil', ['id' => $c['id']]) . '#relances'); exit; case 'consentement': exiger_ecriture(); $c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null; if (!$c) { rediriger('kanban'); } try { $retirer = ($_POST['action'] ?? '') === 'retirer'; enregistrer_consentement($c, $retirer ? '' : (string) ($_POST['date'] ?? ''), (string) ($_POST['note'] ?? '')); flash($retirer ? t('Accord retiré.') : t('Accord enregistré.'), 'succes'); } catch (InvalidArgumentException $ex) { flash($ex->getMessage(), 'erreur'); } header('Location: ' . url('profil', ['id' => $c['id']]) . '#donnees'); exit; case 'profil_export': exiger_ecriture(); $c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null; if (!$c) { rediriger('kanban'); } envoyer_fichier_temporaire(exporter_donnees_candidat($c), 'donnees-' . strtolower(reference_profil($c)) . '-' . date('Y-m-d') . '.zip', 'application/zip'); case 'suivi': exiger_connexion(); vue('suivi', [ 'titre' => t('Suivi'), 'relances' => relances_ouvertes(), 'entretiens' => entretiens_a_venir(), 'sansAvis' => entretiens_sans_avis(), ]); break; // ----------------------------------------------------------------------- // Besoins clients // ----------------------------------------------------------------------- case 'besoins': exiger_connexion(); $erreur = null; if ($post) { exiger_ecriture(); try { $id = enregistrer_besoin(null, $_POST); flash(t('Besoin enregistré.'), 'succes'); rediriger('besoin', ['id' => $id]); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } $statut = (string) ($_GET['statut'] ?? ''); vue('besoins', ['titre' => t('Besoins clients'), 'liste' => besoins($statut), 'statut' => $statut, 'erreur' => $erreur, 'saisie' => $post ? $_POST : []]); break; case 'besoin': exiger_connexion(); $b = besoin((int) ($_GET['id'] ?? $_POST['besoin_id'] ?? 0)); if (!$b) { http_response_code(404); vue('introuvable', ['titre' => t('Besoin introuvable')]); break; } $erreur = null; if ($post) { exiger_ecriture(); $action = (string) ($_POST['action'] ?? ''); $candidatId = (int) ($_POST['candidat_id'] ?? 0); try { switch ($action) { case 'modifier': enregistrer_besoin($b, $_POST); flash(t('Besoin mis à jour.'), 'succes'); break; case 'statut': changer_statut_besoin($b, (string) ($_POST['statut'] ?? '')); flash(t('Statut mis à jour.'), 'succes'); break; case 'supprimer': supprimer_besoin($b); flash(t('Besoin supprimé.'), 'succes'); rediriger('besoins'); case 'proposer': if (candidat($candidatId)) { proposer_candidat($b, $candidatId); flash(t('Profil ajouté à ce besoin.'), 'succes'); } break; case 'etat': changer_proposition($b, $candidatId, (string) ($_POST['etat'] ?? ''), (string) ($_POST['note'] ?? '')); flash(t('Proposition mise à jour.'), 'succes'); break; case 'retirer': retirer_proposition($b, $candidatId); flash(t('Profil retiré de ce besoin.'), 'succes'); break; } if (($_POST['retour'] ?? '') === 'profil' && $candidatId) { header('Location: ' . url('profil', ['id' => $candidatId]) . '#besoins'); exit; } rediriger('besoin', ['id' => $b['id']]); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('besoin', [ 'titre' => $b['client'] . ' : ' . $b['intitule'], 'b' => $b, 'suggestions' => $b['statut'] === 'ouvert' ? rapprochement($b) : [], 'erreur' => $erreur, 'saisie' => $erreur !== null ? $_POST : $b, ]); break; // ----------------------------------------------------------------------- // Sauvegarde (gestionnaire des comptes) // ----------------------------------------------------------------------- case 'sauvegarde': $moi = exiger_admin(); $action = (string) ($_POST['action'] ?? ''); if (!$post && isset($_GET['telecharger'])) { $s = Sauvegarde::ligne((int) $_GET['telecharger']); $chemin = $s ? Sauvegarde::chemin($s) : null; if ($chemin === null) { http_response_code(404); vue('introuvable', ['titre' => t('Sauvegarde introuvable')]); break; } journaliser(null, 'sauvegarde', 'telechargement', null, $s['fichier']); header('Content-Type: application/zip'); header('Content-Length: ' . filesize($chemin)); header('Content-Disposition: attachment; filename="' . $s['fichier'] . '"'); header('Cache-Control: private, no-store'); readfile($chemin); exit; } if ($post && $action === 'lancer') { $r = Sauvegarde::executer('manuelle', !empty($_POST['envoyer']), (int) $moi['id']); if (!$r['reussie']) { flash(t('La sauvegarde a échoué : %s', $r['detail']), 'erreur'); } else { journaliser(null, 'sauvegarde', 'manuelle', null, $r['fichier']); flash(t('Sauvegarde faite : %s (%s).', $r['fichier'], Sauvegarde::taille((int) $r['taille'])), 'succes'); if ($r['envoi'] === 'envoye') { flash(t('Email envoyé avec la sauvegarde en pièce jointe.'), 'succes'); } elseif ($r['envoi'] === 'sans_piece') { flash(t('Email envoyé sans pièce jointe : le fichier dépasse la taille admise.'), 'info'); } elseif ($r['envoi'] === 'echec') { flash(t('L\'email n\'est pas parti. %s', $r['detail']), 'erreur'); } } rediriger('sauvegarde'); } if ($post && $action === 'essai') { if (Sauvegarde::tester()) { flash(t('Email d\'essai remis au serveur. Vérifiez les boîtes de réception, courriers indésirables compris.'), 'succes'); } else { flash(t('L\'email d\'essai n\'est pas parti. Vérifiez les adresses dans config/config.php.'), 'erreur'); } rediriger('sauvegarde'); } if ($post && $action === 'supprimer') { $s = Sauvegarde::ligne((int) ($_POST['id'] ?? 0)); if ($s) { Sauvegarde::supprimerFichier(db(), $s); journaliser(null, 'sauvegarde', 'suppression', $s['fichier'], null); flash(t('Copie supprimée du serveur.'), 'succes'); } rediriger('sauvegarde'); } if ($post && $action === 'fichiers') { try { confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? '')); } catch (InvalidArgumentException $ex) { flash($ex->getMessage(), 'erreur'); rediriger('sauvegarde'); } journaliser(null, 'sauvegarde', 'fichiers', null, 'Archive des fichiers'); envoyer_fichier_temporaire(Sauvegarde::archiverFichiers(), 'vivier-fichiers-' . date('Y-m-d-Hi') . '.zip', 'application/zip'); } vue('sauvegarde', [ 'titre' => t('Sauvegarde'), 'liste' => Sauvegarde::liste(), 'etat' => Sauvegarde::etat(), 'reglages' => Sauvegarde::reglages(), 'fichiers' => db()->query('SELECT COUNT(*) AS n, COALESCE(SUM(taille_octets), 0) AS octets FROM documents')->fetch(), ]); break; case 'cv': case 'cv_word': case 'cv_paquet': case 'cv_traduire': case 'cv_traduction': case 'europass': case 'europass_word': exiger_connexion(); $c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0)); if (!$c) { http_response_code(404); vue('introuvable', ['titre' => t('Profil introuvable')]); break; } $anonyme = ($_GET['anonyme'] ?? $_POST['anonyme'] ?? '') === '1'; $source = langue_du_profil($c); // Sans choix : la langue de l'interface quand elle n'est pas le français, sinon celle du CV d'origine $langue = (string) ($_GET['langue'] ?? $_POST['langue'] ?? ''); $parDefaut = langue_courante() !== 'fr' && isset(LANGUES_CV[langue_courante()]) ? langue_courante() : $source; $langue = isset(LANGUES_CV[$langue]) ? $langue : (isset(LANGUES_CV[$parDefaut]) ? $parDefaut : 'fr'); $retour = ['id' => $c['id'], 'langue' => $langue] + ($anonyme ? ['anonyme' => 1] : []); $pageRetour = ($_POST['format'] ?? '') === 'europass' ? 'europass' : 'cv'; // Traduction automatique du contenu if ($page === 'cv_traduire') { exiger_ecriture(); try { if (!$post || !Claude::configure() || !traduction_necessaire($c, $langue)) { throw new RuntimeException(t('La traduction automatique n\'est pas disponible.')); } traduire_profil($c, $langue); flash(t('Contenu traduit. Relisez-le avant d\'envoyer le CV.'), 'succes'); } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger($pageRetour, $retour); } // Traduction par fichier : on télécharge le paquet, on le fait traduire, on le recharge if ($page === 'cv_paquet') { $nom = 'traduction-' . strtolower(reference_profil($c)) . '-' . $langue . '.json'; header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $nom . '"'); header('Cache-Control: private, no-store'); echo json_encode([ 'type' => 'traduction-vivier', 'profil' => reference_profil($c), 'langue_source' => $source, 'langue_cible' => $langue, 'contenu' => paquet_traduction($c), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); exit; } if ($page === 'cv_traduction') { exiger_ecriture(); try { $f = controler_fichier($_FILES['traduction'] ?? [], ['json']); $recu = json_decode((string) file_get_contents($f['tmp']), true); if (!is_array($recu) || ($recu['type'] ?? '') !== 'traduction-vivier' || !isset($recu['contenu'])) { throw new RuntimeException(t('Ce fichier n\'est pas une traduction produite pour Vivier.')); } if (($recu['profil'] ?? '') !== reference_profil($c)) { throw new RuntimeException(t('Cette traduction concerne un autre profil (%s).', (string) ($recu['profil'] ?? '?'))); } $cible = (string) ($recu['langue_cible'] ?? $langue); if (!isset(LANGUES_CV[$cible]) || !traduction_necessaire($c, $cible)) { throw new RuntimeException(t('La langue de cette traduction n\'est pas gérée.')); } enregistrer_traduction($c, $cible, $recu['contenu'], 'fichier'); $retour['langue'] = $cible; flash(t('Traduction enregistrée.'), 'succes'); } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger('cv', $retour); } $traduction = traduction_profil($c, $langue); // CV Europass : coordonnées affichées sauf demande contraire, photo seulement si on la demande if ($page === 'europass' || $page === 'europass_word') { $options = [ 'anonyme' => $anonyme, 'photo' => !$anonyme && ($_GET['photo'] ?? '') === '1' && !empty($c['photo']), 'coordonnees' => !$anonyme && ($_GET['coordonnees'] ?? '1') !== '0', ]; $d = donnees_europass($c, $options, $langue, $traduction['contenu']); $version = ($anonyme ? 'CV Europass anonymisé' : 'CV Europass') . ' ' . strtoupper($langue); if ($page === 'europass_word') { $photo = $d['photo'] !== null ? dossier_stockage() . '/' . $d['photo'] : null; $docx = ExportEuropass::generer($d, __DIR__ . '/assets/europass/logo-europass-blanc.png', $photo); journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (Word)'); $nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx'; header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); header('Content-Length: ' . strlen($docx)); header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom)); header('Cache-Control: private, no-store'); echo $docx; exit; } journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (PDF)'); require VIVIER_RACINE . '/vues/europass.php'; break; } $d = donnees_cv($c, $anonyme, (array) config('societe'), $langue, $traduction['contenu']); $version = ($anonyme ? 'CV anonymisé' : 'CV IT Club') . ' ' . strtoupper($langue); if ($page === 'cv_word') { $docx = ExportWord::generer($d, __DIR__ . '/assets/logo-itclub-or.png'); journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (Word)'); $nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx'; header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); header('Content-Length: ' . strlen($docx)); header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom)); header('Cache-Control: private, no-store'); echo $docx; exit; } journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (PDF)'); require VIVIER_RACINE . '/vues/cv.php'; break; case 'commentaire': exiger_ecriture(); $id = (int) ($_POST['id'] ?? 0); $texte = trim((string) ($_POST['texte'] ?? '')); if ($post && $texte !== '' && candidat($id)) { db()->prepare('INSERT INTO commentaires (candidat_id, auteur_id, texte) VALUES (?, ?, ?)') ->execute([$id, utilisateur()['id'], $texte]); journaliser($id, 'commentaire'); } elseif ($post) { flash(t('Le commentaire est vide.'), 'erreur'); } rediriger_fiche($id, 'commentaires'); case 'document_ajout': exiger_ecriture(); $id = (int) ($_POST['id'] ?? 0); $type = (string) ($_POST['type'] ?? 'autre'); try { if (!$post || !candidat($id) || !isset(TYPES_DOCUMENT[$type])) { throw new RuntimeException(t('Requête invalide.')); } attacher_document($id, $type, controler_fichier($_FILES['fichier'] ?? [])); flash(t('Document ajouté.'), 'succes'); } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger_fiche($id, 'documents'); case 'document': exiger_connexion(); $d = document((int) ($_GET['id'] ?? 0)); $chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null; if (!$d || !is_file($chemin)) { http_response_code(404); exit(t('Document introuvable.')); } $nom = preg_replace('/[^\w.\- ]+/u', '_', $d['nom_fichier']); header('Content-Type: ' . $d['type_mime']); header('Content-Length: ' . filesize($chemin)); header('Content-Disposition: attachment; filename="' . $nom . '"'); header('Cache-Control: private, no-store'); readfile($chemin); exit; // ----------------------------------------------------------------------- // Comptes (administrateur) // ----------------------------------------------------------------------- case 'utilisateurs': $moi = exiger_admin(); $cible = (int) ($_POST['id'] ?? 0); $action = $post ? (string) ($_POST['action'] ?? '') : ''; try { switch ($action) { case 'inviter': $jeton = creer_invitation((string) ($_POST['nom'] ?? '')); // Le lien n'est montré qu'une fois : seule son empreinte est enregistrée $_SESSION['invitation_lien'] = ['lien' => adresse_complete('invitation', ['jeton' => $jeton]), 'nom' => trim((string) ($_POST['nom'] ?? ''))]; rediriger_ancre('utilisateurs', 'invitations'); case 'annuler_invitation': flash(annuler_invitation((int) ($_POST['invitation'] ?? 0)) ? t('Invitation annulée : le lien ne fonctionne plus.') : t('Cette invitation n\'existe plus.'), 'succes'); rediriger_ancre('utilisateurs', 'invitations'); case 'creer': $id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'recruteur', true); journaliser(null, 'compte', 'creation', null, mb_strtolower(trim((string) $_POST['identifiant']))); flash(t('Compte créé. Transmettez l\'identifiant et le mot de passe à la personne : elle le changera à sa première connexion, puis activera la double authentification.'), 'succes'); break; case 'accepter': case 'refuser': $d = $action === 'accepter' ? accepter_demande($cible) : refuser_demande($cible); if ($d === null) { flash(t('Cette demande n\'existe plus.'), 'erreur'); } else { flash($action === 'accepter' ? t('Compte %s accepté. Prévenez la personne : elle peut se connecter.', $d['identifiant']) : t('Demande de %s refusée et effacée.', $d['identifiant']), 'succes'); } break; case 'debloquer': $cle = (string) ($_POST['cle'] ?? ''); if (debloquer($cle)) { journaliser(null, 'compte', 'deblocage', null, preg_replace('/^(compte|ip):/', '', $cle)); flash(t('Blocage levé. La connexion est de nouveau possible.'), 'succes'); } rediriger_ancre('utilisateurs', 'blocages'); case 'reindexer': flash(tn(Recherche::toutIndexer(db()), '%d profil réindexé.', '%d profils réindexés.'), 'succes'); rediriger_ancre('utilisateurs', 'maintenance'); case 'traduire_tout': relancer_traductions_en_echec(); planifier_toutes_traductions(); $n = nombre_traductions_a_faire(); flash($n ? tn($n, '%d traduction à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.', '%d traductions à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.') : t('Tous les profils sont déjà traduits.'), 'succes'); rediriger_ancre('utilisateurs', 'maintenance'); } // Les actions suivantes portent sur un compte déjà accepté if (in_array($action, ['modifier', 'gestionnaire', 'basculer', 'double_auth'], true)) { $compte = compte_par_id($cible); if (!$compte || $compte['en_attente']) { throw new InvalidArgumentException(t('Compte introuvable.')); } $soi = $cible === (int) $moi['id']; if ($action === 'modifier') { modifier_utilisateur($cible, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? '')); if (($_POST['mot_de_passe'] ?? '') !== '') { confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? '')); // Mot de passe donné par le gestionnaire : la personne le change à sa prochaine connexion changer_mot_de_passe($cible, (string) $_POST['mot_de_passe'], !$soi); if ($soi) { garder_session_courante($cible); } journaliser(null, 'compte', 'mot_de_passe', null, $compte['identifiant']); } flash(t('Compte modifié.'), 'succes'); } elseif ($action === 'gestionnaire') { if ($soi) { throw new InvalidArgumentException(t('Vous ne pouvez pas retirer votre propre gestion des comptes.')); } confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? '')); db()->prepare("UPDATE utilisateurs SET role = IF(role = 'admin', 'recruteur', 'admin') WHERE id = ?")->execute([$cible]); journaliser(null, 'compte', $compte['role'] === 'admin' ? 'gestionnaire_retire' : 'gestionnaire', null, $compte['identifiant']); flash($compte['role'] === 'admin' ? t('Gestion des comptes retirée.') : t('Gestion des comptes confiée.'), 'succes'); } elseif ($action === 'double_auth') { confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? '')); reinitialiser_double_authentification($cible); if ($soi) { $_SESSION = []; rediriger('connexion'); } journaliser(null, 'compte', 'double_auth', null, $compte['identifiant']); flash(t('Double authentification remise à zéro. La personne en active une nouvelle à sa prochaine connexion. Ses sessions ouvertes sont fermées.'), 'succes'); } elseif ($action === 'basculer') { if ($soi) { throw new InvalidArgumentException(t('Vous ne pouvez pas désactiver votre propre compte.')); } db()->prepare('UPDATE utilisateurs SET actif = 1 - actif, session_version = session_version + 1 WHERE id = ?')->execute([$cible]); journaliser(null, 'compte', $compte['actif'] ? 'desactivation' : 'reactivation', null, $compte['identifiant']); flash($compte['actif'] ? t('Compte désactivé : il ne peut plus se connecter et ses sessions sont fermées.') : t('Compte réactivé.'), 'succes'); } } } catch (InvalidArgumentException $ex) { flash($ex->getMessage(), 'erreur'); } if ($post) { rediriger('utilisateurs'); } $lien = $_SESSION['invitation_lien'] ?? null; unset($_SESSION['invitation_lien']); vue('utilisateurs', [ 'titre' => t('Comptes'), 'liste' => utilisateurs(), 'demandes' => demandes_compte(), 'moi' => $moi, 'invitations' => invitations_ouvertes(), 'lien' => $lien, 'blocages' => blocages_en_cours(), 'aTraduire' => nombre_traductions_a_faire(), 'echecsTraduction' => traductions_en_echec(), ]); break; // ----------------------------------------------------------------------- // Journal des connexions et erreurs (gestionnaire) // ----------------------------------------------------------------------- case 'connexions': exiger_admin(); $qui = (int) ($_GET['utilisateur'] ?? 0); $resultat = (string) ($_GET['resultat'] ?? ''); vue('connexions', ['titre' => t('Connexions'), 'lignes' => connexions($qui, $resultat), 'comptes' => utilisateurs(), 'qui' => $qui, 'resultat' => $resultat]); break; case 'erreurs': exiger_admin(); if ($post && ($_POST['action'] ?? '') === 'vider') { vider_erreurs(); flash(t('Journal des erreurs vidé.'), 'succes'); rediriger('erreurs'); } $noms = db()->query('SELECT id, nom_affiche FROM utilisateurs')->fetchAll(PDO::FETCH_KEY_PAIR); vue('erreurs', ['titre' => t('Erreurs'), 'lignes' => erreurs_notees(), 'noms' => $noms]); break; // ----------------------------------------------------------------------- // Traduction des profils : la suivante de la file (appelée par le script, en arrière-plan), // ou tout de suite, depuis la fiche // ----------------------------------------------------------------------- case 'traduction_suivante': $moi = exiger_ecriture(); if (!$post || !Claude::configure()) { reponse_json(['ok' => false, 'reste' => 0]); } session_write_close(); $r = traduction_suivante((int) $moi['id']); reponse_json(['ok' => $r['erreur'] === null, 'fait' => $r['fait'], 'reste' => $r['reste'], 'erreur' => $r['erreur']]); case 'traduire_profil': $moi = exiger_ecriture(); $c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null; if (!$c) { rediriger('kanban'); } $langue = isset(LANGUES_INTERFACE[$_POST['langue'] ?? '']) ? (string) $_POST['langue'] : langue_courante(); try { traduire_profil($c, $langue); flash(t('Profil traduit. Relisez la traduction : elle est automatique.'), 'succes'); } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger('profil', ['id' => $c['id']]); // ----------------------------------------------------------------------- // Recherche // ----------------------------------------------------------------------- case 'recherche': exiger_connexion(); $q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200); $filtres = filtres_recherche(); $r = Recherche::chercher(db(), $q, $filtres); $r['resultats'] = traduire_titres($r['resultats']); vue('recherche', [ 'titre' => $q !== '' ? t('Recherche : %s', $q) : t('Tous les profils'), 'q' => $q, 'filtres' => $filtres, 'resultats' => $r['resultats'], 'analyse' => $r['analyse'], 'langues' => db()->query('SELECT DISTINCT langue FROM langues ORDER BY langue')->fetchAll(PDO::FETCH_COLUMN), 'cv' => cv_origine_par_candidat(array_column($r['resultats'], 'id')), ]); break; // ----------------------------------------------------------------------- // Modification, mise à jour et suppression d'un profil // ----------------------------------------------------------------------- case 'profil_modifier': exiger_ecriture(); $c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0)); if (!$c) { http_response_code(404); vue('introuvable', ['titre' => t('Profil introuvable')]); break; } $erreur = null; if ($post) { try { $n = modifier_candidat($c, $_POST); flash($n ? tn($n, '%d champ modifié.', '%d champs modifiés.') : t('Aucun changement.'), $n ? 'succes' : 'info'); rediriger('profil', ['id' => $c['id']]); } catch (InvalidArgumentException $ex) { $erreur = $ex->getMessage(); } } vue('profil_modifier', ['titre' => t('Modifier %s', $c['prenom'] . ' ' . $c['nom']), 'c' => $c, 'erreur' => $erreur, 'saisie' => $post ? $_POST : $c]); break; case 'profil_maj': $moi = exiger_ecriture(); $c = candidat((int) ($_POST['id'] ?? 0)); if (!$post || !$c) { rediriger('kanban'); } try { $json = controler_fichier($_FILES['profil'] ?? [], ['json']); $cv = fichier_fourni($_FILES['cv'] ?? null) ? controler_fichier($_FILES['cv'], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']) : null; $rapport = fichier_fourni($_FILES['rapport'] ?? null) ? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx']) : null; $profil = json_decode((string) file_get_contents($json['tmp']), true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide.')); } $erreurs = Import::valider($profil); if ($erreurs) { throw new RuntimeException(t('Mise à jour refusée : %s', implode(' ', array_slice($erreurs, 0, 5)) . (count($erreurs) > 5 ? ' …' : ''))); } $autre = Import::doublon(db(), $profil); if ($autre !== null && $autre !== (int) $c['id']) { throw new RuntimeException(t('L\'adresse email de ce fichier appartient à un autre profil.')); } $pdo = db(); $pdo->beginTransaction(); try { Import::remplacer($pdo, (int) $c['id'], $profil, (int) $moi['id']); journaliser((int) $c['id'], 'modification', 'Analyse', null, 'Mise à jour depuis ' . $profil['source']['fichier_origine']); if ($cv) { // Photo reprise du nouveau CV seulement si la fiche n'en a pas : un choix fait à la main n'est pas écrasé $photo = empty($c['photo']) ? Photo::extraire($cv['tmp'], $cv['ext']) : null; attacher_document((int) $c['id'], 'cv_origine', $cv); if ($photo !== null) { Photo::enregistrer($pdo, (int) $c['id'], $photo); } } if ($rapport) { attacher_document((int) $c['id'], 'rapport', $rapport); } $pdo->commit(); } catch (Throwable $ex) { $pdo->rollBack(); throw $ex; } flash(t('Profil mis à jour. Le statut, les commentaires et les documents sont conservés.'), 'succes'); } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger('profil', ['id' => $c['id']]); // Suppression : réservée au gestionnaire. Le profil part à la corbeille, d'où il est effacé après 30 jours. case 'profil_suppr': exiger_admin(); $c = candidat((int) ($_POST['id'] ?? 0)); if ($post && $c) { mettre_a_la_corbeille($c); flash(t('Profil %s mis à la corbeille. Il sera effacé pour de bon dans %d jours.', reference_profil($c), CORBEILLE_JOURS), 'succes'); } rediriger(($_POST['retour'] ?? '') === 'conservation' ? 'conservation' : 'kanban'); // ----------------------------------------------------------------------- // Corbeille (gestionnaire) : restaurer un profil ou l'effacer sans attendre // ----------------------------------------------------------------------- case 'corbeille': exiger_admin(); if ($post) { $c = candidat((int) ($_POST['id'] ?? 0), true); $action = (string) ($_POST['action'] ?? ''); if (!$c || $c['supprime_le'] === null) { flash(t('Ce profil n\'est plus dans la corbeille.'), 'erreur'); } elseif ($action === 'restaurer') { restaurer_candidat($c); flash(t('Profil %s restauré.', $c['prenom'] . ' ' . $c['nom']), 'succes'); } elseif ($action === 'effacer') { supprimer_candidat($c); flash(t('Profil %s effacé pour de bon, avec ses documents.', reference_profil($c)), 'succes'); } rediriger('corbeille'); } $effaces = purger_corbeille(); if ($effaces) { flash(t($effaces > 1 ? '%d profils effacés : leur délai de %d jours était passé.' : '%d profil effacé : son délai de %d jours était passé.', $effaces, CORBEILLE_JOURS), 'info'); } vue('corbeille', ['titre' => t('Corbeille'), 'liste' => traduire_titres(corbeille())]); break; case 'document_suppr': exiger_ecriture(); $d = document((int) ($_POST['id'] ?? 0)); if ($post && $d) { supprimer_document($d); flash(t('Document supprimé.'), 'succes'); rediriger_fiche((int) $d['candidat_id'], 'documents'); } rediriger('kanban'); case 'mot_cle': exiger_ecriture(); $id = (int) ($_POST['id'] ?? 0); if ($post && candidat($id)) { $pdo = db(); if (($_POST['action'] ?? '') === 'supprimer') { $pdo->prepare('DELETE FROM mots_cles WHERE id = ? AND candidat_id = ?')->execute([(int) ($_POST['mot_id'] ?? 0), $id]); } else { $type = (string) ($_POST['type'] ?? 'mot_cle'); $mot = mb_strtolower(mb_substr(trim((string) ($_POST['mot'] ?? '')), 0, 80)); if ($mot !== '' && isset(TYPES_MOT[$type])) { $pdo->prepare('INSERT IGNORE INTO mots_cles (candidat_id, type, mot, manuel) VALUES (?, ?, ?, 1)')->execute([$id, $type, $mot]); journaliser($id, 'modification', TYPES_MOT[$type], null, $mot); } } Recherche::indexer($pdo, $id); } rediriger_fiche($id, 'mots-cles'); case 'rapport': exiger_connexion(); $d = document((int) ($_GET['id'] ?? 0)); $chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null; if (!$d || !is_file($chemin) || !in_array(strtolower(pathinfo($d['nom_fichier'], PATHINFO_EXTENSION)), ['md', 'txt'], true)) { http_response_code(404); vue('introuvable', ['titre' => t('Rapport introuvable')]); break; } vue('rapport', [ 'titre' => $d['nom_fichier'], 'd' => $d, 'c' => candidat((int) $d['candidat_id']), 'html' => markdown_vers_html((string) file_get_contents($chemin)), ]); break; // ----------------------------------------------------------------------- // Mon compte // ----------------------------------------------------------------------- case 'compte': $moi = exiger_connexion(); if ($post) { $action = (string) ($_POST['action'] ?? 'mot_de_passe'); try { if ($action === 'mot_de_passe') { confirmer_mot_de_passe((string) ($_POST['actuel'] ?? '')); if (($_POST['nouveau'] ?? '') !== ($_POST['confirmation'] ?? '')) { throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.')); } changer_mot_de_passe((int) $moi['id'], (string) ($_POST['nouveau'] ?? '')); garder_session_courante((int) $moi['id']); journaliser(null, 'securite', 'mot_de_passe', null, $moi['identifiant']); flash(t('Mot de passe modifié. Vos autres sessions sont fermées.'), 'succes'); } elseif ($action === 'codes') { confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? '')); $_SESSION['codes_a_montrer'] = generer_codes_secours((int) $moi['id']); journaliser(null, 'securite', 'codes_secours', null, $moi['identifiant']); rediriger('codes_secours'); } elseif ($action === 'sessions') { fermer_sessions((int) $moi['id'], true); journaliser(null, 'securite', 'sessions', null, $moi['identifiant']); flash(t('Vos autres sessions sont fermées. Celle-ci reste ouverte.'), 'succes'); } } catch (InvalidArgumentException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger('compte'); } vue('compte', [ 'titre' => t('Mon compte'), 'moi' => $moi, 'compte' => compte_par_id((int) $moi['id']), 'derniere' => connexions((int) $moi['id'], 'reussie', 5), ]); break; // ----------------------------------------------------------------------- // Conservation des données et journal (administrateur) // ----------------------------------------------------------------------- case 'conservation': exiger_admin(); if ($post && ($_POST['action'] ?? '') === 'prolonger') { $c = candidat((int) ($_POST['id'] ?? 0)); if ($c) { prolonger_conservation($c, (int) config('conservation_mois')); flash(t('Conservation prolongée de %d mois.', (int) config('conservation_mois')), 'succes'); } rediriger('conservation'); } vue('conservation', ['titre' => t('Conservation'), 'liste' => traduire_titres(candidats_a_echeance())]); break; case 'journal': exiger_admin(); $qui = (int) ($_GET['utilisateur'] ?? 0); vue('journal', ['titre' => t('Journal'), 'lignes' => journal($qui ?: null), 'comptes' => utilisateurs(), 'qui' => $qui]); break; // ----------------------------------------------------------------------- // Export CSV de la liste et tableau de bord // ----------------------------------------------------------------------- case 'export_csv': exiger_admin(); $r = Recherche::chercher(db(), mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200), filtres_recherche()); $lignes = lignes_export(array_map('intval', array_column($r['resultats'], 'id'))); journaliser(null, 'export', 'csv', null, 'Liste CSV (' . count($lignes) . ' profils)'); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="vivier-profils-' . date('Y-m-d') . '.csv"'); header('Cache-Control: private, no-store'); $sortie = fopen('php://output', 'w'); fwrite($sortie, "\xEF\xBB\xBF"); // marque UTF-8 : Excel affiche les accents // Une cellule qui commence par = + - @ serait lue comme une formule par un tableur $neutre = static fn (string $v): string => preg_match('/^[=+\-@\t\r]/', $v) ? "'" . $v : $v; if ($lignes) { fputcsv($sortie, array_map('t', array_keys($lignes[0])), ';', '"', ''); } foreach ($lignes as $l) { fputcsv($sortie, array_map($neutre, array_values($l)), ';', '"', ''); } exit; case 'tableau': exiger_connexion(); vue('tableau', ['titre' => t('Tableau de bord'), 's' => statistiques(), 'sauvegarde' => utilisateur()['role'] === 'admin' ? Sauvegarde::etat() : null]); break; // ----------------------------------------------------------------------- // Langue de l'interface // ----------------------------------------------------------------------- case 'langue': $langue = (string) ($_POST['langue'] ?? ''); if ($post && isset(LANGUES_INTERFACE[$langue])) { setcookie('vivier_langue', $langue, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => (bool) config('https'), 'httponly' => true, 'samesite' => 'Lax']); if (utilisateur()) { db()->prepare('UPDATE utilisateurs SET langue = ? WHERE id = ?')->execute([$langue, utilisateur()['id']]); } } // Retour à la page d'où vient la demande, si elle est bien de ce site $retour = (string) ($_POST['retour'] ?? ''); header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban'))); exit; // ----------------------------------------------------------------------- // Photo du candidat // ----------------------------------------------------------------------- case 'photo': exiger_connexion(); $req = db()->prepare('SELECT photo FROM candidats WHERE id = ? AND (supprime_le IS NULL OR ?)'); $req->execute([(int) ($_GET['id'] ?? 0), (int) est_admin()]); $relatif = $req->fetchColumn(); $chemin = $relatif ? dossier_stockage() . '/' . $relatif : null; if (!$chemin || !is_file($chemin)) { http_response_code(404); exit; } header('Content-Type: image/jpeg'); header('Content-Length: ' . filesize($chemin)); header('Cache-Control: private, max-age=86400'); // l'adresse change avec la photo readfile($chemin); exit; case 'import_photo': exiger_ecriture(); $imp = $_SESSION['import'] ?? null; $chemin = $imp && preg_match('/^[a-f0-9]{32}$/', $imp['dossier']) ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/photo.jpg' : null; if (!$chemin || !is_file($chemin)) { http_response_code(404); exit; } header('Content-Type: image/jpeg'); header('Cache-Control: private, no-store'); readfile($chemin); exit; case 'photo_maj': exiger_ecriture(); $c = candidat((int) ($_POST['id'] ?? 0)); if (!$post || !$c) { rediriger('kanban'); } try { if (($_POST['action'] ?? '') === 'retirer') { Photo::supprimer(db(), (int) $c['id']); journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo retirée'); flash(t('Photo retirée.'), 'succes'); } else { $f = controler_fichier($_FILES['photo'] ?? [], ['jpg', 'jpeg', 'png', 'webp', 'gif']); $jpeg = Photo::normaliser((string) file_get_contents($f['tmp'])); if ($jpeg === null) { throw new RuntimeException(t('Cette image est illisible. Chargez un fichier JPEG ou PNG.')); } Photo::enregistrer(db(), (int) $c['id'], $jpeg); journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo ajoutée'); flash(t('Photo enregistrée.'), 'succes'); } } catch (RuntimeException $ex) { flash($ex->getMessage(), 'erreur'); } rediriger_fiche((int) $c['id'], 'photo'); // ----------------------------------------------------------------------- // Aide : les guides du dossier aide/ // ----------------------------------------------------------------------- case 'aide': $moi = exiger_connexion(); $guide = (string) ($_GET['guide'] ?? 'guide'); if ($guide === 'administration' && $moi['role'] !== 'admin') { http_response_code(403); exit(t('Page réservée au gestionnaire des comptes.')); } $langue = (string) ($_GET['langue'] ?? ''); $aide = aide($guide, isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante()); if ($aide === null) { http_response_code(404); vue('introuvable', ['titre' => t('Page introuvable')]); break; } vue('aide', ['titre' => t(DOCUMENTS_AIDE[$guide]), 'guide' => $guide, 'aide' => $aide]); break; default: exiger_connexion(); http_response_code(404); vue('introuvable', ['titre' => t('Page introuvable')]); }