Feat : add Webauth
All checks were successful
Build and Push Docker Images / docker (push) Successful in 1m22s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 1m22s
This commit is contained in:
Binary file not shown.
@@ -5,6 +5,7 @@ Agrège les données de tous les agents et expose une API REST pour le frontend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -24,6 +25,25 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from webauthn import (
|
||||||
|
generate_registration_options,
|
||||||
|
verify_registration_response,
|
||||||
|
generate_authentication_options,
|
||||||
|
verify_authentication_response,
|
||||||
|
options_to_json,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.structs import (
|
||||||
|
AuthenticatorAttachment,
|
||||||
|
AuthenticatorSelectionCriteria,
|
||||||
|
UserVerificationRequirement,
|
||||||
|
ResidentKeyRequirement,
|
||||||
|
PublicKeyCredentialDescriptor,
|
||||||
|
RegistrationCredential as WebAuthnRegistrationCredential,
|
||||||
|
AuthenticatorAttestationResponse,
|
||||||
|
AuthenticationCredential as WebAuthnAuthenticationCredential,
|
||||||
|
AuthenticatorAssertionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
|
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
|
||||||
@@ -53,6 +73,14 @@ def _load_jwt_secret() -> str:
|
|||||||
|
|
||||||
JWT_SECRET = _load_jwt_secret()
|
JWT_SECRET = _load_jwt_secret()
|
||||||
|
|
||||||
|
# ─── WebAuthn / Passkeys config ───────────────────────────────────────────────
|
||||||
|
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
|
||||||
|
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "VPS Monitor")
|
||||||
|
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "http://localhost:3020")
|
||||||
|
|
||||||
|
# In-memory challenge store: session_id → {challenge, username, expires_at}
|
||||||
|
_webauthn_challenges: dict[str, dict] = {}
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer()
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
# ─── Modèles ──────────────────────────────────────────────────────────────────
|
# ─── Modèles ──────────────────────────────────────────────────────────────────
|
||||||
@@ -110,6 +138,21 @@ class PurgeRequest(BaseModel):
|
|||||||
to_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
|
to_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyRegisterFinishRequest(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
name: str = "Ma passkey"
|
||||||
|
credential: dict
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyLoginBeginRequest(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyLoginFinishRequest(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
credential: dict
|
||||||
|
|
||||||
|
|
||||||
# ─── SQLite ───────────────────────────────────────────────────────────────────
|
# ─── SQLite ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -194,6 +237,20 @@ def init_db() -> None:
|
|||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false')
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false')
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true')
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS passkeys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
@@ -258,6 +315,98 @@ def _get_setting(key: str) -> str:
|
|||||||
return row["value"] if row else ""
|
return row["value"] if row else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── WebAuthn / Passkey helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _b64url_encode(b: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - len(s) % 4
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_challenge(challenge: bytes, username: str | None = None) -> str:
|
||||||
|
"""Stocke un challenge WebAuthn en mémoire et retourne un session_id."""
|
||||||
|
session_id = secrets.token_hex(16)
|
||||||
|
_webauthn_challenges[session_id] = {
|
||||||
|
"challenge": challenge,
|
||||||
|
"username": username,
|
||||||
|
"expires_at": time.time() + 300,
|
||||||
|
}
|
||||||
|
# Nettoyage des challenges expirés
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, v in list(_webauthn_challenges.items()) if v["expires_at"] < now]
|
||||||
|
for k in expired:
|
||||||
|
_webauthn_challenges.pop(k, None)
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
def _pop_challenge(session_id: str) -> dict | None:
|
||||||
|
entry = _webauthn_challenges.pop(session_id, None)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if time.time() > entry["expires_at"]:
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _get_passkeys_for_user(username: str) -> list[dict]:
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM passkeys WHERE username = ? ORDER BY created_at DESC",
|
||||||
|
(username,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_passkey_by_credential_id(credential_id: str) -> dict | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM passkeys WHERE credential_id = ?", (credential_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _update_passkey_sign_count(credential_id: str, sign_count: int) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE passkeys SET sign_count = ? WHERE credential_id = ?",
|
||||||
|
(sign_count, credential_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_registration_credential(data: dict) -> WebAuthnRegistrationCredential:
|
||||||
|
resp = data["response"]
|
||||||
|
return WebAuthnRegistrationCredential(
|
||||||
|
id=data["id"],
|
||||||
|
raw_id=_b64url_decode(data.get("rawId", data["id"])),
|
||||||
|
response=AuthenticatorAttestationResponse(
|
||||||
|
client_data_json=_b64url_decode(resp["clientDataJSON"]),
|
||||||
|
attestation_object=_b64url_decode(resp["attestationObject"]),
|
||||||
|
),
|
||||||
|
type=data["type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_authentication_credential(data: dict) -> WebAuthnAuthenticationCredential:
|
||||||
|
resp = data["response"]
|
||||||
|
user_handle = resp.get("userHandle")
|
||||||
|
return WebAuthnAuthenticationCredential(
|
||||||
|
id=data["id"],
|
||||||
|
raw_id=_b64url_decode(data.get("rawId", data["id"])),
|
||||||
|
response=AuthenticatorAssertionResponse(
|
||||||
|
client_data_json=_b64url_decode(resp["clientDataJSON"]),
|
||||||
|
authenticator_data=_b64url_decode(resp["authenticatorData"]),
|
||||||
|
signature=_b64url_decode(resp["signature"]),
|
||||||
|
user_handle=_b64url_decode(user_handle) if user_handle else None,
|
||||||
|
),
|
||||||
|
type=data["type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth helpers ─────────────────────────────────────────────────────────────
|
# ─── Auth helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_token(username: str, role: str) -> str:
|
def create_token(username: str, role: str) -> str:
|
||||||
@@ -477,7 +626,10 @@ async def _cleanup_old_stats() -> None:
|
|||||||
@app.get("/api/auth/status")
|
@app.get("/api/auth/status")
|
||||||
def auth_status():
|
def auth_status():
|
||||||
"""Indique si des utilisateurs existent déjà (pour le frontend)."""
|
"""Indique si des utilisateurs existent déjà (pour le frontend)."""
|
||||||
return {"has_users": len(load_users()) > 0}
|
return {
|
||||||
|
"has_users": len(load_users()) > 0,
|
||||||
|
"passkey_enabled": _get_setting("passkey_enabled") == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/register", status_code=201)
|
@app.post("/api/auth/register", status_code=201)
|
||||||
@@ -561,7 +713,7 @@ def admin_update_setting(
|
|||||||
_: Annotated[dict, Depends(require_admin)],
|
_: Annotated[dict, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Met à jour un paramètre d'administration."""
|
"""Met à jour un paramètre d'administration."""
|
||||||
allowed_keys = {"registration_open"}
|
allowed_keys = {"registration_open", "passkey_enabled"}
|
||||||
if key not in allowed_keys:
|
if key not in allowed_keys:
|
||||||
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -680,6 +832,214 @@ def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin)
|
|||||||
return {"deleted": deleted, "status": "ok"}
|
return {"deleted": deleted, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Routes Passkeys ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/api/auth/passkey/register/begin")
|
||||||
|
def passkey_register_begin(
|
||||||
|
current_user: Annotated[dict, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""Démarre l'enregistrement d'une passkey pour l'utilisateur connecté."""
|
||||||
|
if _get_setting("passkey_enabled") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
|
||||||
|
|
||||||
|
username = current_user["username"]
|
||||||
|
existing = _get_passkeys_for_user(username)
|
||||||
|
exclude_creds = [
|
||||||
|
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
|
||||||
|
for pk in existing
|
||||||
|
]
|
||||||
|
|
||||||
|
opts = generate_registration_options(
|
||||||
|
rp_id=WEBAUTHN_RP_ID,
|
||||||
|
rp_name=WEBAUTHN_RP_NAME,
|
||||||
|
user_id=username.encode(),
|
||||||
|
user_name=username,
|
||||||
|
user_display_name=username,
|
||||||
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||||
|
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
|
||||||
|
resident_key=ResidentKeyRequirement.REQUIRED,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
),
|
||||||
|
exclude_credentials=exclude_creds,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = _store_challenge(opts.challenge, username=username)
|
||||||
|
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/passkey/register/finish")
|
||||||
|
def passkey_register_finish(
|
||||||
|
body: PasskeyRegisterFinishRequest,
|
||||||
|
current_user: Annotated[dict, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""Finalise l'enregistrement d'une passkey."""
|
||||||
|
if _get_setting("passkey_enabled") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
|
||||||
|
|
||||||
|
challenge_data = _pop_challenge(body.session_id)
|
||||||
|
if not challenge_data:
|
||||||
|
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
|
||||||
|
if challenge_data["username"] != current_user["username"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Session invalide")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cred = _parse_registration_credential(body.credential)
|
||||||
|
verification = verify_registration_response(
|
||||||
|
credential=cred,
|
||||||
|
expected_challenge=challenge_data["challenge"],
|
||||||
|
expected_rp_id=WEBAUTHN_RP_ID,
|
||||||
|
expected_origin=WEBAUTHN_ORIGIN,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Vérification échouée : {exc}")
|
||||||
|
|
||||||
|
cred_id = _b64url_encode(verification.credential_id)
|
||||||
|
pub_key = _b64url_encode(verification.credential_public_key)
|
||||||
|
|
||||||
|
if _get_passkey_by_credential_id(cred_id):
|
||||||
|
raise HTTPException(status_code=409, detail="Cette passkey est déjà enregistrée")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO passkeys (username, credential_id, public_key, sign_count, name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(current_user["username"], cred_id, pub_key,
|
||||||
|
verification.sign_count, body.name, int(time.time())),
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/passkey/login/begin")
|
||||||
|
def passkey_login_begin(body: PasskeyLoginBeginRequest):
|
||||||
|
"""Démarre l'authentification par passkey."""
|
||||||
|
if _get_setting("passkey_enabled") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
|
||||||
|
|
||||||
|
allow_creds: list[PublicKeyCredentialDescriptor] = []
|
||||||
|
if body.username:
|
||||||
|
passkeys = _get_passkeys_for_user(body.username)
|
||||||
|
allow_creds = [
|
||||||
|
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
|
||||||
|
for pk in passkeys
|
||||||
|
]
|
||||||
|
|
||||||
|
opts = generate_authentication_options(
|
||||||
|
rp_id=WEBAUTHN_RP_ID,
|
||||||
|
allow_credentials=allow_creds,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = _store_challenge(opts.challenge, username=body.username)
|
||||||
|
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/passkey/login/finish")
|
||||||
|
def passkey_login_finish(body: PasskeyLoginFinishRequest, request: Request):
|
||||||
|
"""Vérifie la passkey et retourne un JWT."""
|
||||||
|
if _get_setting("passkey_enabled") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
|
||||||
|
|
||||||
|
challenge_data = _pop_challenge(body.session_id)
|
||||||
|
if not challenge_data:
|
||||||
|
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
|
||||||
|
|
||||||
|
cred_id = body.credential.get("id", "")
|
||||||
|
passkey = _get_passkey_by_credential_id(cred_id)
|
||||||
|
if not passkey:
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
_log_login("unknown", ip, False, "Passkey introuvable")
|
||||||
|
raise HTTPException(status_code=401, detail="Passkey non reconnue")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cred = _parse_authentication_credential(body.credential)
|
||||||
|
verification = verify_authentication_response(
|
||||||
|
credential=cred,
|
||||||
|
expected_challenge=challenge_data["challenge"],
|
||||||
|
expected_rp_id=WEBAUTHN_RP_ID,
|
||||||
|
expected_origin=WEBAUTHN_ORIGIN,
|
||||||
|
credential_public_key=_b64url_decode(passkey["public_key"]),
|
||||||
|
credential_current_sign_count=passkey["sign_count"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
_log_login(passkey["username"], ip, False, f"Passkey invalide : {exc}")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentification échouée")
|
||||||
|
|
||||||
|
_update_passkey_sign_count(cred_id, verification.new_sign_count)
|
||||||
|
|
||||||
|
user = next((u for u in load_users() if u["username"] == passkey["username"]), None)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
|
||||||
|
|
||||||
|
ip = _get_client_ip(request)
|
||||||
|
_log_login(user["username"], ip, True, "passkey")
|
||||||
|
token = create_token(user["username"], user["role"])
|
||||||
|
return {"access_token": token, "token_type": "bearer", "role": user["role"]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/passkeys")
|
||||||
|
def list_my_passkeys(current_user: Annotated[dict, Depends(get_current_user)]):
|
||||||
|
"""Liste les passkeys de l'utilisateur connecté."""
|
||||||
|
passkeys = _get_passkeys_for_user(current_user["username"])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"credential_id": pk["credential_id"],
|
||||||
|
"name": pk["name"],
|
||||||
|
"created_at": datetime.fromtimestamp(pk["created_at"], tz=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
for pk in passkeys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/auth/passkeys/{credential_id}")
|
||||||
|
def delete_my_passkey(
|
||||||
|
credential_id: str,
|
||||||
|
current_user: Annotated[dict, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
"""Supprime une passkey de l'utilisateur connecté."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM passkeys WHERE credential_id = ? AND username = ?",
|
||||||
|
(credential_id, current_user["username"]),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Passkey introuvable")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/passkeys")
|
||||||
|
def admin_list_passkeys(_: Annotated[dict, Depends(require_admin)]):
|
||||||
|
"""Admin : liste toutes les passkeys enregistrées."""
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM passkeys ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"credential_id": row["credential_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"name": row["name"],
|
||||||
|
"created_at": datetime.fromtimestamp(row["created_at"], tz=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/passkeys/{credential_id}")
|
||||||
|
def admin_delete_passkey(
|
||||||
|
credential_id: str,
|
||||||
|
_: Annotated[dict, Depends(require_admin)],
|
||||||
|
):
|
||||||
|
"""Admin : révoque n'importe quelle passkey."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM passkeys WHERE credential_id = ?", (credential_id,)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Passkey introuvable")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# ─── Routes VPS ───────────────────────────────────────────────────────────────
|
# ─── Routes VPS ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/vps")
|
@app.get("/api/vps")
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ aiohttp>=3.9.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
bcrypt>=4.0.0
|
bcrypt>=4.0.0
|
||||||
|
webauthn>=2.0.0
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function App() {
|
|||||||
const [role, setRole] = useState(null)
|
const [role, setRole] = useState(null)
|
||||||
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
|
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
|
||||||
const [isFirstUser, setIsFirstUser] = useState(false)
|
const [isFirstUser, setIsFirstUser] = useState(false)
|
||||||
|
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
const [vpsList, setVpsList] = useState([])
|
const [vpsList, setVpsList] = useState([])
|
||||||
@@ -56,7 +57,10 @@ export default function App() {
|
|||||||
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authStatus()
|
authStatus()
|
||||||
.then(({ has_users }) => setIsFirstUser(!has_users))
|
.then(({ has_users, passkey_enabled }) => {
|
||||||
|
setIsFirstUser(!has_users)
|
||||||
|
setPasskeyEnabled(!!passkey_enabled)
|
||||||
|
})
|
||||||
.catch(() => setIsFirstUser(false))
|
.catch(() => setIsFirstUser(false))
|
||||||
.finally(() => setAuthChecked(true))
|
.finally(() => setAuthChecked(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -211,6 +215,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<LoginPage
|
<LoginPage
|
||||||
isFirstUser={isFirstUser}
|
isFirstUser={isFirstUser}
|
||||||
|
passkeyEnabled={passkeyEnabled}
|
||||||
onAuthenticated={handleAuthenticated}
|
onAuthenticated={handleAuthenticated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -184,3 +184,140 @@ export async function purgeDb({ table, period, fromTs, toTs }) {
|
|||||||
})
|
})
|
||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Passkeys (WebAuthn) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _b64url(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let str = ''
|
||||||
|
for (const b of bytes) str += String.fromCharCode(b)
|
||||||
|
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fromB64url(str) {
|
||||||
|
const padding = '='.repeat((4 - (str.length % 4)) % 4)
|
||||||
|
const base64 = (str + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const binary = atob(base64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function _prepareRegistrationOptions(opts) {
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
challenge: _fromB64url(opts.challenge),
|
||||||
|
user: { ...opts.user, id: _fromB64url(opts.user.id) },
|
||||||
|
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _prepareAuthenticationOptions(opts) {
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
challenge: _fromB64url(opts.challenge),
|
||||||
|
allowCredentials: (opts.allowCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _serializeRegistrationCredential(cred) {
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: _b64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: _b64url(cred.response.clientDataJSON),
|
||||||
|
attestationObject: _b64url(cred.response.attestationObject),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _serializeAuthenticationCredential(cred) {
|
||||||
|
const out = {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: _b64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: _b64url(cred.response.clientDataJSON),
|
||||||
|
authenticatorData: _b64url(cred.response.authenticatorData),
|
||||||
|
signature: _b64url(cred.response.signature),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (cred.response.userHandle) {
|
||||||
|
out.response.userHandle = _b64url(cred.response.userHandle)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lance l'enregistrement complet d'une passkey (begin → browser → finish). */
|
||||||
|
export async function registerPasskey(name) {
|
||||||
|
// 1. Obtenir le challenge
|
||||||
|
const beginRes = await fetch(`${BASE}/auth/passkey/register/begin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
const { session_id, options } = await handleResponse(beginRes)
|
||||||
|
|
||||||
|
// 2. Appel biométrique
|
||||||
|
const cred = await navigator.credentials.create({
|
||||||
|
publicKey: _prepareRegistrationOptions(options),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Finaliser
|
||||||
|
const finishRes = await fetch(`${BASE}/auth/passkey/register/finish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ session_id, name, credential: _serializeRegistrationCredential(cred) }),
|
||||||
|
})
|
||||||
|
return handleResponse(finishRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authentification complète par passkey (begin → browser → finish). Retourne {access_token, role}. */
|
||||||
|
export async function loginWithPasskey(username = null) {
|
||||||
|
// 1. Obtenir le challenge
|
||||||
|
const beginRes = await fetch(`${BASE}/auth/passkey/login/begin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
})
|
||||||
|
const { session_id, options } = await handleResponse(beginRes)
|
||||||
|
|
||||||
|
// 2. Appel biométrique (TouchID / FaceID)
|
||||||
|
const cred = await navigator.credentials.get({
|
||||||
|
publicKey: _prepareAuthenticationOptions(options),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Vérification côté serveur
|
||||||
|
const finishRes = await fetch(`${BASE}/auth/passkey/login/finish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id, credential: _serializeAuthenticationCredential(cred) }),
|
||||||
|
})
|
||||||
|
return handleResponse(finishRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyPasskeys() {
|
||||||
|
const res = await fetch(`${BASE}/auth/passkeys`, { headers: authHeaders() })
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMyPasskey(credentialId) {
|
||||||
|
const res = await fetch(`${BASE}/auth/passkeys/${encodeURIComponent(credentialId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGetPasskeys() {
|
||||||
|
const res = await fetch(`${BASE}/admin/passkeys`, { headers: authHeaders() })
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeletePasskey(credentialId) {
|
||||||
|
const res = await fetch(`${BASE}/admin/passkeys/${encodeURIComponent(credentialId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react'
|
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react'
|
||||||
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client'
|
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage({ onBack }) {
|
export default function AdminPage({ onBack }) {
|
||||||
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database'
|
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'logs' | 'database'
|
||||||
|
|
||||||
// ─── Settings ────────────────────────────────────────────────────────────
|
// ─── Settings ────────────────────────────────────────────────────────────
|
||||||
const [settings, setSettings] = useState(null)
|
const [settings, setSettings] = useState(null)
|
||||||
@@ -64,6 +64,55 @@ export default function AdminPage({ onBack }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePasskeys = async () => {
|
||||||
|
if (!settings) return
|
||||||
|
const newValue = settings.passkey_enabled === 'true' ? 'false' : 'true'
|
||||||
|
setToggleLoading(true)
|
||||||
|
try {
|
||||||
|
await setAdminSetting('passkey_enabled', newValue)
|
||||||
|
setSettings(prev => ({ ...prev, passkey_enabled: newValue }))
|
||||||
|
} catch (err) {
|
||||||
|
setSettingsError(err.message)
|
||||||
|
} finally {
|
||||||
|
setToggleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Passkeys admin ──────────────────────────────────────────────────────
|
||||||
|
const [adminPasskeys, setAdminPasskeys] = useState([])
|
||||||
|
const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false)
|
||||||
|
const [adminPasskeysError, setAdminPasskeysError] = useState(null)
|
||||||
|
const [revokeLoading, setRevokeLoading] = useState(null)
|
||||||
|
|
||||||
|
const loadAdminPasskeys = useCallback(async () => {
|
||||||
|
setAdminPasskeysLoading(true)
|
||||||
|
setAdminPasskeysError(null)
|
||||||
|
try {
|
||||||
|
const data = await adminGetPasskeys()
|
||||||
|
setAdminPasskeys(data)
|
||||||
|
} catch (err) {
|
||||||
|
setAdminPasskeysError(err.message)
|
||||||
|
} finally {
|
||||||
|
setAdminPasskeysLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'passkeys') loadAdminPasskeys()
|
||||||
|
}, [activeTab, loadAdminPasskeys])
|
||||||
|
|
||||||
|
const handleRevokePasskey = async (credentialId) => {
|
||||||
|
setRevokeLoading(credentialId)
|
||||||
|
try {
|
||||||
|
await adminDeletePasskey(credentialId)
|
||||||
|
setAdminPasskeys(prev => prev.filter(p => p.credential_id !== credentialId))
|
||||||
|
} catch (err) {
|
||||||
|
setAdminPasskeysError(err.message)
|
||||||
|
} finally {
|
||||||
|
setRevokeLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Login logs ──────────────────────────────────────────────────────────
|
// ─── Login logs ──────────────────────────────────────────────────────────
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [logsTotal, setLogsTotal] = useState(0)
|
const [logsTotal, setLogsTotal] = useState(0)
|
||||||
@@ -196,6 +245,7 @@ export default function AdminPage({ onBack }) {
|
|||||||
<div className="flex gap-1 mb-8 border-b border-gray-800">
|
<div className="flex gap-1 mb-8 border-b border-gray-800">
|
||||||
{[
|
{[
|
||||||
{ key: 'settings', label: 'Paramètres' },
|
{ key: 'settings', label: 'Paramètres' },
|
||||||
|
{ key: 'passkeys', label: 'Passkeys' },
|
||||||
{ key: 'logs', label: 'Connexions' },
|
{ key: 'logs', label: 'Connexions' },
|
||||||
{ key: 'database', label: 'Base de données' },
|
{ key: 'database', label: 'Base de données' },
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
@@ -236,12 +286,95 @@ export default function AdminPage({ onBack }) {
|
|||||||
onChange={toggleRegistration}
|
onChange={toggleRegistration}
|
||||||
loading={toggleLoading}
|
loading={toggleLoading}
|
||||||
/>
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Authentification par passkey"
|
||||||
|
description="Autorise la connexion via TouchID / FaceID (WebAuthn)."
|
||||||
|
enabled={settings?.passkey_enabled === 'true'}
|
||||||
|
onChange={togglePasskeys}
|
||||||
|
loading={toggleLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Passkeys ── */}
|
||||||
|
{activeTab === 'passkeys' && (
|
||||||
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-300">Passkeys enregistrées</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Gérez les passkeys (TouchID / FaceID) de tous les utilisateurs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadAdminPasskeys}
|
||||||
|
disabled={adminPasskeysLoading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={adminPasskeysLoading ? 'animate-spin' : ''} />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adminPasskeysError && (
|
||||||
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
||||||
|
{adminPasskeysError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminPasskeysLoading && adminPasskeys.length === 0
|
||||||
|
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
|
||||||
|
: adminPasskeys.length === 0
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-gray-600">
|
||||||
|
<Fingerprint size={28} />
|
||||||
|
<p className="text-xs">Aucune passkey enregistrée.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="overflow-x-auto -mx-2">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b border-gray-800">
|
||||||
|
<th className="pb-2 px-2 font-medium">Utilisateur</th>
|
||||||
|
<th className="pb-2 px-2 font-medium">Appareil</th>
|
||||||
|
<th className="pb-2 px-2 font-medium">Enregistrée le</th>
|
||||||
|
<th className="pb-2 px-2 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/60">
|
||||||
|
{adminPasskeys.map(pk => (
|
||||||
|
<tr key={pk.credential_id} className="hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="py-2 px-2 font-mono text-gray-200">{pk.username}</td>
|
||||||
|
<td className="py-2 px-2 text-gray-300 flex items-center gap-1.5">
|
||||||
|
<Key size={11} className="text-indigo-400 flex-shrink-0" />
|
||||||
|
{pk.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(pk.created_at).toLocaleString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevokePasskey(pk.credential_id)}
|
||||||
|
disabled={revokeLoading === pk.credential_id}
|
||||||
|
className="px-2 py-1 rounded-md text-xs text-red-400 hover:bg-red-950/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{revokeLoading === pk.credential_id ? '…' : 'Révoquer'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Tab: Connexions ── */}
|
{/* ── Tab: Connexions ── */}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Monitor } from 'lucide-react'
|
import { Monitor, Fingerprint } from 'lucide-react'
|
||||||
import { login, register } from '../api/client'
|
import { login, register, loginWithPasskey } from '../api/client'
|
||||||
|
|
||||||
export default function LoginPage({ isFirstUser, onAuthenticated }) {
|
export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [password2, setPassword2] = useState('')
|
const [password2, setPassword2] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [passkeyLoading, setPasskeyLoading] = useState(false)
|
||||||
|
const [browserSupportsPasskey, setBrowserSupportsPasskey] = useState(false)
|
||||||
|
|
||||||
const isRegister = isFirstUser
|
const isRegister = isFirstUser
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.PublicKeyCredential) {
|
||||||
|
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||||
|
.then(available => setBrowserSupportsPasskey(available))
|
||||||
|
.catch(() => setBrowserSupportsPasskey(false))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -39,6 +49,25 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
setError(null)
|
||||||
|
setPasskeyLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await loginWithPasskey(username.trim() || null)
|
||||||
|
onAuthenticated(data.access_token, data.role)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) {
|
||||||
|
setError('Authentification annulée.')
|
||||||
|
} else {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasskeyLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPasskeyButton = !isRegister && passkeyEnabled && browserSupportsPasskey
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
@@ -53,7 +82,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
|
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
|
||||||
{isRegister && (
|
{isRegister && (
|
||||||
<div className="bg-indigo-950/40 border border-indigo-800/50 rounded-lg px-3 py-2 text-xs text-indigo-300">
|
<div className="bg-indigo-950/40 border border-indigo-800/50 rounded-lg px-3 py-2 text-xs text-indigo-300">
|
||||||
Aucun utilisateur n'existe encore. Le premier compte créé sera <strong>admin</strong>.
|
Aucun utilisateur n'existe encore. Le premier compte créé sera <strong>admin</strong>.
|
||||||
@@ -66,57 +95,79 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
autoComplete="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isRegister && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
|
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoFocus
|
||||||
value={password2}
|
autoComplete="username"
|
||||||
onChange={(e) => setPassword2(e.target.value)}
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<div>
|
||||||
type="submit"
|
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
|
||||||
disabled={loading}
|
<input
|
||||||
className="w-full py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors mt-2"
|
type="password"
|
||||||
>
|
required
|
||||||
{loading
|
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||||
? 'Chargement…'
|
value={password}
|
||||||
: isRegister
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
? 'Créer le compte'
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
: 'Se connecter'}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password2}
|
||||||
|
onChange={(e) => setPassword2(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || passkeyLoading}
|
||||||
|
className="w-full py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors mt-2"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Chargement…'
|
||||||
|
: isRegister
|
||||||
|
? 'Créer le compte'
|
||||||
|
: 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{showPasskeyButton && (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center gap-3">
|
||||||
|
<div className="flex-1 border-t border-gray-800" />
|
||||||
|
<span className="text-xs text-gray-600">ou</span>
|
||||||
|
<div className="flex-1 border-t border-gray-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={loading || passkeyLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-sm font-medium transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<Fingerprint size={16} className="text-indigo-400" />
|
||||||
|
{passkeyLoading ? 'Vérification…' : 'Se connecter avec une passkey'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { KeyRound, ArrowLeft, Check } from 'lucide-react'
|
import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react'
|
||||||
import { changePassword } from '../api/client'
|
import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client'
|
||||||
|
|
||||||
export default function ProfilePage({ username, onBack }) {
|
export default function ProfilePage({ username, onBack }) {
|
||||||
const [oldPassword, setOldPassword] = useState('')
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
@@ -10,6 +10,72 @@ export default function ProfilePage({ username, onBack }) {
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// ─── Passkeys ─────────────────────────────────────────────────────────────
|
||||||
|
const [passkeys, setPasskeys] = useState([])
|
||||||
|
const [passkeysLoading, setPasskeysLoading] = useState(true)
|
||||||
|
const [passkeysError, setPasskeysError] = useState(null)
|
||||||
|
const [addName, setAddName] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [addError, setAddError] = useState(null)
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(null)
|
||||||
|
const [browserSupports, setBrowserSupports] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.PublicKeyCredential) {
|
||||||
|
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||||
|
.then(ok => setBrowserSupports(ok))
|
||||||
|
.catch(() => setBrowserSupports(false))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPasskeys = useCallback(async () => {
|
||||||
|
setPasskeysLoading(true)
|
||||||
|
setPasskeysError(null)
|
||||||
|
try {
|
||||||
|
const data = await getMyPasskeys()
|
||||||
|
setPasskeys(data)
|
||||||
|
} catch (err) {
|
||||||
|
setPasskeysError(err.message)
|
||||||
|
} finally {
|
||||||
|
setPasskeysLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadPasskeys() }, [loadPasskeys])
|
||||||
|
|
||||||
|
const handleAddPasskey = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setAddError(null)
|
||||||
|
setAdding(true)
|
||||||
|
try {
|
||||||
|
const name = addName.trim() || 'Ma passkey'
|
||||||
|
await registerPasskey(name)
|
||||||
|
setAddName('')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) {
|
||||||
|
setAddError('Enregistrement annulé.')
|
||||||
|
} else {
|
||||||
|
setAddError(err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePasskey = async (credentialId) => {
|
||||||
|
setDeleteLoading(credentialId)
|
||||||
|
try {
|
||||||
|
await deleteMyPasskey(credentialId)
|
||||||
|
setPasskeys(prev => prev.filter(p => p.credential_id !== credentialId))
|
||||||
|
} catch (err) {
|
||||||
|
setPasskeysError(err.message)
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Password change ──────────────────────────────────────────────────────
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -121,6 +187,83 @@ export default function ProfilePage({ username, onBack }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Passkeys ── */}
|
||||||
|
{browserSupports && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 mt-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Fingerprint size={15} className="text-indigo-400" />
|
||||||
|
<h2 className="text-sm font-medium text-gray-300">Passkeys (TouchID / FaceID)</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Connectez-vous sans mot de passe grâce à la biométrie de votre appareil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{passkeysError && (
|
||||||
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
||||||
|
{passkeysError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des passkeys */}
|
||||||
|
{passkeysLoading
|
||||||
|
? <p className="text-xs text-gray-500 mb-4">Chargement…</p>
|
||||||
|
: passkeys.length === 0
|
||||||
|
? <p className="text-xs text-gray-500 mb-4">Aucune passkey enregistrée.</p>
|
||||||
|
: (
|
||||||
|
<ul className="divide-y divide-gray-800 mb-4">
|
||||||
|
{passkeys.map(pk => (
|
||||||
|
<li key={pk.credential_id} className="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Key size={13} className="text-indigo-400 flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-gray-200 truncate">{pk.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(pk.created_at).toLocaleDateString('fr-FR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePasskey(pk.credential_id)}
|
||||||
|
disabled={deleteLoading === pk.credential_id}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-50 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
{deleteLoading === pk.credential_id ? '…' : 'Supprimer'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Formulaire d'ajout */}
|
||||||
|
{addError && (
|
||||||
|
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
|
||||||
|
{addError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleAddPasskey} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom de l'appareil (ex : MacBook)"
|
||||||
|
value={addName}
|
||||||
|
onChange={e => setAddName(e.target.value)}
|
||||||
|
disabled={adding}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={adding}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{adding ? 'En cours…' : 'Ajouter'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user