Skip to content
Merged

Dev #227

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Ryvie-Back/routes/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ router.get('/ldap/check-first-time', async (req: any, res: any) => {
ldapClient.bind(ldapConfig.bindDN, ldapConfig.bindPassword, (err) => {
if (err) {
ldapClient.destroy();
return res.status(500).json({ error: 'Échec de connexion LDAP', isFirstTime: false });
// LDAP injoignable ≠ « déjà configuré ». isFirstTime:null = état INCONNU :
// le front doit réessayer, surtout pas sauter l'assistant de première
// configuration (sinon une machine neuve avec LDAP en panne reste
// bloquée sur le login sans aucun compte existant).
return res.status(503).json({ error: 'Échec de connexion LDAP', code: 'LDAP_UNAVAILABLE', isFirstTime: null });
}

const filter = ldapConfig.userFilter;
Expand All @@ -222,7 +226,7 @@ router.get('/ldap/check-first-time', async (req: any, res: any) => {
(err, ldapRes) => {
if (err) {
ldapClient.unbind();
return res.status(500).json({ error: 'Erreur de recherche LDAP', isFirstTime: false });
return res.status(503).json({ error: 'Erreur de recherche LDAP', code: 'LDAP_UNAVAILABLE', isFirstTime: null });
}

let userCount = 0;
Expand Down Expand Up @@ -251,7 +255,7 @@ router.get('/ldap/check-first-time', async (req: any, res: any) => {
ldapRes.on('error', (err) => {
ldapClient.unbind();
console.error('[check-first-time] Erreur LDAP:', err);
res.status(500).json({ error: 'Erreur LDAP', isFirstTime: false });
res.status(503).json({ error: 'Erreur LDAP', code: 'LDAP_UNAVAILABLE', isFirstTime: null });
});
}
);
Expand Down
1,488 changes: 952 additions & 536 deletions Ryvie-Back/routes/system/storage.ts

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions Ryvie-Back/services/system/architectureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,31 @@ function normalizeLdapCompose(content: string): { content: string; changed: bool
const adminPassword = extractComposeValue(content, 'LDAP_ADMIN_PASSWORD', 'admin');
const ldapRoot = extractComposeValue(content, 'LDAP_ROOT', 'dc=example,dc=org');

// CRITIQUE : préserver la directive user: posée par install.sh.
// Le bind-mount /data/config/ldap/data appartient à l'utilisateur applicatif
// (uid variable : 1000 ou 1001 selon la machine) et le conteneur bitnami DOIT
// tourner avec ce même UID (GID 0 obligatoire). Sans cette ligne, le conteneur
// retombe sur l'UID par défaut de l'image (1001) → slapd boucle sur
// « Permission denied » → check-first-time échoue → l'assistant de première
// configuration ne s'affiche jamais.
const userMatch = content.match(/^\s*user:\s*["']?(\d+:\d+)["']?\s*$/m);
let composeUser = userMatch ? userMatch[1] : '';
if (!composeUser) {
try {
const dataDirStat = fs.statSync(path.join(DATA_ROOT, 'config', 'ldap', 'data'));
composeUser = `${dataDirStat.uid}:0`;
} catch {
composeUser = '1001:0';
}
}

const canonicalContent = `version: '3.8'

services:
openldap:
image: julescloud/ryvieldap:latest
container_name: openldap
user: "${composeUser}"
environment:
- LDAP_ADMIN_USERNAME=admin
- LDAP_ADMIN_PASSWORD=${adminPassword}
Expand Down
2 changes: 1 addition & 1 deletion Ryvie-Front/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@
"dockerCurrentLocation": "Current location",
"dockerMoveSelectTarget": "Target partition",
"dockerMoveNoTarget": "No other mounted partition available",
"dockerMoveGrow": "Grow the target partition to fill the disk (a reboot may be required for the system partition)",
"dockerMoveGrow": "Grow the target partition to fill the disk (online, no reboot needed)",
"dockerMoveChecking": "Running pre-checks...",
"dockerMoveRequired": "Required space",
"dockerMoveAvailable": "Available",
Expand Down
2 changes: 1 addition & 1 deletion Ryvie-Front/src/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,7 @@
"dockerCurrentLocation": "Emplacement actuel",
"dockerMoveSelectTarget": "Partition cible",
"dockerMoveNoTarget": "Aucune autre partition montée disponible",
"dockerMoveGrow": "Agrandir la partition cible pour remplir le disque (un redémarrage peut être nécessaire pour la partition système)",
"dockerMoveGrow": "Agrandir la partition cible pour remplir le disque (à chaud, sans redémarrage)",
"dockerMoveChecking": "Pré-vérifications en cours...",
"dockerMoveRequired": "Espace requis",
"dockerMoveAvailable": "Disponible",
Expand Down
15 changes: 12 additions & 3 deletions Ryvie-Front/src/pages/AppStore.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from '../utils/setupAxios';
import axios, { handleTokenError } from '../utils/setupAxios';
import urlsConfig from '../config/urls';
const { getServerUrl } = urlsConfig;
import { getCurrentAccessMode } from '../utils/detectAccessMode';
Expand Down Expand Up @@ -584,6 +584,11 @@ const AppStore = () => {
if (!sessionInfo?.isActive || !sessionInfo?.token) {
addLog(`❌ Erreur: Utilisateur non connecté`, 'error');
showToast(t('appStore.notifications.mustBeConnected'), 'error');
// Rediriger vers la connexion au lieu de rester bloqué : le token a
// expiré (TTL 60 min) et ne peut plus être rafraîchi côté serveur
// (jwt.verify rejette un token expiré). handleTokenError gère le cas
// overlay/iframe (postMessage) comme le cas plein écran.
handleTokenError('EXPIRED_TOKEN');
return;
}

Expand All @@ -599,11 +604,13 @@ const AppStore = () => {
if (payload.exp && sessionManager.isTokenExpired(sessionInfo.token)) {
throw new Error('Token expiré');
}

addLog(`✅ Token valide, expiration: ${new Date(payload.exp * 1000).toLocaleString()}`, 'success');
} catch (tokenError) {
addLog(`❌ Erreur de validation du token: ${tokenError.message}`, 'error');
showToast(t('appStore.notifications.sessionExpired'), 'error');
// Token expiré/illisible → renvoyer vers la connexion (voir ci-dessus).
handleTokenError('EXPIRED_TOKEN');
return;
}

Expand Down Expand Up @@ -653,7 +660,9 @@ try {
timeout: 300000,
headers: {
'Content-Type': 'application/json',
// Ajoutez ici d'autres headers si nécessaires (auth, etc.)
// Header d'auth explicite : ne pas dépendre uniquement du header par
// défaut d'axios (pas toujours positionné dans le contexte overlay).
Authorization: `Bearer ${sessionInfo.token}`,
}
});

Expand Down
50 changes: 35 additions & 15 deletions Ryvie-Front/src/pages/FirstTimeSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,43 @@ const FirstTimeSetup = () => {
// Vérifier au chargement si la page est accessible
useEffect(() => {
const checkAccess = async () => {
try {
const accessMode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(accessMode);
const response = await axios.get(`${serverUrl}/api/ldap/check-first-time`);

if (response.data && !response.data.isFirstTime) {
// Des utilisateurs existent déjà, rediriger vers login
console.log('[FirstTimeSetup] Des utilisateurs existent déjà - redirection vers login');
navigate('/login', { replace: true });
} else {
// C'est bien la première fois, autoriser l'accès
setIsChecking(false);
const accessMode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(accessMode);

// LDAP peut être en cours de démarrage : on réessaie avant de trancher.
// Ne rediriger vers /login QUE sur un isFirstTime === false explicite
// (des comptes existent vraiment). Un état inconnu (503 LDAP_UNAVAILABLE)
// ne doit pas éjecter l'utilisateur de l'assistant de première config.
const RETRIES = 5;
const DELAY_MS = 3000;
let isFirstTime: boolean | null = null;
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try {
const response = await axios.get(`${serverUrl}/api/ldap/check-first-time`);
if (typeof response.data?.isFirstTime === 'boolean') {
isFirstTime = response.data.isFirstTime;
break;
}
} catch (error) {
console.warn(`[FirstTimeSetup] check-first-time tentative ${attempt}/${RETRIES} échouée`);
}
} catch (error) {
console.error('[FirstTimeSetup] Erreur lors de la vérification:', error);
// En cas d'erreur, rediriger vers login par sécurité
if (attempt < RETRIES) {
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
}

if (isFirstTime === false) {
// Des utilisateurs existent déjà, rediriger vers login
console.log('[FirstTimeSetup] Des utilisateurs existent déjà - redirection vers login');
navigate('/login', { replace: true });
} else {
// Première fois confirmée, ou état inconnu (LDAP KO) : laisser
// l'assistant accessible — la création du compte échouera proprement
// si LDAP est vraiment indisponible, sans boucle de redirection.
if (isFirstTime === null) {
console.error('[FirstTimeSetup] État première-connexion inconnu (LDAP KO) - accès assistant maintenu');
}
setIsChecking(false);
}
};

Expand Down
32 changes: 28 additions & 4 deletions Ryvie-Front/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,41 @@ const Login = () => {
return;
}

// 4) Vérifier si c'est la première connexion
// 4) Vérifier si c'est la première connexion.
// LDAP peut mettre du temps à démarrer (boot) ou être temporairement KO :
// le backend renvoie alors 503 + isFirstTime:null (état INCONNU). On
// RÉESSAIE au lieu de conclure « déjà configuré », sinon une machine
// neuve dont LDAP est indisponible n'affiche jamais l'assistant RAID.
try {
const mode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(mode);
const response = await axios.get(`${serverUrl}/api/ldap/check-first-time`);

if (response.data && response.data.isFirstTime) {

const FIRST_TIME_RETRIES = 5;
const FIRST_TIME_DELAY_MS = 3000;
let isFirstTime: boolean | null = null;
for (let attempt = 1; attempt <= FIRST_TIME_RETRIES; attempt++) {
try {
const response = await axios.get(`${serverUrl}/api/ldap/check-first-time`);
if (typeof response.data?.isFirstTime === 'boolean') {
isFirstTime = response.data.isFirstTime;
break;
}
} catch (e) {
console.warn(`[Login] check-first-time tentative ${attempt}/${FIRST_TIME_RETRIES} échouée (LDAP indisponible ?)`);
}
if (attempt < FIRST_TIME_RETRIES) {
await new Promise(resolve => setTimeout(resolve, FIRST_TIME_DELAY_MS));
}
}

if (isFirstTime === true) {
console.log('[Login] Première connexion détectée - redirection vers Assistant RAID');
navigate('/setup/storage', { replace: true });
return;
}
if (isFirstTime === null) {
console.error('[Login] Impossible de déterminer l\'état première-connexion (LDAP KO) - fallback Keycloak');
}

// 5) Récupérer l'IP locale si on est sur ryvie.local (pour que le cookie Keycloak
// soit sur le même domaine que les apps)
Expand Down
58 changes: 48 additions & 10 deletions Ryvie-Front/src/pages/StorageSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,15 @@ const StorageSettings = () => {
const [showDockerMoveModal, setShowDockerMoveModal] = useState(false);
const [dockerMoveTarget, setDockerMoveTarget] = useState<string>('');
const [dockerMoveGrow, setDockerMoveGrow] = useState(false);
const [dockerMoveReclaim, setDockerMoveReclaim] = useState(false);
const [dockerMovePrechecks, setDockerMovePrechecks] = useState<any>(null);
const [dockerMovePrechecking, setDockerMovePrechecking] = useState(false);
const [dockerMoveState, setDockerMoveState] = useState<any>(null);

// Analyse de l'espace récupérable (anciennes installs) — alimente la case du modal Docker move
const [diskReclaim, setDiskReclaim] = useState<any>(null);
const [diskReclaimScanning, setDiskReclaimScanning] = useState(false);

// Helper: strip emojis from strings for consistent UI DA
const stripEmojis = (str) => {
if (!str) return '';
Expand Down Expand Up @@ -1041,29 +1046,33 @@ const StorageSettings = () => {
// Points de montage candidats (dérivés des disques), hors emplacement Docker actuel
const getCandidateMounts = useCallback((): string[] => {
const current = dockerLocation?.currentMount;
// Points de montage à ne jamais proposer comme cible Docker (EFI, /boot, etc.)
const isValidTarget = (mp: string) =>
mp && mp.startsWith('/') && mp !== current &&
mp !== '/boot' && !mp.startsWith('/boot/');
const set = new Set<string>();
(disks || []).forEach((disk: any) => {
(disk.children || []).forEach((ch: any) => {
(ch.mountpoints || []).forEach((mp: string) => {
if (mp && mp.startsWith('/') && mp !== current) set.add(mp);
if (isValidTarget(mp)) set.add(mp);
});
});
(disk.mountpoints || []).forEach((mp: string) => {
if (mp && mp.startsWith('/') && mp !== current) set.add(mp);
if (isValidTarget(mp)) set.add(mp);
});
});
return Array.from(set).sort();
}, [disks, dockerLocation]);

// Lance les pré-vérifications pour une cible donnée
const runDockerMovePrechecks = async (targetMount: string, grow: boolean) => {
const runDockerMovePrechecks = async (targetMount: string, grow: boolean, reclaim = false) => {
setDockerMovePrechecking(true);
setDockerMovePrechecks(null);
try {
const accessMode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(accessMode);
const resp = await axios.post(`${serverUrl}/api/storage/docker-move-prechecks`, {
targetMount, growRequested: grow
targetMount, growRequested: grow, reclaimRequested: reclaim
}, { timeout: 60000 });
setDockerMovePrechecks(resp.data);
} catch (error) {
Expand All @@ -1083,7 +1092,7 @@ const StorageSettings = () => {
const accessMode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(accessMode);
const resp = await axios.post(`${serverUrl}/api/storage/docker-move`, {
targetMount: dockerMoveTarget, growRequested: dockerMoveGrow
targetMount: dockerMoveTarget, growRequested: dockerMoveGrow, reclaimRequested: dockerMoveReclaim
}, { timeout: 30000 });
if (resp.data.success) {
addLog('Déplacement Docker démarré', 'success');
Expand Down Expand Up @@ -1124,10 +1133,26 @@ const StorageSettings = () => {
}
};

// Scanne le disque système pour l'espace récupérable (anciennes installs orphelines)
const loadDiskReclaim = useCallback(async () => {
setDiskReclaimScanning(true);
try {
const accessMode = getCurrentAccessMode() || 'private';
const serverUrl = getServerUrl(accessMode);
const resp = await axios.post(`${serverUrl}/api/storage/disk-reclaim-scan`, {}, { timeout: 60000 });
setDiskReclaim(resp.data);
} catch (error) {
setDiskReclaim({ success: false, canProceed: false, reasons: [error.response?.data?.error || error.message] });
} finally {
setDiskReclaimScanning(false);
}
}, []);

// Charger l'emplacement Docker au montage (et reprendre un déplacement en cours)
useEffect(() => {
loadDockerLocation();
}, [loadDockerLocation]);
loadDiskReclaim();
}, [loadDockerLocation, loadDiskReclaim]);

// Check if the array can be grown (members larger than used dev size)
const checkCanGrow = useCallback(async () => {
Expand Down Expand Up @@ -1895,8 +1920,10 @@ const StorageSettings = () => {
onClick={() => {
setDockerMoveTarget('');
setDockerMoveGrow(false);
setDockerMoveReclaim(false);
setDockerMovePrechecks(null);
loadDockerLocation();
loadDiskReclaim();
setShowDockerMoveModal(true);
}}
>
Expand Down Expand Up @@ -2539,6 +2566,7 @@ const StorageSettings = () => {
</div>
</div>

{/* Agrandir la partition cible pour remplir le disque (à chaud, sans reboot) */}
<div className="modal-section">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input type="checkbox" checked={dockerMoveGrow}
Expand All @@ -2553,16 +2581,25 @@ const StorageSettings = () => {

{dockerMovePrechecks && !dockerMovePrechecking && (
<div className="modal-section" style={{ background: '#f8f9fa', padding: '1rem', borderRadius: '6px' }}>
<div>{t('storageSettings.dockerMoveRequired')} : <strong>{formatBytes(dockerMovePrechecks.requiredBytes || 0)}</strong> — {t('storageSettings.dockerMoveAvailable')} : <strong>{formatBytes(dockerMovePrechecks.availableBytes || 0)}</strong></div>
{dockerMovePrechecks.growPlan && dockerMovePrechecks.growPlan.rebootWillBeRequired && (
<div className="storage-alert storage-alert-warning" style={{ marginTop: '0.5rem' }}>{t('storageSettings.dockerMoveRebootWarning')}</div>
<div>{t('storageSettings.dockerMoveRequired')} : <strong>{formatBytes(dockerMovePrechecks.requiredBytes || 0)}</strong> · {t('storageSettings.dockerMoveAvailable')} : <strong>{formatBytes(dockerMovePrechecks.availableBytes || 0)}</strong>
{(dockerMovePrechecks.projectedAvailableBytes || 0) > (dockerMovePrechecks.availableBytes || 0) && (
<span> → <strong style={{ color: '#10b981' }}>{formatBytes(dockerMovePrechecks.projectedAvailableBytes || 0)}</strong> après agrandissement</span>
)}
</div>
{dockerMovePrechecks.growPlan && dockerMovePrechecks.growPlan.supported && !dockerMovePrechecks.growPlan.alreadyMax && (
<div style={{ marginTop: '0.5rem', color: '#10b981' }}>
✓ L'agrandissement se fait à chaud, sans redémarrage : seule la fin de la partition bouge, aucune donnée n'est déplacée.
{dockerMovePrechecks.growPlan.swapRelocation && ' Le swap sera recréé en fin de disque (brève désactivation).'}
</div>
)}
{dockerMovePrechecks.reasons && dockerMovePrechecks.reasons.length > 0 && (
<ul style={{ color: '#ef4444', margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{dockerMovePrechecks.reasons.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
)}
<div className="storage-alert storage-alert-warning" style={{ marginTop: '0.5rem' }}>{t('storageSettings.dockerMoveDowntime')}</div>
<div className="storage-alert storage-alert-error" style={{ marginTop: '0.5rem', fontWeight: 600 }}>
⚠️ Attention : toutes les applications (Docker) seront arrêtées pendant l'opération et redémarreront une fois la copie terminée. Prévois une courte interruption de service.
</div>
</div>
)}

Expand All @@ -2577,6 +2614,7 @@ const StorageSettings = () => {
</div>
</div>
)}

</div>
);
};
Expand Down
Loading
Loading