SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%

feat(auth): password login (PBKDF2), own password change, professor-set passwords, column migration

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 9e6193a

10 changed files +167 −12

modified backend/app/api/v1/auth.py +40 −3
@@ -17,6 +17,7 @@ from app.core.security import (
17 17 decode_token,
18 18 email_allowed,
19 19 get_current_user,
20 + verify_password,
20 21 )
21 22 from app.services import mail, users
22 23
@@ -32,6 +33,16 @@ class VerifyReq(BaseModel):
32 33 token: str
33 34
34 35
36 +class PasswordLoginReq(BaseModel):
37 + email: EmailStr
38 + password: str
39 +
40 +
41 +class ChangePasswordReq(BaseModel):
42 + current_password: str | None = None
43 + new_password: str
44 +
45 +
35 46 class PrefsReq(BaseModel):
36 47 tutoiement: bool | None = None
37 48 course: str | None = None
@@ -90,6 +101,19 @@ async def magic_link(req: MagicLinkReq, request: Request, response: Response,
90 101 return out
91 102
92 103
104 +@router.post("/auth/login")
105 +async def password_login(req: PasswordLoginReq, request: Request, response: Response,
106 + settings: Settings = Depends(get_settings)) -> dict:
107 + ip = request.client.host if request.client else "?"
108 + limiter.check(f"pwd:{ip}", 30, 3600, "Trop de tentatives. Réessaie plus tard.")
109 + limiter.check(f"pwd:{req.email.lower()}", 10, 900, "Trop de tentatives pour ce compte. Patiente 15 minutes.")
110 + user = await users.authenticate(req.email, req.password)
111 + if not user:
112 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Courriel ou mot de passe invalide.")
113 + out = _issue(user.id, user.role, settings, response)
114 + return {"mode": "password", **out, "user": _user_dict(user)}
115 +
116 +
93 117 @router.post("/auth/verify")
94 118 async def verify(req: VerifyReq, response: Response,
95 119 settings: Settings = Depends(get_settings)) -> dict:
@@ -123,7 +147,7 @@ async def logout(response: Response) -> dict:
123 147
124 148 @router.get("/auth/config")
125 149 async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
126 − return {"smtp": settings.smtp_enabled,
150 + return {"smtp": settings.smtp_enabled, "password": True,
127 151 "access_code": bool(await users.effective_access_code(settings)),
128 152 "domains": sorted(settings.allowed_domains), "courses": settings.courses,
129 153 "term": settings.TERM_LABEL}
@@ -131,8 +155,8 @@ async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
131 155
132 156 def _user_dict(u) -> dict: # noqa: ANN001
133 157 return {"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
134 − "preferences": u.preferences or {}, "consent_at": u.consent_at.isoformat()
135 − if u.consent_at else None}
158 + "preferences": u.preferences or {}, "has_password": bool(u.password_hash),
159 + "consent_at": u.consent_at.isoformat() if u.consent_at else None}
136 160
137 161
138 162 @router.get("/me")
@@ -152,6 +176,19 @@ async def patch_prefs(req: PrefsReq, auth: AuthUser = Depends(get_current_user))
152 176 return _user_dict(user)
153 177
154 178
179 +@router.post("/me/password")
180 +async def change_password(req: ChangePasswordReq, auth: AuthUser = Depends(get_current_user)) -> dict:
181 + user = await users.get_user(auth.id)
182 + if not user:
183 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
184 + if len(req.new_password) < 8:
185 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).")
186 + if user.password_hash and not verify_password(req.current_password or "", user.password_hash):
187 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect.")
188 + await users.set_password(auth.id, req.new_password)
189 + return {"ok": True}
190 +
191 +
155 192 @router.post("/me/consent")
156 193 async def consent(auth: AuthUser = Depends(get_current_user)) -> dict:
157 194 await users.set_consent(auth.id)
modified backend/app/api/v1/professor.py +18 −0
@@ -254,3 +254,21 @@ async def set_access_code(req: AccessCodeReq, _: AuthUser = Depends(require_prof
254 254 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Code trop court (6 caractères minimum).")
255 255 await users.set_setting("access_code", code)
256 256 return {"ok": True, "access_code": code}
257 +
258 +
259 +class SetPasswordReq(BaseModel):
260 + password: str
261 +
262 +
263 +@router.put("/students/{user_id}/password")
264 +async def set_student_password(user_id: str, req: SetPasswordReq,
265 + auth: AuthUser = Depends(require_professor)) -> dict:
266 + if len(req.password) < 8:
267 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).")
268 + u = await users.get_user(user_id)
269 + if not u:
270 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")
271 + if u.role == "admin" and auth.role != "admin":
272 + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.")
273 + await users.set_password(user_id, req.password)
274 + return {"ok": True}
modified backend/app/core/security.py +20 −0
@@ -2,7 +2,9 @@
2 2
3 3 from __future__ import annotations
4 4
5 +import base64
5 6 import hashlib
7 +import hmac
6 8 import secrets
7 9 from datetime import UTC, datetime, timedelta
8 10 from typing import Any
@@ -107,3 +109,21 @@ async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
107 109 if not user.is_admin:
108 110 raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.")
109 111 return user
112 +
113 +
114 +# ------------------------------------------------------------------ passwords (PBKDF2-SHA256)
115 +def hash_password(password: str, iterations: int = 310_000) -> str:
116 + salt = secrets.token_bytes(16)
117 + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
118 + return f"pbkdf2${iterations}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
119 +
120 +
121 +def verify_password(password: str, stored: str | None) -> bool:
122 + if not stored or not stored.startswith("pbkdf2$"):
123 + return False
124 + try:
125 + _, it, salt_b64, dk_b64 = stored.split("$", 3)
126 + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), base64.b64decode(salt_b64), int(it))
127 + return hmac.compare_digest(dk, base64.b64decode(dk_b64))
128 + except (ValueError, TypeError):
129 + return False
modified backend/app/db.py +17 −0
@@ -25,6 +25,23 @@ async def init_db() -> None:
25 25 await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
26 26 await conn.exec_driver_sql("PRAGMA foreign_keys=ON")
27 27 await conn.run_sync(Base.metadata.create_all)
28 + await conn.run_sync(_add_missing_columns)
29 +
30 +
31 +def _add_missing_columns(sync_conn) -> None: # noqa: ANN001
32 + """Poor man's migration: ALTER TABLE ADD COLUMN for new nullable columns (SQLite/Postgres)."""
33 + from sqlalchemy import inspect, text
34 +
35 + insp = inspect(sync_conn)
36 + for table in Base.metadata.sorted_tables:
37 + if not insp.has_table(table.name):
38 + continue
39 + existing = {c["name"] for c in insp.get_columns(table.name)}
40 + for col in table.columns:
41 + if col.name in existing:
42 + continue
43 + ctype = col.type.compile(sync_conn.dialect)
44 + sync_conn.execute(text(f'ALTER TABLE {table.name} ADD COLUMN "{col.name}" {ctype}'))
28 45
29 46
30 47 async def get_session() -> AsyncIterator[AsyncSession]:
modified backend/app/models/__init__.py +1 −0
@@ -39,6 +39,7 @@ class User(Base):
39 39 display_name: Mapped[str | None] = mapped_column(String(120))
40 40 locale: Mapped[str] = mapped_column(String(8), default="fr-CA")
41 41 preferences: Mapped[dict] = mapped_column(JSON, default=dict)
42 + password_hash: Mapped[str | None] = mapped_column(String(255))
42 43 consent_at: Mapped[datetime | None] = mapped_column(DateTime)
43 44 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
44 45 last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
modified backend/app/services/users.py +24 −1
@@ -7,7 +7,7 @@ from datetime import timedelta
7 7 from sqlalchemy import delete, func, select
8 8
9 9 from app.core.config import Settings
10 −from app.core.security import new_magic_token, role_for_email
10 +from app.core.security import hash_password, new_magic_token, role_for_email, verify_password
11 11 from app.db import SessionLocal
12 12 from app.models import (
13 13 AppSetting,
@@ -137,6 +137,7 @@ async def list_users() -> list[dict]:
137 137 .where(Message.role == "user").group_by(Conversation.user_id))).all())
138 138 rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars()
139 139 return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
140 + "has_password": bool(u.password_hash),
140 141 "created_at": u.created_at.isoformat(),
141 142 "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None,
142 143 "consent_at": u.consent_at.isoformat() if u.consent_at else None,
@@ -191,3 +192,25 @@ async def set_setting(key: str, value: str) -> None:
191 192 async def effective_access_code(settings: Settings) -> str:
192 193 override = await get_setting("access_code")
193 194 return override if override is not None else settings.ACCESS_CODE
195 +
196 +
197 +# ------------------------------------------------------------------ passwords
198 +async def set_password(user_id: str, password: str) -> bool:
199 + async with SessionLocal() as session:
200 + user = await session.get(User, user_id)
201 + if not user:
202 + return False
203 + user.password_hash = hash_password(password)
204 + await session.commit()
205 + return True
206 +
207 +
208 +async def authenticate(email: str, password: str) -> User | None:
209 + async with SessionLocal() as session:
210 + user = await session.scalar(select(User).where(User.email == email.lower().strip()))
211 + if user and verify_password(password, user.password_hash):
212 + user.last_seen_at = utcnow()
213 + await session.commit()
214 + await session.refresh(user)
215 + return user
216 + return None
modified frontend/src/features/auth/login-page.tsx +27 −5
@@ -1,7 +1,7 @@
1 1 import { useEffect, useState, type FormEvent } from 'react';
2 2 import { useNavigate, useSearchParams } from 'react-router-dom';
3 3 import { useQuery } from '@tanstack/react-query';
4 −import { KeyRound, Mail, ShieldCheck } from 'lucide-react';
4 +import { KeyRound, Lock, Mail, ShieldCheck } from 'lucide-react';
5 5 import { api, setToken } from '@/lib/api';
6 6 import type { User } from '@/lib/types';
7 7 import { useAuth } from '@/stores/auth';
@@ -9,7 +9,7 @@ import { Logo } from '@/components/ui/logo';
9 9 import { Button } from '@/components/ui/button';
10 10 import { Spinner } from '@/components/ui/spinner';
11 11
12 −interface AuthConfig { smtp: boolean; access_code: boolean; domains: string[]; courses: string[]; term: string }
12 +interface AuthConfig { smtp: boolean; access_code: boolean; password?: boolean; domains: string[]; courses: string[]; term: string }
13 13 interface LoginResp { mode: string; token?: string; user?: User; sent?: boolean; dev_link?: string; hint?: string }
14 14
15 15 export function LoginPage() {
@@ -19,7 +19,8 @@ export function LoginPage() {
19 19 const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') });
20 20 const [email, setEmail] = useState('');
21 21 const [code, setCode] = useState('');
22 − const [mode, setMode] = useState<'code' | 'link'>('code');
22 + const [mode, setMode] = useState<'code' | 'link' | 'password'>('code');
23 + const [password, setPassword] = useState('');
23 24 const [busy, setBusy] = useState(false);
24 25 const [error, setError] = useState<string | null>(null);
25 26 const [info, setInfo] = useState<string | null>(null);
@@ -46,6 +47,14 @@ export function LoginPage() {
46 47 if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }
47 48 setBusy(true);
48 49 try {
50 + if (mode === 'password') {
51 + const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) });
52 + if (r.token) setToken(r.token);
53 + if (r.user) setUser(r.user);
54 + await api('/me/consent', { method: 'POST' }).catch(() => undefined);
55 + nav('/', { replace: true });
56 + return;
57 + }
49 58 const body: Record<string, string> = { email: email.trim() };
50 59 if (mode === 'code') body.access_code = code.trim();
51 60 const r = await api<LoginResp>('/auth/magic-link', { method: 'POST', body: JSON.stringify(body) });
@@ -88,6 +97,19 @@ export function LoginPage() {
88 97 <input type="email" required autoComplete="email" inputMode="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="prenom.nom@uqo.ca" className="h-12 flex-1 bg-transparent px-3 outline-none" />
89 98 </span>
90 99 </label>
100 + <div className="grid grid-cols-2 gap-1 rounded-xl bg-neutral-surface p-1 text-sm" role="tablist">
101 + <button type="button" role="tab" aria-selected={mode !== 'password'} onClick={() => setMode(cfg?.access_code ? 'code' : 'link')} className={`h-10 rounded-lg font-medium ${mode !== 'password' ? 'bg-white shadow-card text-uqo-blue-dark' : 'text-neutral-muted'}`}>Étudiant · code du cours</button>
102 + <button type="button" role="tab" aria-selected={mode === 'password'} onClick={() => setMode('password')} className={`h-10 rounded-lg font-medium ${mode === 'password' ? 'bg-white shadow-card text-uqo-blue-dark' : 'text-neutral-muted'}`}>Mot de passe</button>
103 + </div>
104 + {mode === 'password' && (
105 + <label className="block">
106 + <span className="text-sm font-medium">Mot de passe</span>
107 + <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">
108 + <Lock size={18} className="ml-3 text-neutral-muted" />
109 + <input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />
110 + </span>
111 + </label>
112 + )}
91 113 {mode === 'code' && (
92 114 <label className="block">
93 115 <span className="text-sm font-medium">Code d'accès du cours</span>
@@ -103,8 +125,8 @@ export function LoginPage() {
103 125 </label>
104 126 {error && <p className="text-sm text-semantic-error">{error}</p>}
105 127 {info && <p className="text-sm text-uqo-blue-dark bg-uqo-blue-light rounded-lg p-3 break-all">{info}</p>}
106 − <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'code' ? 'Se connecter' : 'Recevoir un lien de connexion'}</Button>
107 − {cfg?.access_code && cfg?.smtp && (
128 + <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'link' ? 'Recevoir un lien de connexion' : 'Se connecter'}</Button>
129 + {cfg?.access_code && cfg?.smtp && mode !== 'password' && (
108 130 <button type="button" onClick={() => setMode(mode === 'code' ? 'link' : 'code')} className="w-full text-sm text-uqo-blue underline min-h-[44px]">
109 131 {mode === 'code' ? 'Recevoir plutôt un lien par courriel' : 'Utiliser plutôt le code d\'accès du cours'}
110 132 </button>
modified frontend/src/features/auth/settings-dialog.tsx +15 −0
@@ -11,6 +11,9 @@ export function SettingsDialog() {
11 11 const { user, updatePrefs } = useAuth();
12 12 const nav = useNavigate();
13 13 const [busy, setBusy] = useState(false);
14 + const [cur, setCur] = useState('');
15 + const [nw, setNw] = useState('');
16 + const [pwMsg, setPwMsg] = useState<string | null>(null);
14 17 if (!user) return null;
15 18 const p = user.preferences;
16 19
@@ -60,6 +63,18 @@ export function SettingsDialog() {
60 63 ))}
61 64 </div>
62 65 </Row>
66 + <div className="mt-4 rounded-xl border border-neutral-line p-4 text-sm">
67 + <div className="font-medium">{user.has_password ? 'Changer mon mot de passe' : 'Définir un mot de passe'}</div>
68 + <p className="text-neutral-muted mt-1">Permet de te connecter avec courriel + mot de passe, sans code du cours.</p>
69 + <div className="mt-2 grid gap-2 sm:grid-cols-2">
70 + {user.has_password && <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} placeholder="Mot de passe actuel" autoComplete="current-password" className="h-11 rounded-xl border border-neutral-line px-3" />}
71 + <input type="password" value={nw} onChange={(e) => setNw(e.target.value)} placeholder="Nouveau mot de passe (≥ 8)" autoComplete="new-password" className="h-11 rounded-xl border border-neutral-line px-3" />
72 + </div>
73 + <div className="mt-2 flex items-center gap-3">
74 + <Button size="sm" variant="secondary" disabled={nw.length < 8} onClick={() => api('/me/password', { method: 'POST', body: JSON.stringify({ current_password: cur || null, new_password: nw }) }).then(() => { setPwMsg('Mot de passe enregistré.'); setCur(''); setNw(''); useAuth.getState().load(); }).catch((e) => setPwMsg(e.message))}>Enregistrer</Button>
75 + {pwMsg && <span className="text-neutral-muted">{pwMsg}</span>}
76 + </div>
77 + </div>
63 78 <div className="mt-4 rounded-xl bg-neutral-surface p-4 text-sm">
64 79 <div className="font-medium">Vie privée (Loi 25)</div>
65 80 <p className="text-neutral-muted mt-1">Tes conversations sont conservées 12 mois puis anonymisées ; les fichiers 7 jours (30 si épinglés). Aucune donnée n'est vendue ni utilisée pour entraîner des modèles. <a href="/confidentialite" className="text-uqo-blue underline">Politique complète</a>.</p>
modified frontend/src/features/professor/professor-page.tsx +4 −3
@@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react';
2 2 import { fmtDate } from '@/lib/format';
3 3 import { Link } from 'react-router-dom';
4 4 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5 −import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, Link2, UserPlus, Copy, Check, KeyRound, Shield, ShieldOff } from 'lucide-react';
5 +import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, Link2, UserPlus, Copy, Check, KeyRound, Shield, ShieldOff, Lock } from 'lucide-react';
6 6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
7 7 import { api } from '@/lib/api';
8 8 import { Button } from '@/components/ui/button';
@@ -215,7 +215,7 @@ function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; to
215 215 }
216 216
217 217
218 −interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number }
218 +interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number; has_password?: boolean }
219 219 interface StudentsResp { users: StudentRow[]; access_code: string; smtp: boolean }
220 220
221 221 function StudentsTab() {
@@ -253,7 +253,7 @@ function StudentsTab() {
253 253 <div className="space-y-4">
254 254 <div className="grid md:grid-cols-2 gap-4">
255 255 <Card title="Créer des comptes étudiants">
256 − <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom &lt;courriel@uqo.ca&gt;</code>. Les comptes créés peuvent se connecter avec le code d'accès du cours, ou via un lien personnel.</p>
256 + <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom &lt;courriel@uqo.ca&gt;</code>. Les comptes créés se connectent avec le code d'accès du cours, un lien personnel, ou un mot de passe que tu définis (icône cadenas).</p>
257 257 <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" />
258 258 <div className="mt-2 flex items-center gap-3">
259 259 <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}><UserPlus size={16} /> Créer les comptes</Button>
@@ -294,6 +294,7 @@ function StudentsTab() {
294 294 <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td>
295 295 <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td>
296 296 <td className="py-2 text-right whitespace-nowrap">
297 + <button onClick={() => { const pw = prompt(`Nouveau mot de passe pour ${u.email} (≥ 8 caractères) :`); if (pw) api(`/professor/students/${u.id}/password`, { method: 'PUT', body: JSON.stringify({ password: pw }) }).then(() => { alert('Mot de passe défini.'); refresh(); }).catch((e) => alert(e.message)); }} title={u.has_password ? 'Changer le mot de passe' : 'Définir un mot de passe'} className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${u.has_password ? 'text-uqo-green' : 'text-neutral-muted'}`} aria-label="Mot de passe"><Lock size={15} /></button>
297 298 <button onClick={() => invite(u)} title="Lien de connexion personnel (7 jours)" className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Lien de connexion"><Link2 size={15} /></button>
298 299 {u.role !== 'admin' && (
299 300 <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button>
modified frontend/src/lib/types.ts +1 −0
@@ -7,6 +7,7 @@ export interface User {
7 7 display_name: string | null;
8 8 preferences: { tutoiement?: boolean; course?: string; deep?: boolean; locale?: string; font_scale?: number };
9 9 consent_at: string | null;
10 + has_password?: boolean;
10 11 }
11 12
12 13 export interface Course {
13 14