feat(auth): écran de connexion + session signée (le Basic reste pour les scripts)
- /login professionnel, cookie HMAC 7 jours, next local seulement, CSRF double-submit réutilisé, /logout réel + lien Sortir. - Middleware : session OU Basic ; navigateurs redirigés vers /login, API en 401 JSON ; /static et /login publics.
This commit is contained in:
@@ -112,8 +112,10 @@ sudo systemctl enable --now docker
|
|||||||
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
||||||
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
||||||
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
||||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
|
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
|
||||||
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
||||||
|
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
|
||||||
|
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
|
||||||
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
||||||
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
||||||
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
||||||
|
|||||||
+13
-3
@@ -8,9 +8,15 @@ Environment variables:
|
|||||||
GITEA_URL Base URL of the Gitea instance.
|
GITEA_URL Base URL of the Gitea instance.
|
||||||
GITEA_TOKEN Gitea API token (read/write issues).
|
GITEA_TOKEN Gitea API token (read/write issues).
|
||||||
KANBANTE_DB_PATH Path of the local SQLite database.
|
KANBANTE_DB_PATH Path of the local SQLite database.
|
||||||
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
|
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
|
||||||
KANBANTE_AUTH_PASSWORD Basic-auth password.
|
KANBANTE_AUTH_PASSWORD Login password.
|
||||||
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
||||||
|
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
|
||||||
|
(default: derived from the credentials —
|
||||||
|
rotating the password invalidates sessions).
|
||||||
|
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
|
||||||
|
refreshes (chantier B, default 120;
|
||||||
|
0 disables the refresher).
|
||||||
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
||||||
days counts as "active" (default 365).
|
days counts as "active" (default 365).
|
||||||
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
||||||
@@ -89,7 +95,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.21.0"
|
APP_VERSION = "0.22.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -158,6 +164,8 @@ class Settings:
|
|||||||
briefing_include_standup: bool = False
|
briefing_include_standup: bool = False
|
||||||
rules_raw: str = ""
|
rules_raw: str = ""
|
||||||
zombie_days: int = 30
|
zombie_days: int = 30
|
||||||
|
session_secret: str = ""
|
||||||
|
snapshot_interval: int = 120
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -203,4 +211,6 @@ def get_settings() -> Settings:
|
|||||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||||
|
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
|
||||||
|
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
|
||||||
)
|
)
|
||||||
|
|||||||
+167
-28
@@ -22,13 +22,21 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
from fastapi.responses import (
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings
|
|||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
from app import code_links as code_links_service
|
from app import code_links as code_links_service
|
||||||
from app import digest as digest_service
|
from app import digest as digest_service
|
||||||
|
from app import snapshots as snapshots_service
|
||||||
from app import flow as flow_service
|
from app import flow as flow_service
|
||||||
from app import forecast as forecast_service
|
from app import forecast as forecast_service
|
||||||
from app import rules as rules_service
|
from app import rules as rules_service
|
||||||
@@ -155,6 +164,13 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||||
)
|
)
|
||||||
|
# Chantier B: hydrate the client cache from persisted
|
||||||
|
# snapshots BEFORE serving, then keep them warm in the
|
||||||
|
# background — pages never wait on Gitea when data exists.
|
||||||
|
gitea.hydrate_from_snapshots()
|
||||||
|
snapshot_task = asyncio.create_task(
|
||||||
|
snapshots_service.snapshot_loop(gitea, classify_repos)
|
||||||
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
digest_task = asyncio.create_task(_digest_scheduler())
|
digest_task = asyncio.create_task(_digest_scheduler())
|
||||||
rules_task = asyncio.create_task(_rules_scheduler())
|
rules_task = asyncio.create_task(_rules_scheduler())
|
||||||
@@ -179,6 +195,7 @@ async def lifespan(app: FastAPI):
|
|||||||
settings.capture_ntfy_topic,
|
settings.capture_ntfy_topic,
|
||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
|
snapshot_task.cancel()
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
digest_task.cancel()
|
digest_task.cancel()
|
||||||
rules_task.cancel()
|
rules_task.cancel()
|
||||||
@@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
|||||||
gitea = GiteaClient()
|
gitea = GiteaClient()
|
||||||
|
|
||||||
|
|
||||||
# ── Auth middleware ────────────────────────────────────────
|
# ── Auth: login form + signed session cookie (0.22.0) ──────
|
||||||
|
# Before 0.22.0 the browser got HTTP Basic's native prompt.
|
||||||
|
# Browsers now use a real login page; the session rides in an
|
||||||
|
# HMAC-signed cookie. An Authorization: Basic header is still
|
||||||
|
# honoured for scripts and API clients. /api/health stays
|
||||||
|
# public for Docker's HEALTHCHECK; /webhooks/gitea is
|
||||||
|
# authenticated by its HMAC signature (Gitea cannot log in) and
|
||||||
|
# /api/capture by its dedicated token.
|
||||||
|
|
||||||
|
SESSION_COOKIE = "kanbante_session"
|
||||||
|
SESSION_DAYS = 7
|
||||||
|
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login")
|
||||||
|
|
||||||
|
|
||||||
|
def _session_secret() -> bytes:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.session_secret:
|
||||||
|
return settings.session_secret.encode("utf-8")
|
||||||
|
# Derived from the credentials: rotating the password
|
||||||
|
# invalidates every outstanding session (documented trade-off).
|
||||||
|
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
|
||||||
|
return hashlib.sha256(material.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(user: str) -> str:
|
||||||
|
exp = int(time.time()) + SESSION_DAYS * 86400
|
||||||
|
payload = f"{user}.{exp}"
|
||||||
|
sig = hmac.new(
|
||||||
|
_session_secret(), payload.encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return f"{payload}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def _session_user(request: Request) -> str | None:
|
||||||
|
raw = request.cookies.get(SESSION_COOKIE, "")
|
||||||
|
user, _, rest = raw.partition(".")
|
||||||
|
exp, _, sig = rest.partition(".")
|
||||||
|
if not user or not exp or not sig:
|
||||||
|
return None
|
||||||
|
expected = hmac.new(
|
||||||
|
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not secrets.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not secrets.compare_digest(user, get_settings().auth_user):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_ok(request: Request) -> bool:
|
||||||
|
settings = get_settings()
|
||||||
|
header = request.headers.get("authorization", "")
|
||||||
|
if not header.lower().startswith("basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
user, _, password = decoded.partition(":")
|
||||||
|
return secrets.compare_digest(
|
||||||
|
user, settings.auth_user
|
||||||
|
) and secrets.compare_digest(password, settings.auth_password)
|
||||||
|
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
"""HTTP Basic auth, enabled only when credentials are configured.
|
"""Session cookie (browser) or Basic header (scripts)."""
|
||||||
|
|
||||||
/api/health stays public so Docker's HEALTHCHECK (liveness) works.
|
|
||||||
When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every
|
|
||||||
other route answers 503: failing closed beats failing open.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
path = request.url.path
|
||||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
if path in _PUBLIC_PATHS or path.startswith("/static/"):
|
||||||
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.auth_enabled:
|
if not settings.auth_enabled:
|
||||||
@@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
)
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
if _session_user(request) is not None or _basic_ok(request):
|
||||||
ok = False
|
return await call_next(request)
|
||||||
header = request.headers.get("authorization", "")
|
# API consumers get a plain 401 (no browser prompt, no
|
||||||
if header.lower().startswith("basic "):
|
# redirect they cannot follow); browsers get the login page.
|
||||||
try:
|
if path.startswith("/api/") or "text/html" not in request.headers.get(
|
||||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
"accept", ""
|
||||||
except Exception:
|
):
|
||||||
decoded = ""
|
return JSONResponse(
|
||||||
user, _, password = decoded.partition(":")
|
{"ok": False, "error": "Authentication required"},
|
||||||
ok = secrets.compare_digest(
|
|
||||||
user, settings.auth_user
|
|
||||||
) and secrets.compare_digest(password, settings.auth_password)
|
|
||||||
if not ok:
|
|
||||||
return Response(
|
|
||||||
"Authentication required",
|
|
||||||
status_code=401,
|
status_code=401,
|
||||||
headers={"WWW-Authenticate": 'Basic realm="Kanbante"'},
|
|
||||||
)
|
)
|
||||||
return await call_next(request)
|
target = path
|
||||||
|
if request.url.query:
|
||||||
|
target = f"{path}?{request.url.query}"
|
||||||
|
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(AuthMiddleware)
|
app.add_middleware(AuthMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(raw: str) -> str:
|
||||||
|
"""Only local paths may be a post-login destination."""
|
||||||
|
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
|
||||||
|
return raw
|
||||||
|
return "/"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login_page(request: Request, next: str = "/"):
|
||||||
|
if not get_settings().auth_enabled or _session_user(request) is not None:
|
||||||
|
return RedirectResponse(_safe_next(next), status_code=303)
|
||||||
|
return render(request, "login.html", next=_safe_next(next), error="")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(""),
|
||||||
|
password: str = Form(""),
|
||||||
|
next: str = Form("/"),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return RedirectResponse("/", status_code=303)
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return render(
|
||||||
|
request, "login.html", status_code=403,
|
||||||
|
next=_safe_next(next), error="Session expirée — réessaie.",
|
||||||
|
)
|
||||||
|
ok = secrets.compare_digest(
|
||||||
|
username, settings.auth_user
|
||||||
|
) and secrets.compare_digest(password, settings.auth_password)
|
||||||
|
if not ok:
|
||||||
|
return render(
|
||||||
|
request, "login.html", status_code=401,
|
||||||
|
next=_safe_next(next), error="Identifiants incorrects.",
|
||||||
|
)
|
||||||
|
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
_make_session(username),
|
||||||
|
max_age=SESSION_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
response = RedirectResponse("/login", status_code=303)
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ── Live updates (SSE hub) ─────────────────────────────────
|
# ── Live updates (SSE hub) ─────────────────────────────────
|
||||||
|
|
||||||
class LiveHub:
|
class LiveHub:
|
||||||
@@ -330,6 +460,8 @@ def render(
|
|||||||
context.setdefault("csrf_token", token)
|
context.setdefault("csrf_token", token)
|
||||||
context.setdefault("version", APP_VERSION)
|
context.setdefault("version", APP_VERSION)
|
||||||
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
||||||
|
context.setdefault("auth_enabled", get_settings().auth_enabled)
|
||||||
|
context.setdefault("data_freshness", snapshots_service.freshness_label())
|
||||||
html = _tpl_env.get_template(name).render(**context)
|
html = _tpl_env.get_template(name).render(**context)
|
||||||
response = HTMLResponse(html, status_code=status_code)
|
response = HTMLResponse(html, status_code=status_code)
|
||||||
if generated:
|
if generated:
|
||||||
@@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request):
|
|||||||
handled = "pr-merged:" + (",".join(results) if results else "none")
|
handled = "pr-merged:" + (",".join(results) if results else "none")
|
||||||
|
|
||||||
gitea.invalidate_cache()
|
gitea.invalidate_cache()
|
||||||
|
# Chantier B: a webhook means this repo just changed — refresh
|
||||||
|
# its snapshots right away instead of waiting for the interval.
|
||||||
|
if full_name and "/" in full_name:
|
||||||
|
_owner, _, _repo = full_name.partition("/")
|
||||||
|
asyncio.create_task(
|
||||||
|
snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo)
|
||||||
|
)
|
||||||
await hub.broadcast(
|
await hub.broadcast(
|
||||||
{"repo": full_name, "event": event, "action": action, "issue": number}
|
{"repo": full_name, "event": event, "action": action, "issue": number}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Connexion — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-wrap">
|
||||||
|
<section class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<span class="login-mark" aria-hidden="true">📋</span>
|
||||||
|
<h1>Kanbante</h1>
|
||||||
|
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
|
||||||
|
</div>
|
||||||
|
{% if error %}
|
||||||
|
<p class="login-error" role="alert">⚠️ {{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login" class="login-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<label>Nom d'utilisateur
|
||||||
|
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||||
|
</label>
|
||||||
|
<label>Mot de passe
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="login-submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+71
-4
@@ -1297,11 +1297,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
|||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
}
|
}
|
||||||
.side-collapse {
|
.side-collapse {
|
||||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
|
||||||
background: transparent; border: 1px solid var(--border); border-radius: 9px;
|
barre, à droite de la recherche (même gabarit qu'elle). */
|
||||||
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
|
flex-shrink: 0;
|
||||||
|
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||||
}
|
}
|
||||||
.side-collapse:hover { color: var(--text); }
|
.side-collapse:hover {
|
||||||
|
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||||
|
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||||
|
.side-logout:hover { color: var(--text); text-decoration: none; }
|
||||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||||
@@ -1541,3 +1552,59 @@ a.ms-title:hover { text-decoration: underline; }
|
|||||||
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
||||||
}
|
}
|
||||||
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
||||||
|
|
||||||
|
/* ── Écran de connexion (0.22.0) ────────────── */
|
||||||
|
.login-body {
|
||||||
|
min-height: 100dvh; margin: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
|
||||||
|
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
|
||||||
|
var(--bg, #0a0f16);
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
.login-wrap { width: 100%; max-width: 400px; }
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface, #10151f);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 34px 32px 26px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.login-brand { text-align: center; margin-bottom: 22px; }
|
||||||
|
.login-mark {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
|
||||||
|
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.login-brand h1 {
|
||||||
|
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
|
||||||
|
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
|
||||||
|
.login-error {
|
||||||
|
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
|
||||||
|
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
|
||||||
|
color: #f5a58c; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
|
||||||
|
.login-form input {
|
||||||
|
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
|
||||||
|
font: inherit; font-weight: 400;
|
||||||
|
}
|
||||||
|
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
|
||||||
|
.login-submit {
|
||||||
|
margin-top: 4px; padding: 12px 18px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
|
||||||
|
color: #fff; border: none; border-radius: 10px;
|
||||||
|
font: inherit; font-weight: 600; font-size: 1rem;
|
||||||
|
transition: filter 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.login-submit:hover { filter: brightness(1.12); }
|
||||||
|
.login-submit:active { transform: scale(0.98); }
|
||||||
|
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user