feat(0.24.0): utilisateurs, administration, espaces, paramètres, archives (P2-P4)
CI / test (push) Successful in 1m53s
CI / test (push) Successful in 1m53s
- Comptes en base (PBKDF2), rôles, désactivation qui tue la session, audit ; secours environnement pour l'opérateur ; liaison draco par utilisateur ; page /profile. - Espaces de travail : connexions, membres, sélection de projets ; board/notes/épingles/journal scopés par espace ; sélecteur d'espace, badge connexion. - Panneaux /admin (utilisateurs, connexions, espaces, paramètres — l'environnement amorce, le panneau gouverne). - /archives : archive locale réversible, archivage forge confirmé, purge de l'état local, export Markdown, file « à archiver ? » avec archivage en lot. - Correctif session : les comptes membres résolus par la base (la session n'est plus limitée à l'opérateur d'environnement). - 231 tests verts (12 nouveaux au palier 19).
This commit is contained in:
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## 0.24.0 — 2026-10-21
|
||||
|
||||
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
|
||||
|
||||
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
|
||||
selon les recommandations). Aucune configuration obligatoire :
|
||||
au premier démarrage, la connexion Gitea historique est créée
|
||||
depuis l'environnement, l'espace **Principal** la contient, et
|
||||
le compte d'environnement devient l'administrateur initial
|
||||
(mot de passe haché en base). Migration automatique des bases
|
||||
existantes (portées (1, 1) partout).
|
||||
|
||||
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
|
||||
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
|
||||
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
|
||||
base, jamais réaffiché (masque ••••). Registre de clients,
|
||||
instantanés par connexion. Webhooks par connexion
|
||||
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
|
||||
webhook historique.
|
||||
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
|
||||
admin/membre, activation/désactivation (une session d'un
|
||||
compte désactivé meurt aussitôt), journal d'audit admin.
|
||||
Connexion par la base ; les identifiants d'environnement
|
||||
restent un **secours** pour le compte opérateur. Liaison
|
||||
**draco par utilisateur** (le pont SSO lie et ouvre la
|
||||
session du bon compte ; sémantique mono-utilisateur 0.23
|
||||
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
|
||||
infos, mot de passe, déliaison draco, connexions personnelles.
|
||||
- **Espaces de travail (P3)** — l'unité de présentation :
|
||||
connexions attachées, membres (admin d'espace / membre /
|
||||
lecteur), sélection explicite de projets optionnelle.
|
||||
**Board, notes, épingles du jour, journal : scopés par
|
||||
espace** (le même projet peut être rangé différemment dans
|
||||
deux espaces). Sélecteur d'espace dans l'en-tête (visible
|
||||
dès qu'on a accès à plusieurs espaces), badge de connexion
|
||||
sur les projets quand l'espace en a plusieurs. Panneau
|
||||
**Paramètres** : l'environnement amorce, le panneau
|
||||
gouverne — surcharges en base pour les réglages courants
|
||||
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
|
||||
rituels, seuil d'archivage…), provenance affichée.
|
||||
- **Archives (P4)** — page **/archives** : archive **locale**
|
||||
(masque le projet dans l'espace, forge intacte, réversible),
|
||||
archivage **sur la forge** (confirmé, journalisé), purge de
|
||||
l'état local de l'espace, export du récapitulatif Markdown,
|
||||
file **« À archiver ? »** (inactifs depuis
|
||||
`archive_suggest_days`, 180 j par défaut) avec archivage
|
||||
local en lot. Les archives sortent du tableau de bord ;
|
||||
l'onglet Archivés y gagne un lien « Gérer ».
|
||||
|
||||
### Limites assumées de cette version
|
||||
|
||||
- Les rituels d'arrière-plan (briefing, digest, règles,
|
||||
serveur MCP) travaillent sur la portée historique
|
||||
(connexion principale, espace Principal).
|
||||
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
|
||||
chiffrement est générée une fois et gardée en base.
|
||||
|
||||
|
||||
## 0.23.3 — 2026-10-21
|
||||
|
||||
### Correctifs — barre commune v0.1.4 (retours de Bruno)
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+2085
-226
File diff suppressed because it is too large
Load Diff
@@ -28,9 +28,20 @@
|
||||
("outils", "Outils", [
|
||||
("search", "/search", "🔍", "Recherche"),
|
||||
("capture", "/capture", "⚡", "Capture"),
|
||||
("archives", "/archives", "🗄️", "Archives"),
|
||||
("wall", "/wall", "🖥️", "Wallboard"),
|
||||
]),
|
||||
] %}
|
||||
{% if is_admin %}
|
||||
{% set nav_groups = nav_groups + [
|
||||
("admin", "Administration", [
|
||||
("admin", "/admin", "🛠️", "Vue d'ensemble"),
|
||||
("admin_connections", "/admin/connections", "🔌", "Connexions"),
|
||||
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
|
||||
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
|
||||
]),
|
||||
] %}
|
||||
{% endif %}
|
||||
{% set active_group = namespace(key="principal") %}
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
{% for key, href, icon, label in links %}
|
||||
@@ -49,7 +60,7 @@
|
||||
localStorage.removeItem('kanbante-nav-closed');
|
||||
var openGroup = {{ active_group.key | tojson }};
|
||||
var stored = localStorage.getItem('kanbante-nav-open');
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
|
||||
if ({{ (active_nav != "") | tojson }}) {
|
||||
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||
} else if (stored === '') {
|
||||
@@ -71,6 +82,17 @@
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||
</div>
|
||||
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
|
||||
<form method="post" action="/workspace/switch" class="ws-switch">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<span class="ws-dot" style="background: {{ current_workspace.color }}" aria-hidden="true"></span>
|
||||
<select name="workspace_id" class="ws-select" onchange="this.form.submit()" title="Changer d'espace de travail" aria-label="Espace de travail">
|
||||
{% for ws in user_workspaces %}
|
||||
<option value="{{ ws.id }}"{% if ws.id == current_workspace.id %} selected{% endif %}>{{ ws.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
<div class="side-section" data-group="{{ gkey }}">
|
||||
@@ -95,6 +117,9 @@
|
||||
{% if data_freshness %}
|
||||
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||
{% endif %}
|
||||
{% if current_user %}
|
||||
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
|
||||
{% endif %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if auth_enabled %}
|
||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!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>Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🛠️ Administration</h1>
|
||||
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>👥 Utilisateurs ({{ users|length }})</h2>
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
|
||||
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
|
||||
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
|
||||
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
|
||||
<td>{{ u.last_login_at or '—' }}</td>
|
||||
<td class="admin-actions">
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
|
||||
<button type="submit" class="btn-mini">Réinitialiser</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>➕ Nouvel utilisateur</h3>
|
||||
<form method="post" action="/admin/users" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
|
||||
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
|
||||
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="member">Membre</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔭 Vue d'ensemble</h2>
|
||||
<p>
|
||||
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
|
||||
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
|
||||
<a href="/admin/settings">🎛️ Paramètres</a> ·
|
||||
<a href="/archives">🗄️ Archives</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Journal d'administration</h2>
|
||||
{% if audit %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in audit %}
|
||||
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!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>Connexions — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🔌 Connexions aux forges</h1>
|
||||
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for c in connections %}
|
||||
<section class="briefing-card">
|
||||
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
|
||||
<span class="muted">({{ c.slug }})</span>
|
||||
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
|
||||
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
|
||||
</h2>
|
||||
<p class="muted">
|
||||
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
|
||||
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
|
||||
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
|
||||
</p>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle connexion</h2>
|
||||
<form method="post" action="/admin/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
<select name="kind">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!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>Paramètres — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🎛️ Paramètres</h1>
|
||||
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<form method="post" action="/admin/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><code>{{ r.key }}</code></td>
|
||||
<td class="muted">{{ r.env_value }}</td>
|
||||
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
|
||||
<td><strong>{{ r.effective }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
|
||||
</form>
|
||||
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!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>Espaces — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧭 Espaces de travail</h1>
|
||||
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for d in details %}
|
||||
{% set ws = d.ws %}
|
||||
<section class="briefing-card">
|
||||
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
|
||||
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
|
||||
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Renommer</button>
|
||||
</form>
|
||||
|
||||
<h3>🔌 Connexions de l'espace</h3>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% for c in all_connections %}
|
||||
<label>
|
||||
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
|
||||
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<h3>👥 Membres</h3>
|
||||
{% if d.members %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in d.members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
|
||||
<td>{{ m.role }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Retirer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Utilisateur
|
||||
<select name="user_id">
|
||||
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="reader">Lecteur</option>
|
||||
<option value="member" selected>Membre</option>
|
||||
<option value="admin">Admin d'espace</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
|
||||
</form>
|
||||
|
||||
<h3>📦 Sélection de projets</h3>
|
||||
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
|
||||
{% endif %}{% endfor %}{% endfor %}</textarea>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
|
||||
</form>
|
||||
|
||||
{% if is_global_admin %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if is_global_admin %}
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvel espace</h2>
|
||||
<form method="post" action="/admin/workspaces" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
|
||||
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!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>Archives — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🗄️ Archives</h1>
|
||||
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% macro actions(row, kind) %}
|
||||
{% if can_write %}
|
||||
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">📥 Archiver ici</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'local' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">♻️ Restaurer</button>
|
||||
</form>
|
||||
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind != 'forge' and not row.missing %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'forge' %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/archives/export" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rowline(row, kind) %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
|
||||
<strong>{{ row.full_name }}</strong>
|
||||
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
|
||||
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, kind) }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
|
||||
{% if groups.suggested %}
|
||||
<form method="post" action="/archives/local-bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in groups.suggested %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
|
||||
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
|
||||
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
|
||||
{% if groups.local %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
|
||||
{% if groups.forge %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -55,6 +55,7 @@
|
||||
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
|
||||
<a href="/archives" class="seg-link" title="Gérer les archives (locales et forge)">🗄️ Gérer</a>
|
||||
</div>
|
||||
<div class="search-bar toolbar-search">
|
||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
@@ -89,6 +90,7 @@
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
<span class="org-tag">{{ repo.owner.login }}</span>
|
||||
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}🐙{% else %}🍵{% endif %} {{ repo._conn_slug }}</span>{% endif %}
|
||||
{% if repo.health_dot %}
|
||||
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<!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>Mon profil — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>👤 Mon profil</h1>
|
||||
<p class="page-sub">{{ profile_user.username }} · rôle : {{ profile_user.role }}</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
{% if profile_user.must_change_password %}<p class="chip-warn">⚠️ Ton mot de passe est temporaire : change-le ci-dessous.</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>Informations</h2>
|
||||
<form method="post" action="/profile" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom d'affichage <input type="text" name="display_name" value="{{ profile_user.display_name or '' }}"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="muted">Mes espaces :
|
||||
{% for m in memberships %}<span class="ws-dot" style="background: {{ m.color }}"></span> {{ m.name }} ({{ m.member_role }}){% if not loop.last %} · {% endif %}
|
||||
{% else %}aucun{% endfor %}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔑 Mot de passe</h2>
|
||||
{% if profile_user.id %}
|
||||
<form method="post" action="/profile/password" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Mot de passe actuel <input type="password" name="current_password" required autocomplete="current-password"></label>
|
||||
<label>Nouveau mot de passe <input type="password" name="new_password" required autocomplete="new-password"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Changer</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : le mot de passe se change dans la configuration du déploiement.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>✦ Liaison draco (SSO)</h2>
|
||||
{% if profile_user.draco_sub %}
|
||||
<p>Compte lié à l'identité draco <code>{{ profile_user.draco_sub }}</code> — la connexion via ObsiGate ouvre directement cette session.</p>
|
||||
<form method="post" action="/profile/draco/unlink" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Délier</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔌 Mes connexions personnelles</h2>
|
||||
<p class="muted">Visibles de toi seul. Pour les utiliser, ajoute-les à un de tes espaces (ou demande à un admin d'espace).</p>
|
||||
{% for c in my_connections %}
|
||||
<p>
|
||||
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ c.name }}</strong>
|
||||
<span class="muted">({{ c.slug }}) · {{ c.base_url }} · compte : {{ c.account_login or 'à tester' }} · jeton : {{ c.token_mask }}</span>
|
||||
<form method="post" action="/profile/connections/{{ c.id }}/test" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">🧪 Tester</button>
|
||||
</form>
|
||||
<form method="post" action="/profile/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette connexion personnelle ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</p>
|
||||
{% endfor %}
|
||||
<form method="post" action="/profile/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
<select name="kind">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nom <input type="text" name="name" required></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1636,3 +1636,28 @@ body.has-draco-bar .sidebar { top: 60px; }
|
||||
body.has-draco-bar { padding-top: 52px; }
|
||||
body.has-draco-bar .sidebar { top: 52px; }
|
||||
}
|
||||
|
||||
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
|
||||
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
|
||||
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
|
||||
body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
|
||||
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
|
||||
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
|
||||
background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
|
||||
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
|
||||
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
|
||||
color: inherit; cursor: pointer; }
|
||||
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
|
||||
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
|
||||
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
|
||||
|
||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||
async def fake_collect(only_repo):
|
||||
async def fake_collect(only_repo, *args, **kwargs):
|
||||
return [{
|
||||
"full_name": "Projets/demo",
|
||||
"counts": {c: 0 for c in
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
|
||||
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, forge_crypto, models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Migration des anciennes bases (P0) ────────────────────
|
||||
|
||||
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
|
||||
db = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE board_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE project_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name)
|
||||
);
|
||||
CREATE TABLE today_picks (
|
||||
pick_date TEXT NOT NULL,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (pick_date, repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE board_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
from_column TEXT,
|
||||
to_column TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'ui',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO board_state (repo_full_name, issue_number, column_name)
|
||||
VALUES ('Projets/demo', 1, 'todo');
|
||||
INSERT INTO project_notes (repo_full_name, note)
|
||||
VALUES ('Projets/demo', 'note héritée');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
|
||||
models.init_db()
|
||||
board = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in board["todo"]] == [1]
|
||||
assert models.get_notes("Projets/demo") == "note héritée"
|
||||
# La portée par défaut est (1, 1) et les nouvelles tables existent.
|
||||
assert models.list_connections() == []
|
||||
assert models.count_users() == 0
|
||||
|
||||
|
||||
# ── Chiffrement des jetons (P0) ───────────────────────────
|
||||
|
||||
def test_forge_token_encryption_roundtrip(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
|
||||
forge_crypto._fernet = None # forcer la relecture de la clé
|
||||
stored = forge_crypto.encrypt_token("ghp_secret")
|
||||
assert stored != "ghp_secret"
|
||||
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
|
||||
assert forge_crypto.mask_token(stored) == "••••••••"
|
||||
assert forge_crypto.encrypt_token("") == ""
|
||||
|
||||
|
||||
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _login(client, username="bruno", password="motdepasse"):
|
||||
client.get("/login")
|
||||
token = client.cookies.get("kanbante_csrf") or ""
|
||||
return client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"next": "/",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
|
||||
connections = models.list_connections()
|
||||
assert len(connections) == 1
|
||||
assert connections[0]["slug"] == "gitea"
|
||||
assert connections[0]["kind"] == "gitea"
|
||||
workspaces = models.list_workspaces()
|
||||
assert [ws["slug"] for ws in workspaces] == ["principal"]
|
||||
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
|
||||
connections[0]["id"]
|
||||
]
|
||||
users = models.list_users()
|
||||
assert len(users) == 1
|
||||
assert users[0]["username"] == "bruno"
|
||||
assert users[0]["role"] == "admin"
|
||||
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
|
||||
|
||||
|
||||
def test_db_login_and_password_change(auth_client):
|
||||
# Le mot de passe d'environnement a créé le hash en base :
|
||||
# la connexion passe par la base.
|
||||
r = _login(auth_client)
|
||||
assert r.status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "motdepasse",
|
||||
"new_password": "nouveau-secret",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# Le nouveau mot de passe fonctionne…
|
||||
assert _login(auth_client, password="nouveau-secret").status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# …et pour un compte membre, l'ancien ne passe plus (pas de
|
||||
# secours environnement pour les membres).
|
||||
token = csrf_from(auth_client)
|
||||
_login(auth_client, password="nouveau-secret")
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "alice",
|
||||
"password": "alice-pass",
|
||||
"role": "member",
|
||||
"display_name": "Alice",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
assert _login(auth_client, "alice", "alice-pass").status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "alice-pass",
|
||||
"new_password": "alice-nouveau",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
r = _login(auth_client, "alice", "alice-pass")
|
||||
assert r.status_code == 401
|
||||
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
|
||||
|
||||
|
||||
def test_member_cannot_open_admin(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "bob",
|
||||
"password": "bob-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "bob", "bob-pass")
|
||||
assert auth_client.get("/admin").status_code == 403
|
||||
assert auth_client.get("/admin/connections").status_code == 403
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
|
||||
|
||||
def test_disabled_user_session_dies(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "eve",
|
||||
"password": "eve-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
eve_id = models.get_user_by_username("eve")["id"]
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "eve", "eve-pass")
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
# L'admin désactive Eve pendant que sa session est ouverte.
|
||||
with TestClient(main.app) as admin_client:
|
||||
_login(admin_client)
|
||||
token = csrf_from(admin_client)
|
||||
admin_client.post(
|
||||
f"/admin/users/{eve_id}/toggle",
|
||||
data={"csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
r = auth_client.get(
|
||||
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
def test_admin_creates_github_connection_encrypted(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/connections",
|
||||
data={
|
||||
"kind": "github",
|
||||
"name": "GitHub perso",
|
||||
"base_url": "https://api.github.com",
|
||||
"token": "ghp_secret_token",
|
||||
"hook_secret": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
row = models.get_connection_by_slug("github-perso")
|
||||
assert row is not None
|
||||
assert row["kind"] == "github"
|
||||
assert row["token_encrypted"] != "ghp_secret_token"
|
||||
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
|
||||
page = auth_client.get("/admin/connections")
|
||||
assert "ghp_secret_token" not in page.text # jamais réaffiché
|
||||
assert "••••••••" in page.text
|
||||
|
||||
|
||||
# ── Paramètres en base (P3) ───────────────────────────────
|
||||
|
||||
def test_settings_panel_overrides_env(auth_client):
|
||||
from app.config import get_settings
|
||||
|
||||
_login(auth_client)
|
||||
assert get_settings().aging_days == 7
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "42", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 42
|
||||
# Champ vidé = retour à l'environnement.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 7
|
||||
|
||||
|
||||
# ── Espaces et portées (P3) ───────────────────────────────
|
||||
|
||||
def test_board_state_is_scoped_per_workspace():
|
||||
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
|
||||
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
|
||||
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
|
||||
board2 = models.get_board_state("Projets/demo", workspace_id=2)
|
||||
assert board2["review"] and not board2["todo"]
|
||||
|
||||
|
||||
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
|
||||
# Un espace vide (aucune connexion) ne montre aucun projet.
|
||||
empty_id = models.create_workspace("vide", "Vide", "#999999")
|
||||
client.cookies.set("kanbante_workspace", str(empty_id))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" not in page.text
|
||||
principal = models.list_workspaces()[0]
|
||||
client.cookies.set("kanbante_workspace", str(principal["id"]))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" in page.text
|
||||
|
||||
|
||||
# ── Archives (P4) ─────────────────────────────────────────
|
||||
|
||||
def test_local_archive_hides_and_restores(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
principal = models.list_workspaces()[0]
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
r = client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert "/board/Projets/demo" not in client.get("/").text
|
||||
archives = client.get("/archives")
|
||||
assert "Projets/demo" in archives.text
|
||||
assert models.list_local_archived(principal["id"])
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "0",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
|
||||
|
||||
def test_forge_archive_via_page(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/archives/forge",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
|
||||
assert repo["archived"] is True
|
||||
Reference in New Issue
Block a user