login
This commit is contained in:
@@ -17,6 +17,8 @@ Ce projet est un POC de professeur virtuel pour enfants, avec:
|
||||
- Le backend a un socle d'authentification avec comptes `users`, roles `student`, `teacher`, `maintenance`.
|
||||
- Le compte admin/prof initial est seede depuis `.env` via `ADMIN_USERNAME` et `ADMIN_PASSWORD`.
|
||||
- Le login pose un cookie HTTP-only `professeur_top_session` et renvoie aussi un token bearer.
|
||||
- Le frontend affiche maintenant une page de connexion avant l'application et verifie la session via `/auth/me`.
|
||||
- Apres connexion admin/prof, l'interface actuelle reste accessible avec un bouton de deconnexion.
|
||||
- Le vrai `docker-compose.yml` de production n'est pas dans ce repo. Il est situe un niveau au-dessus sur le serveur.
|
||||
- La conf nginx reelle route:
|
||||
- `/api/` vers `tutor-backend:8000`
|
||||
|
||||
@@ -30,13 +30,19 @@ async function parseApiResponse(res) {
|
||||
return data
|
||||
}
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(`${API_BASE}${path}`, options)
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
})
|
||||
return parseApiResponse(res)
|
||||
}
|
||||
|
||||
async function fetchAudio(path, options) {
|
||||
const res = await fetch(`${API_BASE}${path}`, options)
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = `Erreur API (${res.status})`
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
@@ -99,7 +105,72 @@ function getSupportedRecordingMimeType() {
|
||||
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || ''
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
function LoginPage({ onLogin }) {
|
||||
const [credentials, setCredentials] = useState({ username: '', password: '' })
|
||||
const [status, setStatus] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function submitLogin(e) {
|
||||
e.preventDefault()
|
||||
if (!credentials.username.trim() || !credentials.password) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const data = await apiFetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: credentials.username.trim(),
|
||||
password: credentials.password,
|
||||
}),
|
||||
})
|
||||
onLogin(data.user)
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Connexion impossible.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-panel card">
|
||||
<Avatar speaking={false} />
|
||||
<div className="login-copy">
|
||||
<h1>Professeur TOP</h1>
|
||||
<p className="muted">Connexion eleve, professeur ou maintenance.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitLogin} className="stack login-form">
|
||||
<input
|
||||
autoComplete="username"
|
||||
placeholder="Identifiant"
|
||||
value={credentials.username}
|
||||
onChange={(event) =>
|
||||
setCredentials({ ...credentials, username: event.target.value })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
placeholder="Mot de passe"
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(event) =>
|
||||
setCredentials({ ...credentials, password: event.target.value })
|
||||
}
|
||||
/>
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="muted voice-status">{status}</p>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function TutorApp({ currentUser, onLogout }) {
|
||||
const [students, setStudents] = useState([])
|
||||
const [selectedStudentId, setSelectedStudentId] = useState('')
|
||||
const [form, setForm] = useState({ first_name: '', age: 8, grade: 'CM1' })
|
||||
@@ -725,6 +796,12 @@ export default function App() {
|
||||
: 'Choisis ou crée un élève pour commencer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="session-user">
|
||||
<span>{currentUser.username} · {currentUser.role}</span>
|
||||
<button type="button" className="secondary-button" onClick={onLogout}>
|
||||
Deconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="messages">
|
||||
@@ -795,3 +872,53 @@ export default function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [currentUser, setCurrentUser] = useState(null)
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const user = await apiFetch('/auth/me')
|
||||
if (isMounted) setCurrentUser(user)
|
||||
} catch {
|
||||
if (isMounted) setCurrentUser(null)
|
||||
} finally {
|
||||
if (isMounted) setIsCheckingSession(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSession()
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiFetch('/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
setCurrentUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingSession) {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-panel card">
|
||||
<Avatar speaking={false} />
|
||||
<p className="muted">Verification de la session...</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginPage onLogin={setCurrentUser} />
|
||||
}
|
||||
|
||||
return <TutorApp currentUser={currentUser} onLogout={logout} />
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.secondary-button {
|
||||
background: #e2e8f0;
|
||||
color: #14213d;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
@@ -36,7 +40,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.sidebar, .main { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.stack { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.small-gap { gap: 0.5rem; }
|
||||
.hero { display: flex; align-items: center; gap: 1rem; }
|
||||
.hero { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.session-user {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 360px;
|
||||
@@ -113,6 +125,28 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
display: block;
|
||||
}
|
||||
.avatar-caption { font-weight: 700; }
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-panel {
|
||||
width: min(420px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.login-copy {
|
||||
text-align: center;
|
||||
}
|
||||
.login-copy h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from { transform: scale(1); }
|
||||
@@ -122,4 +156,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@media (max-width: 920px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.hero { align-items: flex-start; }
|
||||
.session-user {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user