judu/ex-egopublic Fork
⑂ 2bcbeb3
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: multi-tenant — équipes, équipe active par appareil, invitationsUnverified

- teams/team_members/invitations + tasks.team_id (migration 0003,
  reprise de l'existant dans une équipe « Maison »)
- équipe active en cookie tt_team, validée en SQL à chaque requête,
  fallback plus ancienne appartenance (extracteur ActiveTeam)
- tâches, temps, stats et palette scopés par équipe ; historique
  des ex-membres conservé dans les stats
- invitations par username : erreurs explicites, refus silencieux
  ré-invitable, annulation, accepter = rejoindre + basculer, badge
  compteur sur l'onglet Réglages
- paramètres : sélecteur d'équipe, renommage, membres, créer/quitter
  (bloqué sur la dernière équipe)
- CLI : team add <nom>, user add --team obligatoire
- UI selon le design Claude Design à jour (pill d'équipe, cartes
  d'invitation, tags membres)
Julien Durillon committed 2026-08-07T10:19:57+02:00 Browse files
2bcbeb3 parent: c7e60c0
modified assets/style.css +64 -4
@@ -20,6 +20,11 @@
2020 --color-accent-300: #ffc6a5;
2121 --color-accent-600: #b2622d;
2222 --color-accent-700: #8c491a;
23+ --color-accent-900: #402310;
24+
25+ --color-accent-2-200: #e1eecc;
26+ --color-accent-2-900: #272e1b;
27+ --color-neutral-800: #474238;
2328
2429 --font-heading: "Caprasimo", system-ui, sans-serif;
2530 --font-heading-weight: 400;
@@ -112,6 +117,13 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
112117 .card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
113118 .card-body { margin: 0; font-size: 13px; opacity: 0.8; }
114119
120+/* — tags — */
121+.tag {
122+ display: inline-flex; align-items: center; font-size: 11px;
123+ letter-spacing: 0.02em; padding: 3px 10px; border-radius: 999px;
124+}
125+.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
126+
115127 /* ═══ Couche applicative time-time ═══ */
116128
117129 .viewport { min-height: 100dvh; display: flex; justify-content: center; background: var(--color-neutral-300); }
@@ -122,12 +134,15 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
122134 padding-top: env(safe-area-inset-top, 0px);
123135 }
124136
125-.app-header {
126- padding: var(--space-6) var(--space-4) var(--space-3);
127- display: flex; align-items: baseline; gap: var(--space-2);
137+.app-header { padding: var(--space-6) var(--space-4) var(--space-3); }
138+.app-topline { display: flex; align-items: center; gap: var(--space-2); margin-bottom: 4px; }
139+.team-pill {
140+ display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
141+ padding: 4px 11px 4px 8px; border-radius: 999px;
142+ background: var(--color-accent-2-200); color: var(--color-accent-2-900);
128143 }
144+.app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; }
129145 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
130-.app-header .who { margin-left: auto; font-size: 12px; opacity: .55; }
131146
132147 .app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; }
133148
@@ -149,6 +164,51 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
149164 font-size: 11px;
150165 }
151166 .tab[aria-current="page"] { color: var(--color-accent-700); font-weight: 700; }
167+.tab-ico { position: relative; display: inline-flex; }
168+.tab-badge {
169+ position: absolute; top: -4px; right: -6px;
170+ min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px;
171+ background: var(--color-accent); color: var(--color-bg);
172+ font-size: 10px; font-weight: 700;
173+ display: flex; align-items: center; justify-content: center;
174+ box-shadow: 0 0 0 2px var(--color-surface);
175+}
176+
177+/* — équipes (paramètres) — */
178+.team-choice {
179+ display: flex; align-items: center; gap: var(--space-3); width: 100%;
180+ padding: var(--space-3) var(--space-4); border-radius: 999px; cursor: pointer;
181+ background: var(--color-surface); border: 1px solid transparent;
182+ color: var(--color-text); font: inherit;
183+}
184+.team-choice:hover { border-color: var(--color-accent-2); }
185+.team-choice .team-dot {
186+ width: 12px; height: 12px; border-radius: 999px; flex: none;
187+ background: var(--color-neutral-300);
188+}
189+.team-choice.current {
190+ background: var(--color-accent-2-200); border-color: var(--color-accent-2);
191+ color: var(--color-accent-2-900);
192+}
193+.team-choice.current .team-dot { background: var(--color-accent-2); }
194+
195+.invite-card {
196+ background: var(--color-accent-200); border-radius: var(--radius-lg);
197+ padding: var(--space-4); margin-bottom: var(--space-2);
198+}
199+.invite-kicker {
200+ display: flex; align-items: center; gap: 8px; margin-bottom: var(--space-2);
201+ font-size: 11px; text-transform: uppercase; letter-spacing: .09em;
202+ color: var(--color-accent-900);
203+}
204+.invite-kicker .idot { width: 8px; height: 8px; border-radius: 999px; background: var(--color-accent); }
205+.invite-card p { margin: 0 0 var(--space-4); font-size: 15px; color: var(--color-accent-900); text-wrap: pretty; }
206+
207+.sent-row {
208+ display: flex; align-items: center; gap: var(--space-3);
209+ padding: var(--space-2) 0;
210+ border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
211+}
152212 .tab svg { display: block; }
153213
154214 /* — login — */
@@ -20,6 +20,11 @@
20 --color-accent-300: #ffc6a5;20 --color-accent-300: #ffc6a5;
21 --color-accent-600: #b2622d;21 --color-accent-600: #b2622d;
22 --color-accent-700: #8c491a;22 --color-accent-700: #8c491a;
23+ --color-accent-900: #402310;
24+
25+ --color-accent-2-200: #e1eecc;
26+ --color-accent-2-900: #272e1b;
27+ --color-neutral-800: #474238;
23 28
24 --font-heading: "Caprasimo", system-ui, sans-serif;29 --font-heading: "Caprasimo", system-ui, sans-serif;
25 --font-heading-weight: 400;30 --font-heading-weight: 400;
@@ -112,6 +117,13 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
112 .card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }117 .card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
113 .card-body { margin: 0; font-size: 13px; opacity: 0.8; }118 .card-body { margin: 0; font-size: 13px; opacity: 0.8; }
114 119
120+/* — tags — */
121+.tag {
122+ display: inline-flex; align-items: center; font-size: 11px;
123+ letter-spacing: 0.02em; padding: 3px 10px; border-radius: 999px;
124+}
125+.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
126+
115 /* ═══ Couche applicative time-time ═══ */127 /* ═══ Couche applicative time-time ═══ */
116 128
117 .viewport { min-height: 100dvh; display: flex; justify-content: center; background: var(--color-neutral-300); }129 .viewport { min-height: 100dvh; display: flex; justify-content: center; background: var(--color-neutral-300); }
@@ -122,12 +134,15 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
122 padding-top: env(safe-area-inset-top, 0px);134 padding-top: env(safe-area-inset-top, 0px);
123 }135 }
124 136
125-.app-header {137+.app-header { padding: var(--space-6) var(--space-4) var(--space-3); }
126- padding: var(--space-6) var(--space-4) var(--space-3);138+.app-topline { display: flex; align-items: center; gap: var(--space-2); margin-bottom: 4px; }
127- display: flex; align-items: baseline; gap: var(--space-2);139+.team-pill {
140+ display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
141+ padding: 4px 11px 4px 8px; border-radius: 999px;
142+ background: var(--color-accent-2-200); color: var(--color-accent-2-900);
128 }143 }
144+.app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; }
129 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }145 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
130-.app-header .who { margin-left: auto; font-size: 12px; opacity: .55; }
131 146
132 .app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; }147 .app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; }
133 148
@@ -149,6 +164,51 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
149 font-size: 11px;164 font-size: 11px;
150 }165 }
151 .tab[aria-current="page"] { color: var(--color-accent-700); font-weight: 700; }166 .tab[aria-current="page"] { color: var(--color-accent-700); font-weight: 700; }
167+.tab-ico { position: relative; display: inline-flex; }
168+.tab-badge {
169+ position: absolute; top: -4px; right: -6px;
170+ min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px;
171+ background: var(--color-accent); color: var(--color-bg);
172+ font-size: 10px; font-weight: 700;
173+ display: flex; align-items: center; justify-content: center;
174+ box-shadow: 0 0 0 2px var(--color-surface);
175+}
176+
177+/* — équipes (paramètres) — */
178+.team-choice {
179+ display: flex; align-items: center; gap: var(--space-3); width: 100%;
180+ padding: var(--space-3) var(--space-4); border-radius: 999px; cursor: pointer;
181+ background: var(--color-surface); border: 1px solid transparent;
182+ color: var(--color-text); font: inherit;
183+}
184+.team-choice:hover { border-color: var(--color-accent-2); }
185+.team-choice .team-dot {
186+ width: 12px; height: 12px; border-radius: 999px; flex: none;
187+ background: var(--color-neutral-300);
188+}
189+.team-choice.current {
190+ background: var(--color-accent-2-200); border-color: var(--color-accent-2);
191+ color: var(--color-accent-2-900);
192+}
193+.team-choice.current .team-dot { background: var(--color-accent-2); }
194+
195+.invite-card {
196+ background: var(--color-accent-200); border-radius: var(--radius-lg);
197+ padding: var(--space-4); margin-bottom: var(--space-2);
198+}
199+.invite-kicker {
200+ display: flex; align-items: center; gap: 8px; margin-bottom: var(--space-2);
201+ font-size: 11px; text-transform: uppercase; letter-spacing: .09em;
202+ color: var(--color-accent-900);
203+}
204+.invite-kicker .idot { width: 8px; height: 8px; border-radius: 999px; background: var(--color-accent); }
205+.invite-card p { margin: 0 0 var(--space-4); font-size: 15px; color: var(--color-accent-900); text-wrap: pretty; }
206+
207+.sent-row {
208+ display: flex; align-items: center; gap: var(--space-3);
209+ padding: var(--space-2) 0;
210+ border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
211+}
152 .tab svg { display: block; }212 .tab svg { display: block; }
153 213
154 /* — login — */214 /* — login — */
added migrations/0003_teams.sql +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+-- Équipes : appartenances multiples, équipe active choisie côté client (cookie).
2+CREATE TABLE teams (
3+ id UUID PRIMARY KEY,
4+ name TEXT NOT NULL UNIQUE,
5+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
6+);
7+
8+CREATE TABLE team_members (
9+ team_id UUID NOT NULL REFERENCES teams(id),
10+ user_id UUID NOT NULL REFERENCES users(id),
11+ joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12+ PRIMARY KEY (team_id, user_id)
13+);
14+
15+CREATE TABLE invitations (
16+ id UUID PRIMARY KEY,
17+ team_id UUID NOT NULL REFERENCES teams(id),
18+ invitee_id UUID NOT NULL REFERENCES users(id),
19+ inviter_id UUID NOT NULL REFERENCES users(id),
20+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
21+ -- Une seule invitation en attente par couple (équipe, invité).
22+ UNIQUE (team_id, invitee_id)
23+);
24+
25+ALTER TABLE tasks ADD COLUMN team_id UUID REFERENCES teams(id);
26+
27+-- Reprise de l'existant : les utilisateurs et tâches d'avant les équipes
28+-- rejoignent une équipe « Maison ». Une base vierge n'en crée pas.
29+INSERT INTO teams (id, name)
30+SELECT gen_random_uuid(), 'Maison'
31+WHERE EXISTS (SELECT 1 FROM users) OR EXISTS (SELECT 1 FROM tasks);
32+
33+INSERT INTO team_members (team_id, user_id)
34+SELECT t.id, u.id FROM teams t CROSS JOIN users u WHERE t.name = 'Maison';
35+
36+UPDATE tasks SET team_id = (SELECT id FROM teams WHERE name = 'Maison');
37+
38+ALTER TABLE tasks ALTER COLUMN team_id SET NOT NULL;
39+
40+-- L'unicité des noms de tâches devient par équipe.
41+ALTER TABLE tasks DROP CONSTRAINT tasks_name_key;
42+ALTER TABLE tasks ADD CONSTRAINT tasks_team_name_key UNIQUE (team_id, name);
new file mode 100644
@@ -0,0 +1,42 @@
1+-- Équipes : appartenances multiples, équipe active choisie côté client (cookie).
2+CREATE TABLE teams (
3+ id UUID PRIMARY KEY,
4+ name TEXT NOT NULL UNIQUE,
5+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
6+);
7+
8+CREATE TABLE team_members (
9+ team_id UUID NOT NULL REFERENCES teams(id),
10+ user_id UUID NOT NULL REFERENCES users(id),
11+ joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12+ PRIMARY KEY (team_id, user_id)
13+);
14+
15+CREATE TABLE invitations (
16+ id UUID PRIMARY KEY,
17+ team_id UUID NOT NULL REFERENCES teams(id),
18+ invitee_id UUID NOT NULL REFERENCES users(id),
19+ inviter_id UUID NOT NULL REFERENCES users(id),
20+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
21+ -- Une seule invitation en attente par couple (équipe, invité).
22+ UNIQUE (team_id, invitee_id)
23+);
24+
25+ALTER TABLE tasks ADD COLUMN team_id UUID REFERENCES teams(id);
26+
27+-- Reprise de l'existant : les utilisateurs et tâches d'avant les équipes
28+-- rejoignent une équipe « Maison ». Une base vierge n'en crée pas.
29+INSERT INTO teams (id, name)
30+SELECT gen_random_uuid(), 'Maison'
31+WHERE EXISTS (SELECT 1 FROM users) OR EXISTS (SELECT 1 FROM tasks);
32+
33+INSERT INTO team_members (team_id, user_id)
34+SELECT t.id, u.id FROM teams t CROSS JOIN users u WHERE t.name = 'Maison';
35+
36+UPDATE tasks SET team_id = (SELECT id FROM teams WHERE name = 'Maison');
37+
38+ALTER TABLE tasks ALTER COLUMN team_id SET NOT NULL;
39+
40+-- L'unicité des noms de tâches devient par équipe.
41+ALTER TABLE tasks DROP CONSTRAINT tasks_name_key;
42+ALTER TABLE tasks ADD CONSTRAINT tasks_team_name_key UNIQUE (team_id, name);
modified src/auth/extractor.rs +1 -1
@@ -40,7 +40,7 @@ impl IntoResponse for AuthRedirect {
4040 }
4141 }
4242
43-fn authenticate(parts: &mut Parts, state: &AppState) -> Option<AuthUser> {
43+pub(crate) fn authenticate(parts: &mut Parts, state: &AppState) -> Option<AuthUser> {
4444 let jar = CookieJar::from_headers(&parts.headers);
4545 let cookie = jar.get(SESSION_COOKIE)?;
4646 let verified = token::verify(cookie.value(), state.public).ok()?;
@@ -40,7 +40,7 @@ impl IntoResponse for AuthRedirect {
40 }40 }
41 }41 }
42 42
43-fn authenticate(parts: &mut Parts, state: &AppState) -> Option<AuthUser> {43+pub(crate) fn authenticate(parts: &mut Parts, state: &AppState) -> Option<AuthUser> {
44 let jar = CookieJar::from_headers(&parts.headers);44 let jar = CookieJar::from_headers(&parts.headers);
45 let cookie = jar.get(SESSION_COOKIE)?;45 let cookie = jar.get(SESSION_COOKIE)?;
46 let verified = token::verify(cookie.value(), state.public).ok()?;46 let verified = token::verify(cookie.value(), state.public).ok()?;
modified src/charts.rs +1 -0
@@ -199,6 +199,7 @@ mod tests {
199199 fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow {
200200 StatRow {
201201 user_id: Uuid::from_u128(uid),
202+ username: String::new(),
202203 task_id: Uuid::from_u128(tid),
203204 task_name: task.into(),
204205 total,
@@ -199,6 +199,7 @@ mod tests {
199 fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow {199 fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow {
200 StatRow {200 StatRow {
201 user_id: Uuid::from_u128(uid),201 user_id: Uuid::from_u128(uid),
202+ username: String::new(),
202 task_id: Uuid::from_u128(tid),203 task_id: Uuid::from_u128(tid),
203 task_name: task.into(),204 task_name: task.into(),
204 total,205 total,
modified src/cli.rs +58 -15
@@ -1,10 +1,12 @@
11 use anyhow::{Context, Result, bail};
22 use biscuit_auth::KeyPair;
3+use sqlx::PgPool;
34 use sqlx::postgres::PgPoolOptions;
5+use uuid::Uuid;
46
57 use crate::auth::password;
68 use crate::config;
7-use crate::db::users;
9+use crate::db::teams;
810
911 /// Génère un keypair racine et l'affiche (à faire une fois, en local).
1012 pub fn keygen() {
@@ -13,8 +15,38 @@ pub fn keygen() {
1315 println!("# clé publique (information) : {}", kp.public().to_bytes_hex());
1416 }
1517
16-/// `time-time user add <username>` — création de compte côté serveur.
17-pub async fn user_add(username: &str, password_arg: Option<String>) -> Result<()> {
18+async fn connect() -> Result<PgPool> {
19+ let pool = PgPoolOptions::new()
20+ .max_connections(1)
21+ .connect_with(config::pg_options()?)
22+ .await
23+ .context("connexion à la base")?;
24+ sqlx::migrate!().run(&pool).await?;
25+ Ok(pool)
26+}
27+
28+/// `time-time team add <nom>` — création d'équipe côté serveur.
29+pub async fn team_add(name: &str) -> Result<()> {
30+ let name = name.trim();
31+ if name.is_empty() {
32+ bail!("le nom d'équipe ne peut pas être vide");
33+ }
34+ let pool = connect().await?;
35+ match teams::create(&pool, name).await {
36+ Ok(id) => {
37+ println!("Équipe « {name} » créée ({id}).");
38+ Ok(())
39+ }
40+ Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
41+ bail!("l'équipe « {name} » existe déjà")
42+ }
43+ Err(e) => Err(e.into()),
44+ }
45+}
46+
47+/// `time-time user add <username> --team <nom>` — création de compte côté
48+/// serveur, toujours rattaché à une équipe (invariant : jamais sans équipe).
49+pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) -> Result<()> {
1850 let username = username.trim();
1951 if username.is_empty() {
2052 bail!("le nom d'utilisateur ne peut pas être vide");
@@ -35,22 +67,33 @@ pub async fn user_add(username: &str, password_arg: Option<String>) -> Result<()
3567 bail!("le mot de passe doit faire au moins 8 caractères");
3668 }
3769
38- let pool = PgPoolOptions::new()
39- .max_connections(1)
40- .connect_with(config::pg_options()?)
41- .await
42- .context("connexion à la base")?;
43- sqlx::migrate!().run(&pool).await?;
70+ let pool = connect().await?;
71+ let team_id = teams::find_by_name(&pool, team.trim())
72+ .await?
73+ .with_context(|| format!("l'équipe « {} » n'existe pas (time-time team add)", team.trim()))?;
4474
4575 let hash = password::hash(&password)?;
46- match users::create(&pool, username, &hash).await {
47- Ok(id) => {
48- println!("Utilisateur « {username} » créé ({id}).");
49- Ok(())
50- }
76+ let id = Uuid::now_v7();
77+ let mut tx = pool.begin().await?;
78+ let created = sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)")
79+ .bind(id)
80+ .bind(username)
81+ .bind(&hash)
82+ .execute(&mut *tx)
83+ .await;
84+ match created {
85+ Ok(_) => {}
5186 Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
5287 bail!("l'utilisateur « {username} » existe déjà")
5388 }
54- Err(e) => Err(e.into()),
89+ Err(e) => return Err(e.into()),
5590 }
91+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
92+ .bind(team_id)
93+ .bind(id)
94+ .execute(&mut *tx)
95+ .await?;
96+ tx.commit().await?;
97+ println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim());
98+ Ok(())
5699 }
@@ -1,10 +1,12 @@
1 use anyhow::{Context, Result, bail};1 use anyhow::{Context, Result, bail};
2 use biscuit_auth::KeyPair;2 use biscuit_auth::KeyPair;
3+use sqlx::PgPool;
3 use sqlx::postgres::PgPoolOptions;4 use sqlx::postgres::PgPoolOptions;
5+use uuid::Uuid;
4 6
5 use crate::auth::password;7 use crate::auth::password;
6 use crate::config;8 use crate::config;
7-use crate::db::users;9+use crate::db::teams;
8 10
9 /// Génère un keypair racine et l'affiche (à faire une fois, en local).11 /// Génère un keypair racine et l'affiche (à faire une fois, en local).
10 pub fn keygen() {12 pub fn keygen() {
@@ -13,8 +15,38 @@ pub fn keygen() {
13 println!("# clé publique (information) : {}", kp.public().to_bytes_hex());15 println!("# clé publique (information) : {}", kp.public().to_bytes_hex());
14 }16 }
15 17
16-/// `time-time user add <username>` — création de compte côté serveur.18+async fn connect() -> Result<PgPool> {
17-pub async fn user_add(username: &str, password_arg: Option<String>) -> Result<()> {19+ let pool = PgPoolOptions::new()
20+ .max_connections(1)
21+ .connect_with(config::pg_options()?)
22+ .await
23+ .context("connexion à la base")?;
24+ sqlx::migrate!().run(&pool).await?;
25+ Ok(pool)
26+}
27+
28+/// `time-time team add <nom>` — création d'équipe côté serveur.
29+pub async fn team_add(name: &str) -> Result<()> {
30+ let name = name.trim();
31+ if name.is_empty() {
32+ bail!("le nom d'équipe ne peut pas être vide");
33+ }
34+ let pool = connect().await?;
35+ match teams::create(&pool, name).await {
36+ Ok(id) => {
37+ println!("Équipe « {name} » créée ({id}).");
38+ Ok(())
39+ }
40+ Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
41+ bail!("l'équipe « {name} » existe déjà")
42+ }
43+ Err(e) => Err(e.into()),
44+ }
45+}
46+
47+/// `time-time user add <username> --team <nom>` — création de compte côté
48+/// serveur, toujours rattaché à une équipe (invariant : jamais sans équipe).
49+pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) -> Result<()> {
18 let username = username.trim();50 let username = username.trim();
19 if username.is_empty() {51 if username.is_empty() {
20 bail!("le nom d'utilisateur ne peut pas être vide");52 bail!("le nom d'utilisateur ne peut pas être vide");
@@ -35,22 +67,33 @@ pub async fn user_add(username: &str, password_arg: Option<String>) -> Result<()
35 bail!("le mot de passe doit faire au moins 8 caractères");67 bail!("le mot de passe doit faire au moins 8 caractères");
36 }68 }
37 69
38- let pool = PgPoolOptions::new()70+ let pool = connect().await?;
39- .max_connections(1)71+ let team_id = teams::find_by_name(&pool, team.trim())
40- .connect_with(config::pg_options()?)72+ .await?
41- .await73+ .with_context(|| format!("l'équipe « {} » n'existe pas (time-time team add)", team.trim()))?;
42- .context("connexion à la base")?;
43- sqlx::migrate!().run(&pool).await?;
44 74
45 let hash = password::hash(&password)?;75 let hash = password::hash(&password)?;
46- match users::create(&pool, username, &hash).await {76+ let id = Uuid::now_v7();
47- Ok(id) => {77+ let mut tx = pool.begin().await?;
48- println!("Utilisateur « {username} » créé ({id}).");78+ let created = sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)")
49- Ok(())79+ .bind(id)
50- }80+ .bind(username)
81+ .bind(&hash)
82+ .execute(&mut *tx)
83+ .await;
84+ match created {
85+ Ok(_) => {}
51 Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {86 Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
52 bail!("l'utilisateur « {username} » existe déjà")87 bail!("l'utilisateur « {username} » existe déjà")
53 }88 }
54- Err(e) => Err(e.into()),89+ Err(e) => return Err(e.into()),
55 }90 }
91+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
92+ .bind(team_id)
93+ .bind(id)
94+ .execute(&mut *tx)
95+ .await?;
96+ tx.commit().await?;
97+ println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim());
98+ Ok(())
56 }99 }
modified src/db/entries.rs +55 -25
@@ -11,44 +11,50 @@ pub struct EntryRow {
1111 pub entry_date: NaiveDate,
1212 }
1313
14+/// L'insert n'aboutit que si la tâche appartient à l'équipe active :
15+/// 0 ligne = tâche d'une autre équipe (ou inexistante).
1416 pub async fn insert(
1517 pool: &PgPool,
1618 user_id: Uuid,
19+ team_id: Uuid,
1720 task_id: Uuid,
1821 minutes: i32,
1922 entry_date: NaiveDate,
20-) -> sqlx::Result<Uuid> {
21- let id = Uuid::now_v7();
22- sqlx::query(
23+) -> sqlx::Result<u64> {
24+ let res = sqlx::query(
2325 "INSERT INTO time_entries (id, user_id, task_id, minutes, entry_date)
24- VALUES ($1, $2, $3, $4, $5)",
26+ SELECT $1, $2, $3, $4, $5
27+ WHERE EXISTS (SELECT 1 FROM tasks WHERE id = $3 AND team_id = $6)",
2528 )
26- .bind(id)
29+ .bind(Uuid::now_v7())
2730 .bind(user_id)
2831 .bind(task_id)
2932 .bind(minutes)
3033 .bind(entry_date)
34+ .bind(team_id)
3135 .execute(pool)
3236 .await?;
33- Ok(id)
37+ Ok(res.rows_affected())
3438 }
3539
36-/// MES temps, du plus récent au plus ancien, les `limit` premiers
37-/// (« Voir plus » recharge la page avec une limite plus grande).
40+/// MES temps dans l'équipe active, du plus récent au plus ancien, les `limit`
41+/// premiers (« Voir plus » recharge la page avec une limite plus grande).
3842 /// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes.
3943 pub async fn list_first(
4044 pool: &PgPool,
4145 user_id: Uuid,
46+ team_id: Uuid,
4247 limit: i64,
4348 ) -> sqlx::Result<(Vec<EntryRow>, bool)> {
4449 let mut rows: Vec<EntryRow> = sqlx::query_as(
4550 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date
4651 FROM time_entries e JOIN tasks t ON t.id = e.task_id
47- WHERE e.user_id = $1
52+ WHERE e.user_id = $1 AND t.team_id = $2
4853 ORDER BY e.entry_date DESC, e.id DESC
49- LIMIT $2",
54+ LIMIT $3",
5055 )
5156 .bind(user_id)
57+ .bind(team_id)
5258 .bind(limit + 1)
5359 .fetch_all(pool)
5460 .await?;
@@ -57,66 +63,90 @@ pub async fn list_first(
5763 Ok((rows, has_more))
5864 }
5965
60-/// Mes minutes de la semaine en cours, par tâche (étape 1 de l'ajout).
66+/// Mes minutes de la semaine en cours dans l'équipe, par tâche (étape 1).
6167 pub async fn week_totals_for_user(
6268 pool: &PgPool,
6369 user_id: Uuid,
70+ team_id: Uuid,
6471 from: NaiveDate,
6572 to: NaiveDate,
6673 ) -> sqlx::Result<Vec<(Uuid, i64)>> {
6774 sqlx::query_as(
68- "SELECT task_id, SUM(minutes)::BIGINT FROM time_entries
69- WHERE user_id = $1 AND entry_date >= $2 AND entry_date < $3
70- GROUP BY task_id",
75+ "SELECT e.task_id, SUM(e.minutes)::BIGINT
76+ FROM time_entries e JOIN tasks t ON t.id = e.task_id
77+ WHERE e.user_id = $1 AND t.team_id = $2 AND e.entry_date >= $3 AND e.entry_date < $4
78+ GROUP BY e.task_id",
7179 )
7280 .bind(user_id)
81+ .bind(team_id)
7382 .bind(from)
7483 .bind(to)
7584 .fetch_all(pool)
7685 .await
7786 }
7887
79-/// Une entrée m'appartenant (édition) — None si absente ou pas à moi.
80-pub async fn get_owned(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<Option<EntryRow>> {
88+/// Une entrée m'appartenant, dans l'équipe active (édition) — None sinon.
89+pub async fn get_owned(
90+ pool: &PgPool,
91+ id: Uuid,
92+ user_id: Uuid,
93+ team_id: Uuid,
94+) -> sqlx::Result<Option<EntryRow>> {
8195 sqlx::query_as(
8296 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date
8397 FROM time_entries e JOIN tasks t ON t.id = e.task_id
84- WHERE e.id = $1 AND e.user_id = $2",
98+ WHERE e.id = $1 AND e.user_id = $2 AND t.team_id = $3",
8599 )
86100 .bind(id)
87101 .bind(user_id)
102+ .bind(team_id)
88103 .fetch_optional(pool)
89104 .await
90105 }
91106
92-/// Ownership vérifié en SQL : 0 ligne touchée = pas à moi / inexistant.
107+/// Ownership + équipe vérifiés en SQL : l'entrée doit être à moi et dans
108+/// l'équipe active, la nouvelle tâche aussi. 0 ligne touchée = refus.
93109 pub async fn update_owned(
94110 pool: &PgPool,
95111 id: Uuid,
96112 user_id: Uuid,
113+ team_id: Uuid,
97114 task_id: Uuid,
98115 minutes: i32,
99116 entry_date: NaiveDate,
100117 ) -> sqlx::Result<u64> {
101118 let res = sqlx::query(
102119 "UPDATE time_entries SET task_id = $1, minutes = $2, entry_date = $3
103- WHERE id = $4 AND user_id = $5",
120+ WHERE id = $4 AND user_id = $5
121+ AND task_id IN (SELECT id FROM tasks WHERE team_id = $6)
122+ AND $1 IN (SELECT id FROM tasks WHERE team_id = $6)",
104123 )
105124 .bind(task_id)
106125 .bind(minutes)
107126 .bind(entry_date)
108127 .bind(id)
109128 .bind(user_id)
129+ .bind(team_id)
110130 .execute(pool)
111131 .await?;
112132 Ok(res.rows_affected())
113133 }
114134
115-pub async fn delete_owned(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
116- let res = sqlx::query("DELETE FROM time_entries WHERE id = $1 AND user_id = $2")
117- .bind(id)
118- .bind(user_id)
119- .execute(pool)
120- .await?;
135+pub async fn delete_owned(
136+ pool: &PgPool,
137+ id: Uuid,
138+ user_id: Uuid,
139+ team_id: Uuid,
140+) -> sqlx::Result<u64> {
141+ let res = sqlx::query(
142+ "DELETE FROM time_entries
143+ WHERE id = $1 AND user_id = $2
144+ AND task_id IN (SELECT id FROM tasks WHERE team_id = $3)",
145+ )
146+ .bind(id)
147+ .bind(user_id)
148+ .bind(team_id)
149+ .execute(pool)
150+ .await?;
121151 Ok(res.rows_affected())
122152 }
@@ -11,44 +11,50 @@ pub struct EntryRow {
11 pub entry_date: NaiveDate,11 pub entry_date: NaiveDate,
12 }12 }
13 13
14+/// L'insert n'aboutit que si la tâche appartient à l'équipe active :
15+/// 0 ligne = tâche d'une autre équipe (ou inexistante).
14 pub async fn insert(16 pub async fn insert(
15 pool: &PgPool,17 pool: &PgPool,
16 user_id: Uuid,18 user_id: Uuid,
19+ team_id: Uuid,
17 task_id: Uuid,20 task_id: Uuid,
18 minutes: i32,21 minutes: i32,
19 entry_date: NaiveDate,22 entry_date: NaiveDate,
20-) -> sqlx::Result<Uuid> {23+) -> sqlx::Result<u64> {
21- let id = Uuid::now_v7();24+ let res = sqlx::query(
22- sqlx::query(
23 "INSERT INTO time_entries (id, user_id, task_id, minutes, entry_date)25 "INSERT INTO time_entries (id, user_id, task_id, minutes, entry_date)
24- VALUES ($1, $2, $3, $4, $5)",26+ SELECT $1, $2, $3, $4, $5
27+ WHERE EXISTS (SELECT 1 FROM tasks WHERE id = $3 AND team_id = $6)",
25 )28 )
26- .bind(id)29+ .bind(Uuid::now_v7())
27 .bind(user_id)30 .bind(user_id)
28 .bind(task_id)31 .bind(task_id)
29 .bind(minutes)32 .bind(minutes)
30 .bind(entry_date)33 .bind(entry_date)
34+ .bind(team_id)
31 .execute(pool)35 .execute(pool)
32 .await?;36 .await?;
33- Ok(id)37+ Ok(res.rows_affected())
34 }38 }
35 39
36-/// MES temps, du plus récent au plus ancien, les `limit` premiers40+/// MES temps dans l'équipe active, du plus récent au plus ancien, les `limit`
37-/// (« Voir plus » recharge la page avec une limite plus grande).41+/// premiers (« Voir plus » recharge la page avec une limite plus grande).
38 /// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes.42 /// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes.
39 pub async fn list_first(43 pub async fn list_first(
40 pool: &PgPool,44 pool: &PgPool,
41 user_id: Uuid,45 user_id: Uuid,
46+ team_id: Uuid,
42 limit: i64,47 limit: i64,
43 ) -> sqlx::Result<(Vec<EntryRow>, bool)> {48 ) -> sqlx::Result<(Vec<EntryRow>, bool)> {
44 let mut rows: Vec<EntryRow> = sqlx::query_as(49 let mut rows: Vec<EntryRow> = sqlx::query_as(
45 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date50 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date
46 FROM time_entries e JOIN tasks t ON t.id = e.task_id51 FROM time_entries e JOIN tasks t ON t.id = e.task_id
47- WHERE e.user_id = $152+ WHERE e.user_id = $1 AND t.team_id = $2
48 ORDER BY e.entry_date DESC, e.id DESC53 ORDER BY e.entry_date DESC, e.id DESC
49- LIMIT $2",54+ LIMIT $3",
50 )55 )
51 .bind(user_id)56 .bind(user_id)
57+ .bind(team_id)
52 .bind(limit + 1)58 .bind(limit + 1)
53 .fetch_all(pool)59 .fetch_all(pool)
54 .await?;60 .await?;
@@ -57,66 +63,90 @@ pub async fn list_first(
57 Ok((rows, has_more))63 Ok((rows, has_more))
58 }64 }
59 65
60-/// Mes minutes de la semaine en cours, par tâche (étape 1 de l'ajout).66+/// Mes minutes de la semaine en cours dans l'équipe, par tâche (étape 1).
61 pub async fn week_totals_for_user(67 pub async fn week_totals_for_user(
62 pool: &PgPool,68 pool: &PgPool,
63 user_id: Uuid,69 user_id: Uuid,
70+ team_id: Uuid,
64 from: NaiveDate,71 from: NaiveDate,
65 to: NaiveDate,72 to: NaiveDate,
66 ) -> sqlx::Result<Vec<(Uuid, i64)>> {73 ) -> sqlx::Result<Vec<(Uuid, i64)>> {
67 sqlx::query_as(74 sqlx::query_as(
68- "SELECT task_id, SUM(minutes)::BIGINT FROM time_entries75+ "SELECT e.task_id, SUM(e.minutes)::BIGINT
69- WHERE user_id = $1 AND entry_date >= $2 AND entry_date < $376+ FROM time_entries e JOIN tasks t ON t.id = e.task_id
70- GROUP BY task_id",77+ WHERE e.user_id = $1 AND t.team_id = $2 AND e.entry_date >= $3 AND e.entry_date < $4
78+ GROUP BY e.task_id",
71 )79 )
72 .bind(user_id)80 .bind(user_id)
81+ .bind(team_id)
73 .bind(from)82 .bind(from)
74 .bind(to)83 .bind(to)
75 .fetch_all(pool)84 .fetch_all(pool)
76 .await85 .await
77 }86 }
78 87
79-/// Une entrée m'appartenant (édition) — None si absente ou pas à moi.88+/// Une entrée m'appartenant, dans l'équipe active (édition) — None sinon.
80-pub async fn get_owned(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<Option<EntryRow>> {89+pub async fn get_owned(
90+ pool: &PgPool,
91+ id: Uuid,
92+ user_id: Uuid,
93+ team_id: Uuid,
94+) -> sqlx::Result<Option<EntryRow>> {
81 sqlx::query_as(95 sqlx::query_as(
82 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date96 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date
83 FROM time_entries e JOIN tasks t ON t.id = e.task_id97 FROM time_entries e JOIN tasks t ON t.id = e.task_id
84- WHERE e.id = $1 AND e.user_id = $2",98+ WHERE e.id = $1 AND e.user_id = $2 AND t.team_id = $3",
85 )99 )
86 .bind(id)100 .bind(id)
87 .bind(user_id)101 .bind(user_id)
102+ .bind(team_id)
88 .fetch_optional(pool)103 .fetch_optional(pool)
89 .await104 .await
90 }105 }
91 106
92-/// Ownership vérifié en SQL : 0 ligne touchée = pas à moi / inexistant.107+/// Ownership + équipe vérifiés en SQL : l'entrée doit être à moi et dans
108+/// l'équipe active, la nouvelle tâche aussi. 0 ligne touchée = refus.
93 pub async fn update_owned(109 pub async fn update_owned(
94 pool: &PgPool,110 pool: &PgPool,
95 id: Uuid,111 id: Uuid,
96 user_id: Uuid,112 user_id: Uuid,
113+ team_id: Uuid,
97 task_id: Uuid,114 task_id: Uuid,
98 minutes: i32,115 minutes: i32,
99 entry_date: NaiveDate,116 entry_date: NaiveDate,
100 ) -> sqlx::Result<u64> {117 ) -> sqlx::Result<u64> {
101 let res = sqlx::query(118 let res = sqlx::query(
102 "UPDATE time_entries SET task_id = $1, minutes = $2, entry_date = $3119 "UPDATE time_entries SET task_id = $1, minutes = $2, entry_date = $3
103- WHERE id = $4 AND user_id = $5",120+ WHERE id = $4 AND user_id = $5
121+ AND task_id IN (SELECT id FROM tasks WHERE team_id = $6)
122+ AND $1 IN (SELECT id FROM tasks WHERE team_id = $6)",
104 )123 )
105 .bind(task_id)124 .bind(task_id)
106 .bind(minutes)125 .bind(minutes)
107 .bind(entry_date)126 .bind(entry_date)
108 .bind(id)127 .bind(id)
109 .bind(user_id)128 .bind(user_id)
129+ .bind(team_id)
110 .execute(pool)130 .execute(pool)
111 .await?;131 .await?;
112 Ok(res.rows_affected())132 Ok(res.rows_affected())
113 }133 }
114 134
115-pub async fn delete_owned(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {135+pub async fn delete_owned(
116- let res = sqlx::query("DELETE FROM time_entries WHERE id = $1 AND user_id = $2")136+ pool: &PgPool,
117- .bind(id)137+ id: Uuid,
118- .bind(user_id)138+ user_id: Uuid,
119- .execute(pool)139+ team_id: Uuid,
120- .await?;140+) -> sqlx::Result<u64> {
141+ let res = sqlx::query(
142+ "DELETE FROM time_entries
143+ WHERE id = $1 AND user_id = $2
144+ AND task_id IN (SELECT id FROM tasks WHERE team_id = $3)",
145+ )
146+ .bind(id)
147+ .bind(user_id)
148+ .bind(team_id)
149+ .execute(pool)
150+ .await?;
121 Ok(res.rows_affected())151 Ok(res.rows_affected())
122 }152 }
added src/db/invitations.rs +127 -0
new file mode 100644
@@ -0,0 +1,127 @@
1+use sqlx::PgPool;
2+use uuid::Uuid;
3+
4+/// Invitation reçue (bloc en tête des paramètres).
5+#[derive(sqlx::FromRow)]
6+pub struct InviteRow {
7+ pub id: Uuid,
8+ pub team_name: String,
9+ pub inviter: String,
10+}
11+
12+/// Invitation émise par l'équipe active (liste « en attente »).
13+#[derive(sqlx::FromRow)]
14+pub struct SentRow {
15+ pub id: Uuid,
16+ pub invitee: String,
17+ pub inviter: String,
18+}
19+
20+/// false = une invitation était déjà en attente pour ce couple.
21+pub async fn create(
22+ pool: &PgPool,
23+ team_id: Uuid,
24+ invitee_id: Uuid,
25+ inviter_id: Uuid,
26+) -> sqlx::Result<bool> {
27+ let res = sqlx::query(
28+ "INSERT INTO invitations (id, team_id, invitee_id, inviter_id)
29+ VALUES ($1, $2, $3, $4)
30+ ON CONFLICT (team_id, invitee_id) DO NOTHING",
31+ )
32+ .bind(Uuid::now_v7())
33+ .bind(team_id)
34+ .bind(invitee_id)
35+ .bind(inviter_id)
36+ .execute(pool)
37+ .await?;
38+ Ok(res.rows_affected() > 0)
39+}
40+
41+pub async fn list_for(pool: &PgPool, invitee_id: Uuid) -> sqlx::Result<Vec<InviteRow>> {
42+ sqlx::query_as(
43+ "SELECT i.id, t.name AS team_name, u.username AS inviter
44+ FROM invitations i
45+ JOIN teams t ON t.id = i.team_id
46+ JOIN users u ON u.id = i.inviter_id
47+ WHERE i.invitee_id = $1
48+ ORDER BY i.created_at",
49+ )
50+ .bind(invitee_id)
51+ .fetch_all(pool)
52+ .await
53+}
54+
55+/// Nombre d'invitations en attente (badge de l'onglet Réglages).
56+pub async fn count_for(pool: &PgPool, invitee_id: Uuid) -> sqlx::Result<i64> {
57+ let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM invitations WHERE invitee_id = $1")
58+ .bind(invitee_id)
59+ .fetch_one(pool)
60+ .await?;
61+ Ok(n)
62+}
63+
64+/// Refus : suppression silencieuse, l'inviteur peut ré-inviter plus tard.
65+pub async fn delete_owned(pool: &PgPool, id: Uuid, invitee_id: Uuid) -> sqlx::Result<u64> {
66+ let res = sqlx::query("DELETE FROM invitations WHERE id = $1 AND invitee_id = $2")
67+ .bind(id)
68+ .bind(invitee_id)
69+ .execute(pool)
70+ .await?;
71+ Ok(res.rows_affected())
72+}
73+
74+/// Acceptation : rejoint l'équipe et consomme l'invitation, en transaction.
75+/// Retourne l'équipe rejointe (None si l'invitation n'est pas à moi).
76+pub async fn accept(pool: &PgPool, id: Uuid, invitee_id: Uuid) -> sqlx::Result<Option<Uuid>> {
77+ let mut tx = pool.begin().await?;
78+ let row: Option<(Uuid,)> = sqlx::query_as(
79+ "DELETE FROM invitations WHERE id = $1 AND invitee_id = $2 RETURNING team_id",
80+ )
81+ .bind(id)
82+ .bind(invitee_id)
83+ .fetch_optional(&mut *tx)
84+ .await?;
85+ let Some((team_id,)) = row else {
86+ return Ok(None);
87+ };
88+ sqlx::query(
89+ "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
90+ )
91+ .bind(team_id)
92+ .bind(invitee_id)
93+ .execute(&mut *tx)
94+ .await?;
95+ tx.commit().await?;
96+ Ok(Some(team_id))
97+}
98+
99+/// Invitations en attente émises pour cette équipe.
100+pub async fn list_sent(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<SentRow>> {
101+ sqlx::query_as(
102+ "SELECT i.id, inv.username AS invitee, ur.username AS inviter
103+ FROM invitations i
104+ JOIN users inv ON inv.id = i.invitee_id
105+ JOIN users ur ON ur.id = i.inviter_id
106+ WHERE i.team_id = $1
107+ ORDER BY i.created_at",
108+ )
109+ .bind(team_id)
110+ .fetch_all(pool)
111+ .await
112+}
113+
114+/// Annulation par un membre de l'équipe émettrice (garde en SQL).
115+pub async fn cancel(pool: &PgPool, id: Uuid, team_id: Uuid, by_user: Uuid) -> sqlx::Result<u64> {
116+ let res = sqlx::query(
117+ "DELETE FROM invitations
118+ WHERE id = $1 AND team_id = $2
119+ AND EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $2 AND m.user_id = $3)",
120+ )
121+ .bind(id)
122+ .bind(team_id)
123+ .bind(by_user)
124+ .execute(pool)
125+ .await?;
126+ Ok(res.rows_affected())
127+}
new file mode 100644
@@ -0,0 +1,127 @@
1+use sqlx::PgPool;
2+use uuid::Uuid;
3+
4+/// Invitation reçue (bloc en tête des paramètres).
5+#[derive(sqlx::FromRow)]
6+pub struct InviteRow {
7+ pub id: Uuid,
8+ pub team_name: String,
9+ pub inviter: String,
10+}
11+
12+/// Invitation émise par l'équipe active (liste « en attente »).
13+#[derive(sqlx::FromRow)]
14+pub struct SentRow {
15+ pub id: Uuid,
16+ pub invitee: String,
17+ pub inviter: String,
18+}
19+
20+/// false = une invitation était déjà en attente pour ce couple.
21+pub async fn create(
22+ pool: &PgPool,
23+ team_id: Uuid,
24+ invitee_id: Uuid,
25+ inviter_id: Uuid,
26+) -> sqlx::Result<bool> {
27+ let res = sqlx::query(
28+ "INSERT INTO invitations (id, team_id, invitee_id, inviter_id)
29+ VALUES ($1, $2, $3, $4)
30+ ON CONFLICT (team_id, invitee_id) DO NOTHING",
31+ )
32+ .bind(Uuid::now_v7())
33+ .bind(team_id)
34+ .bind(invitee_id)
35+ .bind(inviter_id)
36+ .execute(pool)
37+ .await?;
38+ Ok(res.rows_affected() > 0)
39+}
40+
41+pub async fn list_for(pool: &PgPool, invitee_id: Uuid) -> sqlx::Result<Vec<InviteRow>> {
42+ sqlx::query_as(
43+ "SELECT i.id, t.name AS team_name, u.username AS inviter
44+ FROM invitations i
45+ JOIN teams t ON t.id = i.team_id
46+ JOIN users u ON u.id = i.inviter_id
47+ WHERE i.invitee_id = $1
48+ ORDER BY i.created_at",
49+ )
50+ .bind(invitee_id)
51+ .fetch_all(pool)
52+ .await
53+}
54+
55+/// Nombre d'invitations en attente (badge de l'onglet Réglages).
56+pub async fn count_for(pool: &PgPool, invitee_id: Uuid) -> sqlx::Result<i64> {
57+ let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM invitations WHERE invitee_id = $1")
58+ .bind(invitee_id)
59+ .fetch_one(pool)
60+ .await?;
61+ Ok(n)
62+}
63+
64+/// Refus : suppression silencieuse, l'inviteur peut ré-inviter plus tard.
65+pub async fn delete_owned(pool: &PgPool, id: Uuid, invitee_id: Uuid) -> sqlx::Result<u64> {
66+ let res = sqlx::query("DELETE FROM invitations WHERE id = $1 AND invitee_id = $2")
67+ .bind(id)
68+ .bind(invitee_id)
69+ .execute(pool)
70+ .await?;
71+ Ok(res.rows_affected())
72+}
73+
74+/// Acceptation : rejoint l'équipe et consomme l'invitation, en transaction.
75+/// Retourne l'équipe rejointe (None si l'invitation n'est pas à moi).
76+pub async fn accept(pool: &PgPool, id: Uuid, invitee_id: Uuid) -> sqlx::Result<Option<Uuid>> {
77+ let mut tx = pool.begin().await?;
78+ let row: Option<(Uuid,)> = sqlx::query_as(
79+ "DELETE FROM invitations WHERE id = $1 AND invitee_id = $2 RETURNING team_id",
80+ )
81+ .bind(id)
82+ .bind(invitee_id)
83+ .fetch_optional(&mut *tx)
84+ .await?;
85+ let Some((team_id,)) = row else {
86+ return Ok(None);
87+ };
88+ sqlx::query(
89+ "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
90+ )
91+ .bind(team_id)
92+ .bind(invitee_id)
93+ .execute(&mut *tx)
94+ .await?;
95+ tx.commit().await?;
96+ Ok(Some(team_id))
97+}
98+
99+/// Invitations en attente émises pour cette équipe.
100+pub async fn list_sent(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<SentRow>> {
101+ sqlx::query_as(
102+ "SELECT i.id, inv.username AS invitee, ur.username AS inviter
103+ FROM invitations i
104+ JOIN users inv ON inv.id = i.invitee_id
105+ JOIN users ur ON ur.id = i.inviter_id
106+ WHERE i.team_id = $1
107+ ORDER BY i.created_at",
108+ )
109+ .bind(team_id)
110+ .fetch_all(pool)
111+ .await
112+}
113+
114+/// Annulation par un membre de l'équipe émettrice (garde en SQL).
115+pub async fn cancel(pool: &PgPool, id: Uuid, team_id: Uuid, by_user: Uuid) -> sqlx::Result<u64> {
116+ let res = sqlx::query(
117+ "DELETE FROM invitations
118+ WHERE id = $1 AND team_id = $2
119+ AND EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $2 AND m.user_id = $3)",
120+ )
121+ .bind(id)
122+ .bind(team_id)
123+ .bind(by_user)
124+ .execute(pool)
125+ .await?;
126+ Ok(res.rows_affected())
127+}
modified src/db/mod.rs +2 -0
@@ -1,4 +1,6 @@
11 pub mod entries;
2+pub mod invitations;
23 pub mod stats;
34 pub mod tasks;
5+pub mod teams;
46 pub mod users;
@@ -1,4 +1,6 @@
1 pub mod entries;1 pub mod entries;
2+pub mod invitations;
2 pub mod stats;3 pub mod stats;
3 pub mod tasks;4 pub mod tasks;
5+pub mod teams;
4 pub mod users;6 pub mod users;
modified src/db/stats.rs +13 -6
@@ -5,6 +5,8 @@ use uuid::Uuid;
55 #[derive(sqlx::FromRow)]
66 pub struct StatRow {
77 pub user_id: Uuid,
8+ /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact).
9+ pub username: String,
810 pub task_id: Uuid,
911 pub task_name: String,
1012 pub total: i64,
@@ -12,22 +14,27 @@ pub struct StatRow {
1214 pub entries: i64,
1315 }
1416
15-/// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début.
16-/// Les tâches archivées restent incluses.
17+/// Totaux par utilisateur × tâche de l'équipe sur [from, to) ;
18+/// bornes NULL = depuis le début. Les tâches archivées restent incluses,
19+/// et les temps des ex-membres aussi (l'équipe se déduit de la tâche).
1720 pub async fn totals(
1821 pool: &PgPool,
22+ team_id: Uuid,
1923 from: Option<NaiveDate>,
2024 to: Option<NaiveDate>,
2125 ) -> sqlx::Result<Vec<StatRow>> {
2226 sqlx::query_as(
23- "SELECT e.user_id, e.task_id, t.name AS task_name,
27+ "SELECT e.user_id, u.username, e.task_id, t.name AS task_name,
2428 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries
2529 FROM time_entries e
2630 JOIN tasks t ON t.id = e.task_id
27- WHERE ($1::date IS NULL OR e.entry_date >= $1)
28- AND ($2::date IS NULL OR e.entry_date < $2)
29- GROUP BY e.user_id, e.task_id, t.name",
31+ JOIN users u ON u.id = e.user_id
32+ WHERE t.team_id = $1
33+ AND ($2::date IS NULL OR e.entry_date >= $2)
34+ AND ($3::date IS NULL OR e.entry_date < $3)
35+ GROUP BY e.user_id, u.username, e.task_id, t.name",
3036 )
37+ .bind(team_id)
3138 .bind(from)
3239 .bind(to)
3340 .fetch_all(pool)
@@ -5,6 +5,8 @@ use uuid::Uuid;
5 #[derive(sqlx::FromRow)]5 #[derive(sqlx::FromRow)]
6 pub struct StatRow {6 pub struct StatRow {
7 pub user_id: Uuid,7 pub user_id: Uuid,
8+ /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact).
9+ pub username: String,
8 pub task_id: Uuid,10 pub task_id: Uuid,
9 pub task_name: String,11 pub task_name: String,
10 pub total: i64,12 pub total: i64,
@@ -12,22 +14,27 @@ pub struct StatRow {
12 pub entries: i64,14 pub entries: i64,
13 }15 }
14 16
15-/// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début.17+/// Totaux par utilisateur × tâche de l'équipe sur [from, to) ;
16-/// Les tâches archivées restent incluses.18+/// bornes NULL = depuis le début. Les tâches archivées restent incluses,
19+/// et les temps des ex-membres aussi (l'équipe se déduit de la tâche).
17 pub async fn totals(20 pub async fn totals(
18 pool: &PgPool,21 pool: &PgPool,
22+ team_id: Uuid,
19 from: Option<NaiveDate>,23 from: Option<NaiveDate>,
20 to: Option<NaiveDate>,24 to: Option<NaiveDate>,
21 ) -> sqlx::Result<Vec<StatRow>> {25 ) -> sqlx::Result<Vec<StatRow>> {
22 sqlx::query_as(26 sqlx::query_as(
23- "SELECT e.user_id, e.task_id, t.name AS task_name,27+ "SELECT e.user_id, u.username, e.task_id, t.name AS task_name,
24 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries28 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries
25 FROM time_entries e29 FROM time_entries e
26 JOIN tasks t ON t.id = e.task_id30 JOIN tasks t ON t.id = e.task_id
27- WHERE ($1::date IS NULL OR e.entry_date >= $1)31+ JOIN users u ON u.id = e.user_id
28- AND ($2::date IS NULL OR e.entry_date < $2)32+ WHERE t.team_id = $1
29- GROUP BY e.user_id, e.task_id, t.name",33+ AND ($2::date IS NULL OR e.entry_date >= $2)
34+ AND ($3::date IS NULL OR e.entry_date < $3)
35+ GROUP BY e.user_id, u.username, e.task_id, t.name",
30 )36 )
37+ .bind(team_id)
31 .bind(from)38 .bind(from)
32 .bind(to)39 .bind(to)
33 .fetch_all(pool)40 .fetch_all(pool)
modified src/db/tasks.rs +19 -13
@@ -8,43 +8,49 @@ pub struct TaskRow {
88 pub archived: bool,
99 }
1010
11-/// Toutes les tâches (écran Tâches).
12-pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> {
13- sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name")
11+/// Toutes les tâches de l'équipe (écran Tâches).
12+pub async fn list_all(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TaskRow>> {
13+ sqlx::query_as("SELECT id, name, archived FROM tasks WHERE team_id = $1 ORDER BY archived, name")
14+ .bind(team_id)
1415 .fetch_all(pool)
1516 .await
1617 }
1718
18-/// Toutes les tâches par ordre de création (UUIDv7 ≈ chronologique) —
19-/// c'est l'ordre qui fixe la couleur de chaque tâche.
20-pub async fn list_by_creation(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> {
21- sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY id")
19+/// Les tâches de l'équipe par ordre de création (UUIDv7 ≈ chronologique) —
20+/// c'est l'ordre qui fixe la couleur de chaque tâche, par équipe.
21+pub async fn list_by_creation(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TaskRow>> {
22+ sqlx::query_as("SELECT id, name, archived FROM tasks WHERE team_id = $1 ORDER BY id")
23+ .bind(team_id)
2224 .fetch_all(pool)
2325 .await
2426 }
2527
26-pub async fn create(pool: &PgPool, name: &str) -> sqlx::Result<Uuid> {
28+pub async fn create(pool: &PgPool, team_id: Uuid, name: &str) -> sqlx::Result<Uuid> {
2729 let id = Uuid::now_v7();
28- sqlx::query("INSERT INTO tasks (id, name) VALUES ($1, $2)")
30+ sqlx::query("INSERT INTO tasks (id, team_id, name) VALUES ($1, $2, $3)")
2931 .bind(id)
32+ .bind(team_id)
3033 .bind(name)
3134 .execute(pool)
3235 .await?;
3336 Ok(id)
3437 }
3538
36-pub async fn rename(pool: &PgPool, id: Uuid, name: &str) -> sqlx::Result<u64> {
37- let res = sqlx::query("UPDATE tasks SET name = $1 WHERE id = $2")
39+/// Scopé à l'équipe active : 0 ligne = tâche d'une autre équipe / inexistante.
40+pub async fn rename(pool: &PgPool, id: Uuid, team_id: Uuid, name: &str) -> sqlx::Result<u64> {
41+ let res = sqlx::query("UPDATE tasks SET name = $1 WHERE id = $2 AND team_id = $3")
3842 .bind(name)
3943 .bind(id)
44+ .bind(team_id)
4045 .execute(pool)
4146 .await?;
4247 Ok(res.rows_affected())
4348 }
4449
45-pub async fn toggle_archived(pool: &PgPool, id: Uuid) -> sqlx::Result<u64> {
46- let res = sqlx::query("UPDATE tasks SET archived = NOT archived WHERE id = $1")
50+pub async fn toggle_archived(pool: &PgPool, id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
51+ let res = sqlx::query("UPDATE tasks SET archived = NOT archived WHERE id = $1 AND team_id = $2")
4752 .bind(id)
53+ .bind(team_id)
4854 .execute(pool)
4955 .await?;
5056 Ok(res.rows_affected())
@@ -8,43 +8,49 @@ pub struct TaskRow {
8 pub archived: bool,8 pub archived: bool,
9 }9 }
10 10
11-/// Toutes les tâches (écran Tâches).11+/// Toutes les tâches de l'équipe (écran Tâches).
12-pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> {12+pub async fn list_all(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TaskRow>> {
13- sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name")13+ sqlx::query_as("SELECT id, name, archived FROM tasks WHERE team_id = $1 ORDER BY archived, name")
14+ .bind(team_id)
14 .fetch_all(pool)15 .fetch_all(pool)
15 .await16 .await
16 }17 }
17 18
18-/// Toutes les tâches par ordre de création (UUIDv7 ≈ chronologique) —19+/// Les tâches de l'équipe par ordre de création (UUIDv7 ≈ chronologique) —
19-/// c'est l'ordre qui fixe la couleur de chaque tâche.20+/// c'est l'ordre qui fixe la couleur de chaque tâche, par équipe.
20-pub async fn list_by_creation(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> {21+pub async fn list_by_creation(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TaskRow>> {
21- sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY id")22+ sqlx::query_as("SELECT id, name, archived FROM tasks WHERE team_id = $1 ORDER BY id")
23+ .bind(team_id)
22 .fetch_all(pool)24 .fetch_all(pool)
23 .await25 .await
24 }26 }
25 27
26-pub async fn create(pool: &PgPool, name: &str) -> sqlx::Result<Uuid> {28+pub async fn create(pool: &PgPool, team_id: Uuid, name: &str) -> sqlx::Result<Uuid> {
27 let id = Uuid::now_v7();29 let id = Uuid::now_v7();
28- sqlx::query("INSERT INTO tasks (id, name) VALUES ($1, $2)")30+ sqlx::query("INSERT INTO tasks (id, team_id, name) VALUES ($1, $2, $3)")
29 .bind(id)31 .bind(id)
32+ .bind(team_id)
30 .bind(name)33 .bind(name)
31 .execute(pool)34 .execute(pool)
32 .await?;35 .await?;
33 Ok(id)36 Ok(id)
34 }37 }
35 38
36-pub async fn rename(pool: &PgPool, id: Uuid, name: &str) -> sqlx::Result<u64> {39+/// Scopé à l'équipe active : 0 ligne = tâche d'une autre équipe / inexistante.
37- let res = sqlx::query("UPDATE tasks SET name = $1 WHERE id = $2")40+pub async fn rename(pool: &PgPool, id: Uuid, team_id: Uuid, name: &str) -> sqlx::Result<u64> {
41+ let res = sqlx::query("UPDATE tasks SET name = $1 WHERE id = $2 AND team_id = $3")
38 .bind(name)42 .bind(name)
39 .bind(id)43 .bind(id)
44+ .bind(team_id)
40 .execute(pool)45 .execute(pool)
41 .await?;46 .await?;
42 Ok(res.rows_affected())47 Ok(res.rows_affected())
43 }48 }
44 49
45-pub async fn toggle_archived(pool: &PgPool, id: Uuid) -> sqlx::Result<u64> {50+pub async fn toggle_archived(pool: &PgPool, id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
46- let res = sqlx::query("UPDATE tasks SET archived = NOT archived WHERE id = $1")51+ let res = sqlx::query("UPDATE tasks SET archived = NOT archived WHERE id = $1 AND team_id = $2")
47 .bind(id)52 .bind(id)
53+ .bind(team_id)
48 .execute(pool)54 .execute(pool)
49 .await?;55 .await?;
50 Ok(res.rows_affected())56 Ok(res.rows_affected())
added src/db/teams.rs +134 -0
new file mode 100644
@@ -0,0 +1,134 @@
1+use sqlx::PgPool;
2+use uuid::Uuid;
3+
4+use super::users::UserRow;
5+
6+/// Une de mes équipes (sélecteur des paramètres).
7+#[derive(sqlx::FromRow)]
8+pub struct TeamRow {
9+ pub id: Uuid,
10+ pub name: String,
11+ pub members: i64,
12+}
13+
14+pub async fn create(pool: &PgPool, name: &str) -> sqlx::Result<Uuid> {
15+ let id = Uuid::now_v7();
16+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
17+ .bind(id)
18+ .bind(name)
19+ .execute(pool)
20+ .await?;
21+ Ok(id)
22+}
23+
24+/// Création UI : l'équipe et son premier membre, en transaction.
25+pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sqlx::Result<Uuid> {
26+ let id = Uuid::now_v7();
27+ let mut tx = pool.begin().await?;
28+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
29+ .bind(id)
30+ .bind(name)
31+ .execute(&mut *tx)
32+ .await?;
33+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
34+ .bind(id)
35+ .bind(user_id)
36+ .execute(&mut *tx)
37+ .await?;
38+ tx.commit().await?;
39+ Ok(id)
40+}
41+
42+pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> {
43+ let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1")
44+ .bind(name)
45+ .fetch_optional(pool)
46+ .await?;
47+ Ok(row.map(|(id,)| id))
48+}
49+
50+pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
51+ let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
52+ .bind(team_id)
53+ .bind(user_id)
54+ .execute(pool)
55+ .await?;
56+ Ok(res.rows_affected())
57+}
58+
59+/// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie.
60+pub async fn membership(
61+ pool: &PgPool,
62+ user_id: Uuid,
63+ team_id: Uuid,
64+) -> sqlx::Result<Option<(Uuid, String)>> {
65+ sqlx::query_as(
66+ "SELECT t.id, t.name FROM teams t
67+ JOIN team_members m ON m.team_id = t.id
68+ WHERE m.user_id = $1 AND t.id = $2",
69+ )
70+ .bind(user_id)
71+ .bind(team_id)
72+ .fetch_optional(pool)
73+ .await
74+}
75+
76+/// Équipe par défaut (pas de cookie ou cookie invalide) : la plus
77+/// ancienne appartenance — stable et prévisible.
78+pub async fn oldest_membership(
79+ pool: &PgPool,
80+ user_id: Uuid,
81+) -> sqlx::Result<Option<(Uuid, String)>> {
82+ sqlx::query_as(
83+ "SELECT t.id, t.name FROM teams t
84+ JOIN team_members m ON m.team_id = t.id
85+ WHERE m.user_id = $1
86+ ORDER BY m.joined_at, t.id
87+ LIMIT 1",
88+ )
89+ .bind(user_id)
90+ .fetch_optional(pool)
91+ .await
92+}
93+
94+/// Mes équipes par ancienneté d'adhésion, avec le nombre de membres.
95+pub async fn teams_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<TeamRow>> {
96+ sqlx::query_as(
97+ "SELECT t.id, t.name,
98+ (SELECT COUNT(*) FROM team_members c WHERE c.team_id = t.id) AS members
99+ FROM teams t
100+ JOIN team_members m ON m.team_id = t.id
101+ WHERE m.user_id = $1
102+ ORDER BY m.joined_at, t.id",
103+ )
104+ .bind(user_id)
105+ .fetch_all(pool)
106+ .await
107+}
108+
109+/// Renommage gardé par l'appartenance en SQL : 0 ligne = pas membre.
110+pub async fn rename(pool: &PgPool, team_id: Uuid, name: &str, by_user: Uuid) -> sqlx::Result<u64> {
111+ let res = sqlx::query(
112+ "UPDATE teams SET name = $1
113+ WHERE id = $2
114+ AND EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $2 AND m.user_id = $3)",
115+ )
116+ .bind(name)
117+ .bind(team_id)
118+ .bind(by_user)
119+ .execute(pool)
120+ .await?;
121+ Ok(res.rows_affected())
122+}
123+
124+pub async fn members(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> {
125+ sqlx::query_as(
126+ "SELECT u.id, u.username FROM users u
127+ JOIN team_members m ON m.user_id = u.id
128+ WHERE m.team_id = $1
129+ ORDER BY u.username",
130+ )
131+ .bind(team_id)
132+ .fetch_all(pool)
133+ .await
134+}
new file mode 100644
@@ -0,0 +1,134 @@
1+use sqlx::PgPool;
2+use uuid::Uuid;
3+
4+use super::users::UserRow;
5+
6+/// Une de mes équipes (sélecteur des paramètres).
7+#[derive(sqlx::FromRow)]
8+pub struct TeamRow {
9+ pub id: Uuid,
10+ pub name: String,
11+ pub members: i64,
12+}
13+
14+pub async fn create(pool: &PgPool, name: &str) -> sqlx::Result<Uuid> {
15+ let id = Uuid::now_v7();
16+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
17+ .bind(id)
18+ .bind(name)
19+ .execute(pool)
20+ .await?;
21+ Ok(id)
22+}
23+
24+/// Création UI : l'équipe et son premier membre, en transaction.
25+pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sqlx::Result<Uuid> {
26+ let id = Uuid::now_v7();
27+ let mut tx = pool.begin().await?;
28+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
29+ .bind(id)
30+ .bind(name)
31+ .execute(&mut *tx)
32+ .await?;
33+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
34+ .bind(id)
35+ .bind(user_id)
36+ .execute(&mut *tx)
37+ .await?;
38+ tx.commit().await?;
39+ Ok(id)
40+}
41+
42+pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> {
43+ let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1")
44+ .bind(name)
45+ .fetch_optional(pool)
46+ .await?;
47+ Ok(row.map(|(id,)| id))
48+}
49+
50+pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
51+ let res = sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
52+ .bind(team_id)
53+ .bind(user_id)
54+ .execute(pool)
55+ .await?;
56+ Ok(res.rows_affected())
57+}
58+
59+/// (id, nom) si l'utilisateur est membre de cette équipe — valide le cookie.
60+pub async fn membership(
61+ pool: &PgPool,
62+ user_id: Uuid,
63+ team_id: Uuid,
64+) -> sqlx::Result<Option<(Uuid, String)>> {
65+ sqlx::query_as(
66+ "SELECT t.id, t.name FROM teams t
67+ JOIN team_members m ON m.team_id = t.id
68+ WHERE m.user_id = $1 AND t.id = $2",
69+ )
70+ .bind(user_id)
71+ .bind(team_id)
72+ .fetch_optional(pool)
73+ .await
74+}
75+
76+/// Équipe par défaut (pas de cookie ou cookie invalide) : la plus
77+/// ancienne appartenance — stable et prévisible.
78+pub async fn oldest_membership(
79+ pool: &PgPool,
80+ user_id: Uuid,
81+) -> sqlx::Result<Option<(Uuid, String)>> {
82+ sqlx::query_as(
83+ "SELECT t.id, t.name FROM teams t
84+ JOIN team_members m ON m.team_id = t.id
85+ WHERE m.user_id = $1
86+ ORDER BY m.joined_at, t.id
87+ LIMIT 1",
88+ )
89+ .bind(user_id)
90+ .fetch_optional(pool)
91+ .await
92+}
93+
94+/// Mes équipes par ancienneté d'adhésion, avec le nombre de membres.
95+pub async fn teams_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<TeamRow>> {
96+ sqlx::query_as(
97+ "SELECT t.id, t.name,
98+ (SELECT COUNT(*) FROM team_members c WHERE c.team_id = t.id) AS members
99+ FROM teams t
100+ JOIN team_members m ON m.team_id = t.id
101+ WHERE m.user_id = $1
102+ ORDER BY m.joined_at, t.id",
103+ )
104+ .bind(user_id)
105+ .fetch_all(pool)
106+ .await
107+}
108+
109+/// Renommage gardé par l'appartenance en SQL : 0 ligne = pas membre.
110+pub async fn rename(pool: &PgPool, team_id: Uuid, name: &str, by_user: Uuid) -> sqlx::Result<u64> {
111+ let res = sqlx::query(
112+ "UPDATE teams SET name = $1
113+ WHERE id = $2
114+ AND EXISTS (SELECT 1 FROM team_members m WHERE m.team_id = $2 AND m.user_id = $3)",
115+ )
116+ .bind(name)
117+ .bind(team_id)
118+ .bind(by_user)
119+ .execute(pool)
120+ .await?;
121+ Ok(res.rows_affected())
122+}
123+
124+pub async fn members(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> {
125+ sqlx::query_as(
126+ "SELECT u.id, u.username FROM users u
127+ JOIN team_members m ON m.user_id = u.id
128+ WHERE m.team_id = $1
129+ ORDER BY u.username",
130+ )
131+ .bind(team_id)
132+ .fetch_all(pool)
133+ .await
134+}
modified src/db/users.rs +5 -14
@@ -13,17 +13,6 @@ pub struct Credentials {
1313 pub password_hash: String,
1414 }
1515
16-pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> sqlx::Result<Uuid> {
17- let id = Uuid::now_v7();
18- sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)")
19- .bind(id)
20- .bind(username)
21- .bind(password_hash)
22- .execute(pool)
23- .await?;
24- Ok(id)
25-}
26-
2716 pub async fn find_credentials(pool: &PgPool, username: &str) -> sqlx::Result<Option<Credentials>> {
2817 sqlx::query_as("SELECT id, password_hash FROM users WHERE username = $1")
2918 .bind(username)
@@ -56,8 +45,10 @@ pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> {
5645 Ok(row.map(|(u,)| u))
5746 }
5847
59-pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> {
60- sqlx::query_as("SELECT id, username FROM users ORDER BY username")
61- .fetch_all(pool)
48+/// Cible d'une invitation.
49+pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
50+ sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
51+ .bind(username)
52+ .fetch_optional(pool)
6253 .await
6354 }
@@ -13,17 +13,6 @@ pub struct Credentials {
13 pub password_hash: String,13 pub password_hash: String,
14 }14 }
15 15
16-pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> sqlx::Result<Uuid> {
17- let id = Uuid::now_v7();
18- sqlx::query("INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)")
19- .bind(id)
20- .bind(username)
21- .bind(password_hash)
22- .execute(pool)
23- .await?;
24- Ok(id)
25-}
26-
27 pub async fn find_credentials(pool: &PgPool, username: &str) -> sqlx::Result<Option<Credentials>> {16 pub async fn find_credentials(pool: &PgPool, username: &str) -> sqlx::Result<Option<Credentials>> {
28 sqlx::query_as("SELECT id, password_hash FROM users WHERE username = $1")17 sqlx::query_as("SELECT id, password_hash FROM users WHERE username = $1")
29 .bind(username)18 .bind(username)
@@ -56,8 +45,10 @@ pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> {
56 Ok(row.map(|(u,)| u))45 Ok(row.map(|(u,)| u))
57 }46 }
58 47
59-pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> {48+/// Cible d'une invitation.
60- sqlx::query_as("SELECT id, username FROM users ORDER BY username")49+pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
61- .fetch_all(pool)50+ sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
51+ .bind(username)
52+ .fetch_optional(pool)
62 .await53 .await
63 }54 }
modified src/main.rs +25 -3
@@ -8,6 +8,7 @@ mod period;
88 mod render;
99 mod routes;
1010 mod state;
11+mod team;
1112
1213 use anyhow::{Context, Result};
1314 use clap::{Parser, Subcommand};
@@ -31,20 +32,34 @@ enum Command {
3132 #[command(subcommand)]
3233 command: UserCommand,
3334 },
35+ /// Gestion des équipes
36+ Team {
37+ #[command(subcommand)]
38+ command: TeamCommand,
39+ },
3440 /// Génère la paire de clés Biscuit racine
3541 Keygen,
3642 }
3743
3844 #[derive(Subcommand)]
3945 enum UserCommand {
40- /// Crée un utilisateur
46+ /// Crée un utilisateur (toujours rattaché à une équipe)
4147 Add {
4248 username: String,
4349 #[arg(long)]
4450 password: Option<String>,
51+ /// Équipe d'accueil (doit exister — `time-time team add`)
52+ #[arg(long)]
53+ team: String,
4554 },
4655 }
4756
57+#[derive(Subcommand)]
58+enum TeamCommand {
59+ /// Crée une équipe
60+ Add { name: String },
61+}
62+
4863 #[tokio::main]
4964 async fn main() -> Result<()> {
5065 let cli = Cli::parse();
@@ -55,8 +70,15 @@ async fn main() -> Result<()> {
5570 Ok(())
5671 }
5772 Some(Command::User {
58- command: UserCommand::Add { username, password },
59- }) => cli::user_add(&username, password).await,
73+ command: UserCommand::Add {
74+ username,
75+ password,
76+ team,
77+ },
78+ }) => cli::user_add(&username, password, &team).await,
79+ Some(Command::Team {
80+ command: TeamCommand::Add { name },
81+ }) => cli::team_add(&name).await,
6082 }
6183 }
6284
@@ -8,6 +8,7 @@ mod period;
8 mod render;8 mod render;
9 mod routes;9 mod routes;
10 mod state;10 mod state;
11+mod team;
11 12
12 use anyhow::{Context, Result};13 use anyhow::{Context, Result};
13 use clap::{Parser, Subcommand};14 use clap::{Parser, Subcommand};
@@ -31,20 +32,34 @@ enum Command {
31 #[command(subcommand)]32 #[command(subcommand)]
32 command: UserCommand,33 command: UserCommand,
33 },34 },
35+ /// Gestion des équipes
36+ Team {
37+ #[command(subcommand)]
38+ command: TeamCommand,
39+ },
34 /// Génère la paire de clés Biscuit racine40 /// Génère la paire de clés Biscuit racine
35 Keygen,41 Keygen,
36 }42 }
37 43
38 #[derive(Subcommand)]44 #[derive(Subcommand)]
39 enum UserCommand {45 enum UserCommand {
40- /// Crée un utilisateur46+ /// Crée un utilisateur (toujours rattaché à une équipe)
41 Add {47 Add {
42 username: String,48 username: String,
43 #[arg(long)]49 #[arg(long)]
44 password: Option<String>,50 password: Option<String>,
51+ /// Équipe d'accueil (doit exister — `time-time team add`)
52+ #[arg(long)]
53+ team: String,
45 },54 },
46 }55 }
47 56
57+#[derive(Subcommand)]
58+enum TeamCommand {
59+ /// Crée une équipe
60+ Add { name: String },
61+}
62+
48 #[tokio::main]63 #[tokio::main]
49 async fn main() -> Result<()> {64 async fn main() -> Result<()> {
50 let cli = Cli::parse();65 let cli = Cli::parse();
@@ -55,8 +70,15 @@ async fn main() -> Result<()> {
55 Ok(())70 Ok(())
56 }71 }
57 Some(Command::User {72 Some(Command::User {
58- command: UserCommand::Add { username, password },73+ command: UserCommand::Add {
59- }) => cli::user_add(&username, password).await,74+ username,
75+ password,
76+ team,
77+ },
78+ }) => cli::user_add(&username, password, &team).await,
79+ Some(Command::Team {
80+ command: TeamCommand::Add { name },
81+ }) => cli::team_add(&name).await,
60 }82 }
61 }83 }
62 84
modified src/render.rs +4 -0
@@ -20,7 +20,11 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> {
2020 pub struct Shell {
2121 pub title: String,
2222 pub username: String,
23+ /// Nom de l'équipe active (pill de l'en-tête).
24+ pub team_name: String,
2325 /// "add" | "stats" | "times" | "settings"
2426 pub tab: &'static str,
2527 pub toast: Option<String>,
28+ /// Invitations en attente — badge sur l'onglet Réglages.
29+ pub invite_count: i64,
2630 }
@@ -20,7 +20,11 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> {
20 pub struct Shell {20 pub struct Shell {
21 pub title: String,21 pub title: String,
22 pub username: String,22 pub username: String,
23+ /// Nom de l'équipe active (pill de l'en-tête).
24+ pub team_name: String,
23 /// "add" | "stats" | "times" | "settings"25 /// "add" | "stats" | "times" | "settings"
24 pub tab: &'static str,26 pub tab: &'static str,
25 pub toast: Option<String>,27 pub toast: Option<String>,
28+ /// Invitations en attente — badge sur l'onglet Réglages.
29+ pub invite_count: i64,
26 }30 }
modified src/routes/entries.rs +44 -28
@@ -8,7 +8,6 @@ use chrono::{Duration, NaiveDate};
88 use serde::Deserialize;
99 use uuid::Uuid;
1010
11-use crate::auth::extractor::AuthUser;
1211 use crate::charts::{color_map, fmt_minutes};
1312 use crate::db::entries::{self, EntryRow};
1413 use crate::db::tasks::{self, TaskRow};
@@ -17,6 +16,7 @@ use crate::period::{Week, day_label, paris_today};
1716 use crate::render::{HtmlTemplate, Shell};
1817 use crate::routes::shell;
1918 use crate::state::AppState;
19+use crate::team::ActiveTeam;
2020
2121 const PAGE_SIZE: i64 = 20;
2222
@@ -82,11 +82,11 @@ fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {
8282
8383 pub async fn entry_new(
8484 State(state): State<AppState>,
85- auth: AuthUser,
85+ ctx: ActiveTeam,
8686 Query(q): Query<NewQuery>,
8787 ) -> Result<Response, AppError> {
8888 let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)));
89- let all_tasks = tasks::list_by_creation(&state.pool).await?;
89+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
9090 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
9191
9292 // Étape 2 si une tâche (active) est choisie.
@@ -106,8 +106,8 @@ pub async fn entry_new(
106106 })
107107 .collect();
108108 return Ok(HtmlTemplate(AddStep2Page {
109- shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?,
110- csrf: auth.csrf_token(&state),
109+ csrf: ctx.user.csrf_token(&state),
110+ shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
111111 tache: picked.id,
112112 picked_color: colors[&picked.id],
113113 picked_name: picked.name.clone(),
@@ -128,10 +128,11 @@ pub async fn entry_new(
128128
129129 // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives.
130130 let (wfrom, wto) = Week::current().bounds();
131- let week: HashMap<Uuid, i64> = entries::week_totals_for_user(&state.pool, auth.id, wfrom, wto)
132- .await?
133- .into_iter()
134- .collect();
131+ let week: HashMap<Uuid, i64> =
132+ entries::week_totals_for_user(&state.pool, ctx.user.id, ctx.team_id, wfrom, wto)
133+ .await?
134+ .into_iter()
135+ .collect();
135136 let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect();
136137 active.sort_by(|a, b| a.name.cmp(&b.name));
137138 let tasks = active
@@ -147,7 +148,7 @@ pub async fn entry_new(
147148 })
148149 .collect();
149150 Ok(HtmlTemplate(AddStep1Page {
150- shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?,
151+ shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
151152 tasks,
152153 })
153154 .into_response())
@@ -172,14 +173,27 @@ fn validate(form: &EntryForm) -> Result<(), AppError> {
172173
173174 pub async fn entry_create(
174175 State(state): State<AppState>,
175- auth: AuthUser,
176+ ctx: ActiveTeam,
176177 Form(form): Form<EntryForm>,
177178 ) -> Result<Redirect, AppError> {
178- if !auth.check_csrf(&state, &form.csrf) {
179+ if !ctx.user.check_csrf(&state, &form.csrf) {
179180 return Err(AppError::Csrf);
180181 }
181182 validate(&form)?;
182- entries::insert(&state.pool, auth.id, form.tache, form.minutes, form.date).await?;
183+ let n = entries::insert(
184+ &state.pool,
185+ ctx.user.id,
186+ ctx.team_id,
187+ form.tache,
188+ form.minutes,
189+ form.date,
190+ )
191+ .await?;
192+ if n == 0 {
193+ return Err(AppError::BadRequest(
194+ "Cette tâche n'appartient pas à l'équipe active.".into(),
195+ ));
196+ }
183197 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))
184198 }
185199
@@ -209,7 +223,7 @@ pub struct PageQuery {
209223
210224 pub async fn entries_list(
211225 State(state): State<AppState>,
212- auth: AuthUser,
226+ ctx: ActiveTeam,
213227 Query(q): Query<PageQuery>,
214228 ) -> Result<HtmlTemplate<TimesPage>, AppError> {
215229 let toast = match q.ok.as_deref() {
@@ -218,9 +232,10 @@ pub async fn entries_list(
218232 _ => None,
219233 };
220234 let page = q.page.unwrap_or(1).max(1);
221- let (rows, has_more) = entries::list_first(&state.pool, auth.id, page * PAGE_SIZE).await?;
235+ let (rows, has_more) =
236+ entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?;
222237
223- let all_tasks = tasks::list_by_creation(&state.pool).await?;
238+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
224239 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
225240
226241 let entries = rows
@@ -234,7 +249,7 @@ pub async fn entries_list(
234249 })
235250 .collect();
236251 Ok(HtmlTemplate(TimesPage {
237- shell: shell(&state, &auth, "Mes temps", "times", toast).await?,
252+ shell: shell(&state, &ctx, "Mes temps", "times", toast).await?,
238253 entries,
239254 more_href: has_more.then(|| format!("/temps?page={}", page + 1)),
240255 }))
@@ -262,14 +277,14 @@ pub struct EditPage {
262277
263278 pub async fn entry_edit(
264279 State(state): State<AppState>,
265- auth: AuthUser,
280+ ctx: ActiveTeam,
266281 Path(id): Path<Uuid>,
267282 ) -> Result<HtmlTemplate<EditPage>, AppError> {
268- let entry = entries::get_owned(&state.pool, id, auth.id)
283+ let entry = entries::get_owned(&state.pool, id, ctx.user.id, ctx.team_id)
269284 .await?
270285 .ok_or(AppError::NotFound)?;
271286 // La tâche de l'entrée reste proposée même archivée depuis.
272- let mut options: Vec<TaskOption> = tasks::list_all(&state.pool)
287+ let mut options: Vec<TaskOption> = tasks::list_all(&state.pool, ctx.team_id)
273288 .await?
274289 .into_iter()
275290 .filter(|t| !t.archived || t.id == entry.task_id)
@@ -285,8 +300,8 @@ pub async fn entry_edit(
285300 .collect();
286301 options.sort_by(|a, b| a.name.cmp(&b.name));
287302 Ok(HtmlTemplate(EditPage {
288- shell: shell(&state, &auth, "Modifier le temps", "times", None).await?,
289- csrf: auth.csrf_token(&state),
303+ csrf: ctx.user.csrf_token(&state),
304+ shell: shell(&state, &ctx, "Modifier le temps", "times", None).await?,
290305 action: format!("/temps/{id}"),
291306 delete_action: format!("/temps/{id}/supprimer"),
292307 tasks: options,
@@ -297,18 +312,19 @@ pub async fn entry_edit(
297312
298313 pub async fn entry_update(
299314 State(state): State<AppState>,
300- auth: AuthUser,
315+ ctx: ActiveTeam,
301316 Path(id): Path<Uuid>,
302317 Form(form): Form<EntryForm>,
303318 ) -> Result<Redirect, AppError> {
304- if !auth.check_csrf(&state, &form.csrf) {
319+ if !ctx.user.check_csrf(&state, &form.csrf) {
305320 return Err(AppError::Csrf);
306321 }
307322 validate(&form)?;
308323 let n = entries::update_owned(
309324 &state.pool,
310325 id,
311- auth.id,
326+ ctx.user.id,
327+ ctx.team_id,
312328 form.tache,
313329 form.minutes,
314330 form.date,
@@ -327,14 +343,14 @@ pub struct CsrfOnly {
327343
328344 pub async fn entry_delete(
329345 State(state): State<AppState>,
330- auth: AuthUser,
346+ ctx: ActiveTeam,
331347 Path(id): Path<Uuid>,
332348 Form(form): Form<CsrfOnly>,
333349 ) -> Result<Redirect, AppError> {
334- if !auth.check_csrf(&state, &form.csrf) {
350+ if !ctx.user.check_csrf(&state, &form.csrf) {
335351 return Err(AppError::Csrf);
336352 }
337- if entries::delete_owned(&state.pool, id, auth.id).await? == 0 {
353+ if entries::delete_owned(&state.pool, id, ctx.user.id, ctx.team_id).await? == 0 {
338354 return Err(AppError::NotFound);
339355 }
340356 Ok(Redirect::to("/temps?ok=suppr"))
@@ -8,7 +8,6 @@ use chrono::{Duration, NaiveDate};
8 use serde::Deserialize;8 use serde::Deserialize;
9 use uuid::Uuid;9 use uuid::Uuid;
10 10
11-use crate::auth::extractor::AuthUser;
12 use crate::charts::{color_map, fmt_minutes};11 use crate::charts::{color_map, fmt_minutes};
13 use crate::db::entries::{self, EntryRow};12 use crate::db::entries::{self, EntryRow};
14 use crate::db::tasks::{self, TaskRow};13 use crate::db::tasks::{self, TaskRow};
@@ -17,6 +16,7 @@ use crate::period::{Week, day_label, paris_today};
17 use crate::render::{HtmlTemplate, Shell};16 use crate::render::{HtmlTemplate, Shell};
18 use crate::routes::shell;17 use crate::routes::shell;
19 use crate::state::AppState;18 use crate::state::AppState;
19+use crate::team::ActiveTeam;
20 20
21 const PAGE_SIZE: i64 = 20;21 const PAGE_SIZE: i64 = 20;
22 22
@@ -82,11 +82,11 @@ fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {
82 82
83 pub async fn entry_new(83 pub async fn entry_new(
84 State(state): State<AppState>,84 State(state): State<AppState>,
85- auth: AuthUser,85+ ctx: ActiveTeam,
86 Query(q): Query<NewQuery>,86 Query(q): Query<NewQuery>,
87 ) -> Result<Response, AppError> {87 ) -> Result<Response, AppError> {
88 let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)));88 let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)));
89- let all_tasks = tasks::list_by_creation(&state.pool).await?;89+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
90 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());90 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
91 91
92 // Étape 2 si une tâche (active) est choisie.92 // Étape 2 si une tâche (active) est choisie.
@@ -106,8 +106,8 @@ pub async fn entry_new(
106 })106 })
107 .collect();107 .collect();
108 return Ok(HtmlTemplate(AddStep2Page {108 return Ok(HtmlTemplate(AddStep2Page {
109- shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?,109+ csrf: ctx.user.csrf_token(&state),
110- csrf: auth.csrf_token(&state),110+ shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
111 tache: picked.id,111 tache: picked.id,
112 picked_color: colors[&picked.id],112 picked_color: colors[&picked.id],
113 picked_name: picked.name.clone(),113 picked_name: picked.name.clone(),
@@ -128,10 +128,11 @@ pub async fn entry_new(
128 128
129 // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives.129 // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives.
130 let (wfrom, wto) = Week::current().bounds();130 let (wfrom, wto) = Week::current().bounds();
131- let week: HashMap<Uuid, i64> = entries::week_totals_for_user(&state.pool, auth.id, wfrom, wto)131+ let week: HashMap<Uuid, i64> =
132- .await?132+ entries::week_totals_for_user(&state.pool, ctx.user.id, ctx.team_id, wfrom, wto)
133- .into_iter()133+ .await?
134- .collect();134+ .into_iter()
135+ .collect();
135 let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect();136 let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect();
136 active.sort_by(|a, b| a.name.cmp(&b.name));137 active.sort_by(|a, b| a.name.cmp(&b.name));
137 let tasks = active138 let tasks = active
@@ -147,7 +148,7 @@ pub async fn entry_new(
147 })148 })
148 .collect();149 .collect();
149 Ok(HtmlTemplate(AddStep1Page {150 Ok(HtmlTemplate(AddStep1Page {
150- shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?,151+ shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
151 tasks,152 tasks,
152 })153 })
153 .into_response())154 .into_response())
@@ -172,14 +173,27 @@ fn validate(form: &EntryForm) -> Result<(), AppError> {
172 173
173 pub async fn entry_create(174 pub async fn entry_create(
174 State(state): State<AppState>,175 State(state): State<AppState>,
175- auth: AuthUser,176+ ctx: ActiveTeam,
176 Form(form): Form<EntryForm>,177 Form(form): Form<EntryForm>,
177 ) -> Result<Redirect, AppError> {178 ) -> Result<Redirect, AppError> {
178- if !auth.check_csrf(&state, &form.csrf) {179+ if !ctx.user.check_csrf(&state, &form.csrf) {
179 return Err(AppError::Csrf);180 return Err(AppError::Csrf);
180 }181 }
181 validate(&form)?;182 validate(&form)?;
182- entries::insert(&state.pool, auth.id, form.tache, form.minutes, form.date).await?;183+ let n = entries::insert(
184+ &state.pool,
185+ ctx.user.id,
186+ ctx.team_id,
187+ form.tache,
188+ form.minutes,
189+ form.date,
190+ )
191+ .await?;
192+ if n == 0 {
193+ return Err(AppError::BadRequest(
194+ "Cette tâche n'appartient pas à l'équipe active.".into(),
195+ ));
196+ }
183 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))197 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))
184 }198 }
185 199
@@ -209,7 +223,7 @@ pub struct PageQuery {
209 223
210 pub async fn entries_list(224 pub async fn entries_list(
211 State(state): State<AppState>,225 State(state): State<AppState>,
212- auth: AuthUser,226+ ctx: ActiveTeam,
213 Query(q): Query<PageQuery>,227 Query(q): Query<PageQuery>,
214 ) -> Result<HtmlTemplate<TimesPage>, AppError> {228 ) -> Result<HtmlTemplate<TimesPage>, AppError> {
215 let toast = match q.ok.as_deref() {229 let toast = match q.ok.as_deref() {
@@ -218,9 +232,10 @@ pub async fn entries_list(
218 _ => None,232 _ => None,
219 };233 };
220 let page = q.page.unwrap_or(1).max(1);234 let page = q.page.unwrap_or(1).max(1);
221- let (rows, has_more) = entries::list_first(&state.pool, auth.id, page * PAGE_SIZE).await?;235+ let (rows, has_more) =
236+ entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?;
222 237
223- let all_tasks = tasks::list_by_creation(&state.pool).await?;238+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
224 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());239 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
225 240
226 let entries = rows241 let entries = rows
@@ -234,7 +249,7 @@ pub async fn entries_list(
234 })249 })
235 .collect();250 .collect();
236 Ok(HtmlTemplate(TimesPage {251 Ok(HtmlTemplate(TimesPage {
237- shell: shell(&state, &auth, "Mes temps", "times", toast).await?,252+ shell: shell(&state, &ctx, "Mes temps", "times", toast).await?,
238 entries,253 entries,
239 more_href: has_more.then(|| format!("/temps?page={}", page + 1)),254 more_href: has_more.then(|| format!("/temps?page={}", page + 1)),
240 }))255 }))
@@ -262,14 +277,14 @@ pub struct EditPage {
262 277
263 pub async fn entry_edit(278 pub async fn entry_edit(
264 State(state): State<AppState>,279 State(state): State<AppState>,
265- auth: AuthUser,280+ ctx: ActiveTeam,
266 Path(id): Path<Uuid>,281 Path(id): Path<Uuid>,
267 ) -> Result<HtmlTemplate<EditPage>, AppError> {282 ) -> Result<HtmlTemplate<EditPage>, AppError> {
268- let entry = entries::get_owned(&state.pool, id, auth.id)283+ let entry = entries::get_owned(&state.pool, id, ctx.user.id, ctx.team_id)
269 .await?284 .await?
270 .ok_or(AppError::NotFound)?;285 .ok_or(AppError::NotFound)?;
271 // La tâche de l'entrée reste proposée même archivée depuis.286 // La tâche de l'entrée reste proposée même archivée depuis.
272- let mut options: Vec<TaskOption> = tasks::list_all(&state.pool)287+ let mut options: Vec<TaskOption> = tasks::list_all(&state.pool, ctx.team_id)
273 .await?288 .await?
274 .into_iter()289 .into_iter()
275 .filter(|t| !t.archived || t.id == entry.task_id)290 .filter(|t| !t.archived || t.id == entry.task_id)
@@ -285,8 +300,8 @@ pub async fn entry_edit(
285 .collect();300 .collect();
286 options.sort_by(|a, b| a.name.cmp(&b.name));301 options.sort_by(|a, b| a.name.cmp(&b.name));
287 Ok(HtmlTemplate(EditPage {302 Ok(HtmlTemplate(EditPage {
288- shell: shell(&state, &auth, "Modifier le temps", "times", None).await?,303+ csrf: ctx.user.csrf_token(&state),
289- csrf: auth.csrf_token(&state),304+ shell: shell(&state, &ctx, "Modifier le temps", "times", None).await?,
290 action: format!("/temps/{id}"),305 action: format!("/temps/{id}"),
291 delete_action: format!("/temps/{id}/supprimer"),306 delete_action: format!("/temps/{id}/supprimer"),
292 tasks: options,307 tasks: options,
@@ -297,18 +312,19 @@ pub async fn entry_edit(
297 312
298 pub async fn entry_update(313 pub async fn entry_update(
299 State(state): State<AppState>,314 State(state): State<AppState>,
300- auth: AuthUser,315+ ctx: ActiveTeam,
301 Path(id): Path<Uuid>,316 Path(id): Path<Uuid>,
302 Form(form): Form<EntryForm>,317 Form(form): Form<EntryForm>,
303 ) -> Result<Redirect, AppError> {318 ) -> Result<Redirect, AppError> {
304- if !auth.check_csrf(&state, &form.csrf) {319+ if !ctx.user.check_csrf(&state, &form.csrf) {
305 return Err(AppError::Csrf);320 return Err(AppError::Csrf);
306 }321 }
307 validate(&form)?;322 validate(&form)?;
308 let n = entries::update_owned(323 let n = entries::update_owned(
309 &state.pool,324 &state.pool,
310 id,325 id,
311- auth.id,326+ ctx.user.id,
327+ ctx.team_id,
312 form.tache,328 form.tache,
313 form.minutes,329 form.minutes,
314 form.date,330 form.date,
@@ -327,14 +343,14 @@ pub struct CsrfOnly {
327 343
328 pub async fn entry_delete(344 pub async fn entry_delete(
329 State(state): State<AppState>,345 State(state): State<AppState>,
330- auth: AuthUser,346+ ctx: ActiveTeam,
331 Path(id): Path<Uuid>,347 Path(id): Path<Uuid>,
332 Form(form): Form<CsrfOnly>,348 Form(form): Form<CsrfOnly>,
333 ) -> Result<Redirect, AppError> {349 ) -> Result<Redirect, AppError> {
334- if !auth.check_csrf(&state, &form.csrf) {350+ if !ctx.user.check_csrf(&state, &form.csrf) {
335 return Err(AppError::Csrf);351 return Err(AppError::Csrf);
336 }352 }
337- if entries::delete_owned(&state.pool, id, auth.id).await? == 0 {353+ if entries::delete_owned(&state.pool, id, ctx.user.id, ctx.team_id).await? == 0 {
338 return Err(AppError::NotFound);354 return Err(AppError::NotFound);
339 }355 }
340 Ok(Redirect::to("/temps?ok=suppr"))356 Ok(Redirect::to("/temps?ok=suppr"))
modified src/routes/login.rs +3 -1
@@ -87,6 +87,8 @@ pub async fn logout(
8787 revocation::revoke(&state.pool, &state.revocations, &auth.rev_ids)
8888 .await
8989 .map_err(AppError::Internal)?;
90- let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/"));
90+ let jar = jar
91+ .remove(Cookie::build((SESSION_COOKIE, "")).path("/"))
92+ .remove(crate::team::clear_team_cookie());
9193 Ok((jar, Redirect::to("/login")).into_response())
9294 }
@@ -87,6 +87,8 @@ pub async fn logout(
87 revocation::revoke(&state.pool, &state.revocations, &auth.rev_ids)87 revocation::revoke(&state.pool, &state.revocations, &auth.rev_ids)
88 .await88 .await
89 .map_err(AppError::Internal)?;89 .map_err(AppError::Internal)?;
90- let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/"));90+ let jar = jar
91+ .remove(Cookie::build((SESSION_COOKIE, "")).path("/"))
92+ .remove(crate::team::clear_team_cookie());
91 Ok((jar, Redirect::to("/login")).into_response())93 Ok((jar, Redirect::to("/login")).into_response())
92 }94 }
modified src/routes/mod.rs +17 -3
@@ -3,15 +3,18 @@ mod login;
33 mod settings;
44 mod statics;
55 mod stats;
6+mod teams;
67
78 use axum::Router;
89 use axum::response::Redirect;
910 use axum::routing::{get, post};
1011
1112 use crate::auth::extractor::AuthUser;
13+use crate::db::{invitations, users};
1214 use crate::error::AppError;
1315 use crate::render::Shell;
1416 use crate::state::AppState;
17+use crate::team::ActiveTeam;
1518
1619 pub fn router(state: AppState) -> Router {
1720 Router::new()
@@ -38,6 +41,14 @@ pub fn router(state: AppState) -> Router {
3841 "/parametres/mot-de-passe",
3942 post(settings::password_change),
4043 )
44+ .route("/equipes", post(teams::team_create))
45+ .route("/equipes/basculer", post(teams::team_switch))
46+ .route("/equipes/renommer", post(teams::team_rename))
47+ .route("/equipes/quitter", post(teams::team_leave))
48+ .route("/equipes/inviter", post(teams::invite_create))
49+ .route("/invitations/{id}/accepter", post(teams::invite_accept))
50+ .route("/invitations/{id}/refuser", post(teams::invite_refuse))
51+ .route("/invitations/{id}/annuler", post(teams::invite_cancel))
4152 .route("/static/style.css", get(statics::css))
4253 .route("/manifest.json", get(statics::manifest))
4354 .route("/icon-512.png", get(statics::icon))
@@ -54,21 +65,24 @@ async fn fallback_404() -> AppError {
5465 AppError::NotFound
5566 }
5667
57-/// Construit le shell (titre + username + tab + toast).
68+/// Construit le shell (titre + pill d'équipe + username + tab + toast + badge).
5869 pub async fn shell(
5970 state: &AppState,
60- auth: &AuthUser,
71+ ctx: &ActiveTeam,
6172 title: &str,
6273 tab: &'static str,
6374 toast: Option<String>,
6475 ) -> Result<Shell, AppError> {
65- let username = crate::db::users::username(&state.pool, auth.id)
76+ let username = users::username(&state.pool, ctx.user.id)
6677 .await?
6778 .unwrap_or_default();
79+ let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;
6880 Ok(Shell {
6981 title: title.to_string(),
7082 username,
83+ team_name: ctx.team_name.clone(),
7184 tab,
7285 toast,
86+ invite_count,
7387 })
7488 }
@@ -3,15 +3,18 @@ mod login;
3 mod settings;3 mod settings;
4 mod statics;4 mod statics;
5 mod stats;5 mod stats;
6+mod teams;
6 7
7 use axum::Router;8 use axum::Router;
8 use axum::response::Redirect;9 use axum::response::Redirect;
9 use axum::routing::{get, post};10 use axum::routing::{get, post};
10 11
11 use crate::auth::extractor::AuthUser;12 use crate::auth::extractor::AuthUser;
13+use crate::db::{invitations, users};
12 use crate::error::AppError;14 use crate::error::AppError;
13 use crate::render::Shell;15 use crate::render::Shell;
14 use crate::state::AppState;16 use crate::state::AppState;
17+use crate::team::ActiveTeam;
15 18
16 pub fn router(state: AppState) -> Router {19 pub fn router(state: AppState) -> Router {
17 Router::new()20 Router::new()
@@ -38,6 +41,14 @@ pub fn router(state: AppState) -> Router {
38 "/parametres/mot-de-passe",41 "/parametres/mot-de-passe",
39 post(settings::password_change),42 post(settings::password_change),
40 )43 )
44+ .route("/equipes", post(teams::team_create))
45+ .route("/equipes/basculer", post(teams::team_switch))
46+ .route("/equipes/renommer", post(teams::team_rename))
47+ .route("/equipes/quitter", post(teams::team_leave))
48+ .route("/equipes/inviter", post(teams::invite_create))
49+ .route("/invitations/{id}/accepter", post(teams::invite_accept))
50+ .route("/invitations/{id}/refuser", post(teams::invite_refuse))
51+ .route("/invitations/{id}/annuler", post(teams::invite_cancel))
41 .route("/static/style.css", get(statics::css))52 .route("/static/style.css", get(statics::css))
42 .route("/manifest.json", get(statics::manifest))53 .route("/manifest.json", get(statics::manifest))
43 .route("/icon-512.png", get(statics::icon))54 .route("/icon-512.png", get(statics::icon))
@@ -54,21 +65,24 @@ async fn fallback_404() -> AppError {
54 AppError::NotFound65 AppError::NotFound
55 }66 }
56 67
57-/// Construit le shell (titre + username + tab + toast).68+/// Construit le shell (titre + pill d'équipe + username + tab + toast + badge).
58 pub async fn shell(69 pub async fn shell(
59 state: &AppState,70 state: &AppState,
60- auth: &AuthUser,71+ ctx: &ActiveTeam,
61 title: &str,72 title: &str,
62 tab: &'static str,73 tab: &'static str,
63 toast: Option<String>,74 toast: Option<String>,
64 ) -> Result<Shell, AppError> {75 ) -> Result<Shell, AppError> {
65- let username = crate::db::users::username(&state.pool, auth.id)76+ let username = users::username(&state.pool, ctx.user.id)
66 .await?77 .await?
67 .unwrap_or_default();78 .unwrap_or_default();
79+ let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;
68 Ok(Shell {80 Ok(Shell {
69 title: title.to_string(),81 title: title.to_string(),
70 username,82 username,
83+ team_name: ctx.team_name.clone(),
71 tab,84 tab,
72 toast,85 toast,
86+ invite_count,
73 })87 })
74 }88 }
modified src/routes/settings.rs +126 -23
@@ -5,39 +5,81 @@ use axum::response::Redirect;
55 use serde::Deserialize;
66 use uuid::Uuid;
77
8-use crate::auth::extractor::AuthUser;
98 use crate::auth::password;
109 use crate::charts::color_map;
11-use crate::db::{tasks, users};
10+use crate::db::{invitations, tasks, teams, users};
1211 use crate::error::AppError;
1312 use crate::render::{HtmlTemplate, Shell};
1413 use crate::routes::shell;
1514 use crate::state::AppState;
15+use crate::team::ActiveTeam;
1616
1717 /// ─── Réglages ───
1818
19+pub struct InviteView {
20+ pub text: String,
21+ pub accept_action: String,
22+ pub refuse_action: String,
23+}
24+
25+pub struct TeamChoice {
26+ pub id: Uuid,
27+ pub name: String,
28+ pub meta: String,
29+ pub current: bool,
30+}
31+
32+pub struct SentView {
33+ pub name: String,
34+ pub meta: String,
35+ pub cancel_action: String,
36+}
37+
1938 #[derive(Template)]
2039 #[template(path = "settings.html")]
2140 pub struct SettingsPage {
2241 pub shell: Shell,
2342 pub csrf: String,
43+ /// Erreur inline du bloc mot de passe.
2444 pub error: Option<&'static str>,
45+ pub invites: Vec<InviteView>,
46+ pub account_line: String,
47+ pub my_teams: Vec<TeamChoice>,
48+ pub members: Vec<String>,
49+ /// Erreur inline du bloc invitation.
50+ pub invite_error: Option<String>,
51+ pub sent: Vec<SentView>,
52+ pub can_leave: bool,
2553 }
2654
2755 #[derive(Deserialize)]
2856 pub struct SettingsQuery {
2957 ok: Option<String>,
3058 err: Option<String>,
59+ /// Username rejoué dans les erreurs d'invitation.
60+ nom: Option<String>,
3161 }
3262
3363 pub async fn settings_page(
3464 State(state): State<AppState>,
35- auth: AuthUser,
65+ ctx: ActiveTeam,
3666 Query(q): Query<SettingsQuery>,
3767 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
3868 let toast = match q.ok.as_deref() {
3969 Some("mdp") => Some("Mot de passe mis à jour".to_string()),
40- _ => None,
70+ Some("invite") => Some("Invitation envoyée".to_string()),
71+ Some("rejoint") => Some(format!("Tu as rejoint {}", ctx.team_name)),
72+ Some("refus") => Some("Invitation refusée".to_string()),
73+ Some("annulee") => Some("Invitation annulée".to_string()),
74+ Some("equipe-creee") => Some(format!("Équipe {} créée", ctx.team_name)),
75+ Some("eq-renommee") => Some("Équipe renommée".to_string()),
76+ Some("quittee") => Some("Équipe quittée".to_string()),
77+ _ => match q.err.as_deref() {
78+ Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),
79+ Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),
80+ Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()),
81+ _ => None,
82+ },
4183 };
4284 let error = match q.err.as_deref() {
4385 Some("mdp") => Some("L'ancien mot de passe est incorrect."),
@@ -45,10 +87,71 @@ pub async fn settings_page(
4587 Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."),
4688 _ => None,
4789 };
90+ let nom = q.nom.as_deref().unwrap_or("");
91+ let invite_error = match q.err.as_deref() {
92+ Some("inconnu") => Some(format!("Aucun compte « {nom} ».")),
93+ Some("deja-membre") => Some(format!("{nom} fait déjà partie de l'équipe.")),
94+ Some("deja-invite") => Some(format!("Une invitation est déjà en attente pour {nom}.")),
95+ _ => None,
96+ };
97+
98+ let invites = invitations::list_for(&state.pool, ctx.user.id)
99+ .await?
100+ .into_iter()
101+ .map(|i| InviteView {
102+ text: format!("{} t’invite à rejoindre l’équipe « {} ».", i.inviter, i.team_name),
103+ accept_action: format!("/invitations/{}/accepter", i.id),
104+ refuse_action: format!("/invitations/{}/refuser", i.id),
105+ })
106+ .collect();
107+
108+ let my_teams: Vec<TeamChoice> = teams::teams_of(&state.pool, ctx.user.id)
109+ .await?
110+ .into_iter()
111+ .map(|t| TeamChoice {
112+ current: t.id == ctx.team_id,
113+ meta: format!("{} membre{}", t.members, if t.members > 1 { "s" } else { "" }),
114+ id: t.id,
115+ name: t.name,
116+ })
117+ .collect();
118+ let can_leave = my_teams.len() > 1;
119+ let account_line = if my_teams.len() > 1 {
120+ format!(
121+ "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
122+ my_teams.len()
123+ )
124+ } else {
125+ "Les comptes sont créés en ligne de commande.".to_string()
126+ };
127+
128+ let members = teams::members(&state.pool, ctx.team_id)
129+ .await?
130+ .into_iter()
131+ .map(|u| u.username)
132+ .collect();
133+
134+ let sent = invitations::list_sent(&state.pool, ctx.team_id)
135+ .await?
136+ .into_iter()
137+ .map(|i| SentView {
138+ name: i.invitee,
139+ meta: format!("invité par {} · en attente", i.inviter),
140+ cancel_action: format!("/invitations/{}/annuler", i.id),
141+ })
142+ .collect();
143+
48144 Ok(HtmlTemplate(SettingsPage {
49- shell: shell(&state, &auth, "Paramètres", "settings", toast).await?,
50- csrf: auth.csrf_token(&state),
145+ csrf: ctx.user.csrf_token(&state),
146+ shell: shell(&state, &ctx, "Paramètres", "settings", toast).await?,
51147 error,
148+ invites,
149+ account_line,
150+ my_teams,
151+ members,
152+ invite_error,
153+ sent,
154+ can_leave,
52155 }))
53156 }
54157
@@ -72,7 +175,7 @@ pub struct TasksPage {
72175
73176 pub async fn tasks_page(
74177 State(state): State<AppState>,
75- auth: AuthUser,
178+ ctx: ActiveTeam,
76179 Query(q): Query<SettingsQuery>,
77180 ) -> Result<HtmlTemplate<TasksPage>, AppError> {
78181 let toast = match (q.ok.as_deref(), q.err.as_deref()) {
@@ -86,7 +189,7 @@ pub async fn tasks_page(
86189 (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()),
87190 _ => None,
88191 };
89- let by_creation = tasks::list_by_creation(&state.pool).await?;
192+ let by_creation = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
90193 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>());
91194 let mut list = by_creation;
92195 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
@@ -101,8 +204,8 @@ pub async fn tasks_page(
101204 })
102205 .collect();
103206 Ok(HtmlTemplate(TasksPage {
104- shell: shell(&state, &auth, "Tâches", "settings", toast).await?,
105- csrf: auth.csrf_token(&state),
207+ csrf: ctx.user.csrf_token(&state),
208+ shell: shell(&state, &ctx, "Tâches", "settings", toast).await?,
106209 tasks,
107210 }))
108211 }
@@ -119,17 +222,17 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
119222
120223 pub async fn task_create(
121224 State(state): State<AppState>,
122- auth: AuthUser,
225+ ctx: ActiveTeam,
123226 Form(form): Form<TaskForm>,
124227 ) -> Result<Redirect, AppError> {
125- if !auth.check_csrf(&state, &form.csrf) {
228+ if !ctx.user.check_csrf(&state, &form.csrf) {
126229 return Err(AppError::Csrf);
127230 }
128231 let name = form.name.trim();
129232 if name.is_empty() {
130233 return Ok(Redirect::to("/parametres/taches?err=vide"));
131234 }
132- match tasks::create(&state.pool, name).await {
235+ match tasks::create(&state.pool, ctx.team_id, name).await {
133236 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")),
134237 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),
135238 Err(e) => Err(e.into()),
@@ -138,18 +241,18 @@ pub async fn task_create(
138241
139242 pub async fn task_rename(
140243 State(state): State<AppState>,
141- auth: AuthUser,
244+ ctx: ActiveTeam,
142245 Path(id): Path<Uuid>,
143246 Form(form): Form<TaskForm>,
144247 ) -> Result<Redirect, AppError> {
145- if !auth.check_csrf(&state, &form.csrf) {
248+ if !ctx.user.check_csrf(&state, &form.csrf) {
146249 return Err(AppError::Csrf);
147250 }
148251 let name = form.name.trim();
149252 if name.is_empty() {
150253 return Ok(Redirect::to("/parametres/taches?err=vide"));
151254 }
152- match tasks::rename(&state.pool, id, name).await {
255+ match tasks::rename(&state.pool, id, ctx.team_id, name).await {
153256 Ok(0) => Err(AppError::NotFound),
154257 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")),
155258 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),
@@ -166,14 +269,14 @@ pub struct ToggleForm {
166269
167270 pub async fn task_toggle_archive(
168271 State(state): State<AppState>,
169- auth: AuthUser,
272+ ctx: ActiveTeam,
170273 Path(id): Path<Uuid>,
171274 Form(form): Form<ToggleForm>,
172275 ) -> Result<Redirect, AppError> {
173- if !auth.check_csrf(&state, &form.csrf) {
276+ if !ctx.user.check_csrf(&state, &form.csrf) {
174277 return Err(AppError::Csrf);
175278 }
176- if tasks::toggle_archived(&state.pool, id).await? == 0 {
279+ if tasks::toggle_archived(&state.pool, id, ctx.team_id).await? == 0 {
177280 return Err(AppError::NotFound);
178281 }
179282 Ok(Redirect::to(if form.restaure.is_some() {
@@ -193,10 +296,10 @@ pub struct PasswordForm {
193296
194297 pub async fn password_change(
195298 State(state): State<AppState>,
196- auth: AuthUser,
299+ ctx: ActiveTeam,
197300 Form(form): Form<PasswordForm>,
198301 ) -> Result<Redirect, AppError> {
199- if !auth.check_csrf(&state, &form.csrf) {
302+ if !ctx.user.check_csrf(&state, &form.csrf) {
200303 return Err(AppError::Csrf);
201304 }
202305 if form.nouveau != form.confirmation {
@@ -205,13 +308,13 @@ pub async fn password_change(
205308 if form.nouveau.len() < 8 {
206309 return Ok(Redirect::to("/parametres?err=court"));
207310 }
208- let current = users::password_hash(&state.pool, auth.id)
311+ let current = users::password_hash(&state.pool, ctx.user.id)
209312 .await?
210313 .ok_or(AppError::NotFound)?;
211314 if !password::verify(&form.ancien, &current) {
212315 return Ok(Redirect::to("/parametres?err=mdp"));
213316 }
214317 let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
215- users::update_password(&state.pool, auth.id, &new_hash).await?;
318+ users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
216319 Ok(Redirect::to("/parametres?ok=mdp"))
217320 }
@@ -5,39 +5,81 @@ use axum::response::Redirect;
5 use serde::Deserialize;5 use serde::Deserialize;
6 use uuid::Uuid;6 use uuid::Uuid;
7 7
8-use crate::auth::extractor::AuthUser;
9 use crate::auth::password;8 use crate::auth::password;
10 use crate::charts::color_map;9 use crate::charts::color_map;
11-use crate::db::{tasks, users};10+use crate::db::{invitations, tasks, teams, users};
12 use crate::error::AppError;11 use crate::error::AppError;
13 use crate::render::{HtmlTemplate, Shell};12 use crate::render::{HtmlTemplate, Shell};
14 use crate::routes::shell;13 use crate::routes::shell;
15 use crate::state::AppState;14 use crate::state::AppState;
15+use crate::team::ActiveTeam;
16 16
17 /// ─── Réglages ───17 /// ─── Réglages ───
18 18
19+pub struct InviteView {
20+ pub text: String,
21+ pub accept_action: String,
22+ pub refuse_action: String,
23+}
24+
25+pub struct TeamChoice {
26+ pub id: Uuid,
27+ pub name: String,
28+ pub meta: String,
29+ pub current: bool,
30+}
31+
32+pub struct SentView {
33+ pub name: String,
34+ pub meta: String,
35+ pub cancel_action: String,
36+}
37+
19 #[derive(Template)]38 #[derive(Template)]
20 #[template(path = "settings.html")]39 #[template(path = "settings.html")]
21 pub struct SettingsPage {40 pub struct SettingsPage {
22 pub shell: Shell,41 pub shell: Shell,
23 pub csrf: String,42 pub csrf: String,
43+ /// Erreur inline du bloc mot de passe.
24 pub error: Option<&'static str>,44 pub error: Option<&'static str>,
45+ pub invites: Vec<InviteView>,
46+ pub account_line: String,
47+ pub my_teams: Vec<TeamChoice>,
48+ pub members: Vec<String>,
49+ /// Erreur inline du bloc invitation.
50+ pub invite_error: Option<String>,
51+ pub sent: Vec<SentView>,
52+ pub can_leave: bool,
25 }53 }
26 54
27 #[derive(Deserialize)]55 #[derive(Deserialize)]
28 pub struct SettingsQuery {56 pub struct SettingsQuery {
29 ok: Option<String>,57 ok: Option<String>,
30 err: Option<String>,58 err: Option<String>,
59+ /// Username rejoué dans les erreurs d'invitation.
60+ nom: Option<String>,
31 }61 }
32 62
33 pub async fn settings_page(63 pub async fn settings_page(
34 State(state): State<AppState>,64 State(state): State<AppState>,
35- auth: AuthUser,65+ ctx: ActiveTeam,
36 Query(q): Query<SettingsQuery>,66 Query(q): Query<SettingsQuery>,
37 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {67 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
38 let toast = match q.ok.as_deref() {68 let toast = match q.ok.as_deref() {
39 Some("mdp") => Some("Mot de passe mis à jour".to_string()),69 Some("mdp") => Some("Mot de passe mis à jour".to_string()),
40- _ => None,70+ Some("invite") => Some("Invitation envoyée".to_string()),
71+ Some("rejoint") => Some(format!("Tu as rejoint {}", ctx.team_name)),
72+ Some("refus") => Some("Invitation refusée".to_string()),
73+ Some("annulee") => Some("Invitation annulée".to_string()),
74+ Some("equipe-creee") => Some(format!("Équipe {} créée", ctx.team_name)),
75+ Some("eq-renommee") => Some("Équipe renommée".to_string()),
76+ Some("quittee") => Some("Équipe quittée".to_string()),
77+ _ => match q.err.as_deref() {
78+ Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),
79+ Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),
80+ Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()),
81+ _ => None,
82+ },
41 };83 };
42 let error = match q.err.as_deref() {84 let error = match q.err.as_deref() {
43 Some("mdp") => Some("L'ancien mot de passe est incorrect."),85 Some("mdp") => Some("L'ancien mot de passe est incorrect."),
@@ -45,10 +87,71 @@ pub async fn settings_page(
45 Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."),87 Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."),
46 _ => None,88 _ => None,
47 };89 };
90+ let nom = q.nom.as_deref().unwrap_or("");
91+ let invite_error = match q.err.as_deref() {
92+ Some("inconnu") => Some(format!("Aucun compte « {nom} ».")),
93+ Some("deja-membre") => Some(format!("{nom} fait déjà partie de l'équipe.")),
94+ Some("deja-invite") => Some(format!("Une invitation est déjà en attente pour {nom}.")),
95+ _ => None,
96+ };
97+
98+ let invites = invitations::list_for(&state.pool, ctx.user.id)
99+ .await?
100+ .into_iter()
101+ .map(|i| InviteView {
102+ text: format!("{} t’invite à rejoindre l’équipe « {} ».", i.inviter, i.team_name),
103+ accept_action: format!("/invitations/{}/accepter", i.id),
104+ refuse_action: format!("/invitations/{}/refuser", i.id),
105+ })
106+ .collect();
107+
108+ let my_teams: Vec<TeamChoice> = teams::teams_of(&state.pool, ctx.user.id)
109+ .await?
110+ .into_iter()
111+ .map(|t| TeamChoice {
112+ current: t.id == ctx.team_id,
113+ meta: format!("{} membre{}", t.members, if t.members > 1 { "s" } else { "" }),
114+ id: t.id,
115+ name: t.name,
116+ })
117+ .collect();
118+ let can_leave = my_teams.len() > 1;
119+ let account_line = if my_teams.len() > 1 {
120+ format!(
121+ "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
122+ my_teams.len()
123+ )
124+ } else {
125+ "Les comptes sont créés en ligne de commande.".to_string()
126+ };
127+
128+ let members = teams::members(&state.pool, ctx.team_id)
129+ .await?
130+ .into_iter()
131+ .map(|u| u.username)
132+ .collect();
133+
134+ let sent = invitations::list_sent(&state.pool, ctx.team_id)
135+ .await?
136+ .into_iter()
137+ .map(|i| SentView {
138+ name: i.invitee,
139+ meta: format!("invité par {} · en attente", i.inviter),
140+ cancel_action: format!("/invitations/{}/annuler", i.id),
141+ })
142+ .collect();
143+
48 Ok(HtmlTemplate(SettingsPage {144 Ok(HtmlTemplate(SettingsPage {
49- shell: shell(&state, &auth, "Paramètres", "settings", toast).await?,145+ csrf: ctx.user.csrf_token(&state),
50- csrf: auth.csrf_token(&state),146+ shell: shell(&state, &ctx, "Paramètres", "settings", toast).await?,
51 error,147 error,
148+ invites,
149+ account_line,
150+ my_teams,
151+ members,
152+ invite_error,
153+ sent,
154+ can_leave,
52 }))155 }))
53 }156 }
54 157
@@ -72,7 +175,7 @@ pub struct TasksPage {
72 175
73 pub async fn tasks_page(176 pub async fn tasks_page(
74 State(state): State<AppState>,177 State(state): State<AppState>,
75- auth: AuthUser,178+ ctx: ActiveTeam,
76 Query(q): Query<SettingsQuery>,179 Query(q): Query<SettingsQuery>,
77 ) -> Result<HtmlTemplate<TasksPage>, AppError> {180 ) -> Result<HtmlTemplate<TasksPage>, AppError> {
78 let toast = match (q.ok.as_deref(), q.err.as_deref()) {181 let toast = match (q.ok.as_deref(), q.err.as_deref()) {
@@ -86,7 +189,7 @@ pub async fn tasks_page(
86 (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()),189 (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()),
87 _ => None,190 _ => None,
88 };191 };
89- let by_creation = tasks::list_by_creation(&state.pool).await?;192+ let by_creation = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
90 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>());193 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>());
91 let mut list = by_creation;194 let mut list = by_creation;
92 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));195 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
@@ -101,8 +204,8 @@ pub async fn tasks_page(
101 })204 })
102 .collect();205 .collect();
103 Ok(HtmlTemplate(TasksPage {206 Ok(HtmlTemplate(TasksPage {
104- shell: shell(&state, &auth, "Tâches", "settings", toast).await?,207+ csrf: ctx.user.csrf_token(&state),
105- csrf: auth.csrf_token(&state),208+ shell: shell(&state, &ctx, "Tâches", "settings", toast).await?,
106 tasks,209 tasks,
107 }))210 }))
108 }211 }
@@ -119,17 +222,17 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
119 222
120 pub async fn task_create(223 pub async fn task_create(
121 State(state): State<AppState>,224 State(state): State<AppState>,
122- auth: AuthUser,225+ ctx: ActiveTeam,
123 Form(form): Form<TaskForm>,226 Form(form): Form<TaskForm>,
124 ) -> Result<Redirect, AppError> {227 ) -> Result<Redirect, AppError> {
125- if !auth.check_csrf(&state, &form.csrf) {228+ if !ctx.user.check_csrf(&state, &form.csrf) {
126 return Err(AppError::Csrf);229 return Err(AppError::Csrf);
127 }230 }
128 let name = form.name.trim();231 let name = form.name.trim();
129 if name.is_empty() {232 if name.is_empty() {
130 return Ok(Redirect::to("/parametres/taches?err=vide"));233 return Ok(Redirect::to("/parametres/taches?err=vide"));
131 }234 }
132- match tasks::create(&state.pool, name).await {235+ match tasks::create(&state.pool, ctx.team_id, name).await {
133 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")),236 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")),
134 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),237 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),
135 Err(e) => Err(e.into()),238 Err(e) => Err(e.into()),
@@ -138,18 +241,18 @@ pub async fn task_create(
138 241
139 pub async fn task_rename(242 pub async fn task_rename(
140 State(state): State<AppState>,243 State(state): State<AppState>,
141- auth: AuthUser,244+ ctx: ActiveTeam,
142 Path(id): Path<Uuid>,245 Path(id): Path<Uuid>,
143 Form(form): Form<TaskForm>,246 Form(form): Form<TaskForm>,
144 ) -> Result<Redirect, AppError> {247 ) -> Result<Redirect, AppError> {
145- if !auth.check_csrf(&state, &form.csrf) {248+ if !ctx.user.check_csrf(&state, &form.csrf) {
146 return Err(AppError::Csrf);249 return Err(AppError::Csrf);
147 }250 }
148 let name = form.name.trim();251 let name = form.name.trim();
149 if name.is_empty() {252 if name.is_empty() {
150 return Ok(Redirect::to("/parametres/taches?err=vide"));253 return Ok(Redirect::to("/parametres/taches?err=vide"));
151 }254 }
152- match tasks::rename(&state.pool, id, name).await {255+ match tasks::rename(&state.pool, id, ctx.team_id, name).await {
153 Ok(0) => Err(AppError::NotFound),256 Ok(0) => Err(AppError::NotFound),
154 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")),257 Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")),
155 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),258 Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")),
@@ -166,14 +269,14 @@ pub struct ToggleForm {
166 269
167 pub async fn task_toggle_archive(270 pub async fn task_toggle_archive(
168 State(state): State<AppState>,271 State(state): State<AppState>,
169- auth: AuthUser,272+ ctx: ActiveTeam,
170 Path(id): Path<Uuid>,273 Path(id): Path<Uuid>,
171 Form(form): Form<ToggleForm>,274 Form(form): Form<ToggleForm>,
172 ) -> Result<Redirect, AppError> {275 ) -> Result<Redirect, AppError> {
173- if !auth.check_csrf(&state, &form.csrf) {276+ if !ctx.user.check_csrf(&state, &form.csrf) {
174 return Err(AppError::Csrf);277 return Err(AppError::Csrf);
175 }278 }
176- if tasks::toggle_archived(&state.pool, id).await? == 0 {279+ if tasks::toggle_archived(&state.pool, id, ctx.team_id).await? == 0 {
177 return Err(AppError::NotFound);280 return Err(AppError::NotFound);
178 }281 }
179 Ok(Redirect::to(if form.restaure.is_some() {282 Ok(Redirect::to(if form.restaure.is_some() {
@@ -193,10 +296,10 @@ pub struct PasswordForm {
193 296
194 pub async fn password_change(297 pub async fn password_change(
195 State(state): State<AppState>,298 State(state): State<AppState>,
196- auth: AuthUser,299+ ctx: ActiveTeam,
197 Form(form): Form<PasswordForm>,300 Form(form): Form<PasswordForm>,
198 ) -> Result<Redirect, AppError> {301 ) -> Result<Redirect, AppError> {
199- if !auth.check_csrf(&state, &form.csrf) {302+ if !ctx.user.check_csrf(&state, &form.csrf) {
200 return Err(AppError::Csrf);303 return Err(AppError::Csrf);
201 }304 }
202 if form.nouveau != form.confirmation {305 if form.nouveau != form.confirmation {
@@ -205,13 +308,13 @@ pub async fn password_change(
205 if form.nouveau.len() < 8 {308 if form.nouveau.len() < 8 {
206 return Ok(Redirect::to("/parametres?err=court"));309 return Ok(Redirect::to("/parametres?err=court"));
207 }310 }
208- let current = users::password_hash(&state.pool, auth.id)311+ let current = users::password_hash(&state.pool, ctx.user.id)
209 .await?312 .await?
210 .ok_or(AppError::NotFound)?;313 .ok_or(AppError::NotFound)?;
211 if !password::verify(&form.ancien, &current) {314 if !password::verify(&form.ancien, &current) {
212 return Ok(Redirect::to("/parametres?err=mdp"));315 return Ok(Redirect::to("/parametres?err=mdp"));
213 }316 }
214 let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;317 let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
215- users::update_password(&state.pool, auth.id, &new_hash).await?;318+ users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
216 Ok(Redirect::to("/parametres?ok=mdp"))319 Ok(Redirect::to("/parametres?ok=mdp"))
217 }320 }
modified src/routes/stats.rs +31 -9
@@ -1,15 +1,19 @@
1+use std::collections::BTreeMap;
2+
13 use askama::Template;
24 use axum::extract::{Query, State};
35 use serde::Deserialize;
6+use uuid::Uuid;
47
5-use crate::auth::extractor::AuthUser;
68 use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend};
7-use crate::db::{stats, tasks, users};
9+use crate::db::users::UserRow;
10+use crate::db::{stats, tasks, teams};
811 use crate::error::AppError;
912 use crate::period::{Month, Week};
1013 use crate::render::{HtmlTemplate, Shell};
1114 use crate::routes::shell;
1215 use crate::state::AppState;
16+use crate::team::ActiveTeam;
1317
1418 #[derive(Template)]
1519 #[template(path = "stats.html")]
@@ -32,7 +36,7 @@ fn href(week: Week, month: Month) -> String {
3236
3337 pub async fn stats_page(
3438 State(state): State<AppState>,
35- auth: AuthUser,
39+ ctx: ActiveTeam,
3640 Query(q): Query<StatsQuery>,
3741 ) -> Result<HtmlTemplate<StatsPage>, AppError> {
3842 let week = q
@@ -49,13 +53,31 @@ pub async fn stats_page(
4953 let (week_from, week_to) = week.bounds();
5054 let (month_from, month_to) = month.bounds();
5155
52- let all_users = users::list_all(&state.pool).await?;
53- let all_tasks = tasks::list_by_creation(&state.pool).await?;
56+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
5457 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
5558
56- let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?;
57- let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?;
58- let total_rows = stats::totals(&state.pool, None, None).await?;
59+ let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;
60+ let month_rows =
61+ stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?;
62+ let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;
63+
64+ // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps
65+ // sur les tâches de l'équipe (historique intact), triés par username.
66+ let mut by_name: BTreeMap<String, Uuid> = teams::members(&state.pool, ctx.team_id)
67+ .await?
68+ .into_iter()
69+ .map(|u| (u.username, u.id))
70+ .collect();
71+ for r in [&week_rows, &month_rows, &total_rows]
72+ .into_iter()
73+ .flatten()
74+ {
75+ by_name.entry(r.username.clone()).or_insert(r.user_id);
76+ }
77+ let all_users: Vec<UserRow> = by_name
78+ .into_iter()
79+ .map(|(username, id)| UserRow { id, username })
80+ .collect();
5981
6082 let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
6183
@@ -96,7 +118,7 @@ pub async fn stats_page(
96118 sheets.extend(s3);
97119
98120 Ok(HtmlTemplate(StatsPage {
99- shell: shell(&state, &auth, "Statistiques", "stats", None).await?,
121+ shell: shell(&state, &ctx, "Statistiques", "stats", None).await?,
100122 legend,
101123 charts: vec![week_chart, month_chart, total_chart],
102124 sheets,
@@ -1,15 +1,19 @@
1+use std::collections::BTreeMap;
2+
1 use askama::Template;3 use askama::Template;
2 use axum::extract::{Query, State};4 use axum::extract::{Query, State};
3 use serde::Deserialize;5 use serde::Deserialize;
6+use uuid::Uuid;
4 7
5-use crate::auth::extractor::AuthUser;
6 use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend};8 use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend};
7-use crate::db::{stats, tasks, users};9+use crate::db::users::UserRow;
10+use crate::db::{stats, tasks, teams};
8 use crate::error::AppError;11 use crate::error::AppError;
9 use crate::period::{Month, Week};12 use crate::period::{Month, Week};
10 use crate::render::{HtmlTemplate, Shell};13 use crate::render::{HtmlTemplate, Shell};
11 use crate::routes::shell;14 use crate::routes::shell;
12 use crate::state::AppState;15 use crate::state::AppState;
16+use crate::team::ActiveTeam;
13 17
14 #[derive(Template)]18 #[derive(Template)]
15 #[template(path = "stats.html")]19 #[template(path = "stats.html")]
@@ -32,7 +36,7 @@ fn href(week: Week, month: Month) -> String {
32 36
33 pub async fn stats_page(37 pub async fn stats_page(
34 State(state): State<AppState>,38 State(state): State<AppState>,
35- auth: AuthUser,39+ ctx: ActiveTeam,
36 Query(q): Query<StatsQuery>,40 Query(q): Query<StatsQuery>,
37 ) -> Result<HtmlTemplate<StatsPage>, AppError> {41 ) -> Result<HtmlTemplate<StatsPage>, AppError> {
38 let week = q42 let week = q
@@ -49,13 +53,31 @@ pub async fn stats_page(
49 let (week_from, week_to) = week.bounds();53 let (week_from, week_to) = week.bounds();
50 let (month_from, month_to) = month.bounds();54 let (month_from, month_to) = month.bounds();
51 55
52- let all_users = users::list_all(&state.pool).await?;56+ let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
53- let all_tasks = tasks::list_by_creation(&state.pool).await?;
54 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());57 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
55 58
56- let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?;59+ let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;
57- let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?;60+ let month_rows =
58- let total_rows = stats::totals(&state.pool, None, None).await?;61+ stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?;
62+ let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;
63+
64+ // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps
65+ // sur les tâches de l'équipe (historique intact), triés par username.
66+ let mut by_name: BTreeMap<String, Uuid> = teams::members(&state.pool, ctx.team_id)
67+ .await?
68+ .into_iter()
69+ .map(|u| (u.username, u.id))
70+ .collect();
71+ for r in [&week_rows, &month_rows, &total_rows]
72+ .into_iter()
73+ .flatten()
74+ {
75+ by_name.entry(r.username.clone()).or_insert(r.user_id);
76+ }
77+ let all_users: Vec<UserRow> = by_name
78+ .into_iter()
79+ .map(|(username, id)| UserRow { id, username })
80+ .collect();
59 81
60 let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);82 let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
61 83
@@ -96,7 +118,7 @@ pub async fn stats_page(
96 sheets.extend(s3);118 sheets.extend(s3);
97 119
98 Ok(HtmlTemplate(StatsPage {120 Ok(HtmlTemplate(StatsPage {
99- shell: shell(&state, &auth, "Statistiques", "stats", None).await?,121+ shell: shell(&state, &ctx, "Statistiques", "stats", None).await?,
100 legend,122 legend,
101 charts: vec![week_chart, month_chart, total_chart],123 charts: vec![week_chart, month_chart, total_chart],
102 sheets,124 sheets,
added src/routes/teams.rs +224 -0
new file mode 100644
@@ -0,0 +1,224 @@
1+use axum::Form;
2+use axum::extract::{Path, State};
3+use axum::response::{IntoResponse, Redirect, Response};
4+use axum_extra::extract::CookieJar;
5+use serde::Deserialize;
6+use uuid::Uuid;
7+
8+use crate::db::{invitations, teams, users};
9+use crate::error::AppError;
10+use crate::state::AppState;
11+use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};
12+
13+fn is_unique_violation(e: &sqlx::Error) -> bool {
14+ matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
15+}
16+
17+/// Encodage minimal pour rejouer un username dans une query string.
18+fn urlenc(s: &str) -> String {
19+ let mut out = String::with_capacity(s.len());
20+ for b in s.bytes() {
21+ match b {
22+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
23+ out.push(b as char)
24+ }
25+ _ => out.push_str(&format!("%{b:02X}")),
26+ }
27+ }
28+ out
29+}
30+
31+#[derive(Deserialize)]
32+pub struct NameForm {
33+ nom: String,
34+ csrf: String,
35+}
36+
37+/// POST /equipes — créer une équipe depuis l'UI et basculer dessus.
38+pub async fn team_create(
39+ State(state): State<AppState>,
40+ ctx: ActiveTeam,
41+ jar: CookieJar,
42+ Form(form): Form<NameForm>,
43+) -> Result<Response, AppError> {
44+ if !ctx.user.check_csrf(&state, &form.csrf) {
45+ return Err(AppError::Csrf);
46+ }
47+ let name = form.nom.trim();
48+ if name.is_empty() {
49+ return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
50+ }
51+ match teams::create_with_member(&state.pool, name, ctx.user.id).await {
52+ Ok(id) => Ok((
53+ jar.add(team_cookie(id)),
54+ Redirect::to("/parametres?ok=equipe-creee"),
55+ )
56+ .into_response()),
57+ Err(e) if is_unique_violation(&e) => {
58+ Ok(Redirect::to("/parametres?err=eq-doublon").into_response())
59+ }
60+ Err(e) => Err(e.into()),
61+ }
62+}
63+
64+#[derive(Deserialize)]
65+pub struct SwitchForm {
66+ equipe: Uuid,
67+ csrf: String,
68+}
69+
70+/// POST /equipes/basculer — change l'équipe active (cookie par appareil).
71+pub async fn team_switch(
72+ State(state): State<AppState>,
73+ ctx: ActiveTeam,
74+ jar: CookieJar,
75+ Form(form): Form<SwitchForm>,
76+) -> Result<Response, AppError> {
77+ if !ctx.user.check_csrf(&state, &form.csrf) {
78+ return Err(AppError::Csrf);
79+ }
80+ if teams::membership(&state.pool, ctx.user.id, form.equipe)
81+ .await?
82+ .is_none()
83+ {
84+ return Err(AppError::NotFound);
85+ }
86+ Ok((jar.add(team_cookie(form.equipe)), Redirect::to("/parametres")).into_response())
87+}
88+
89+/// POST /equipes/renommer — tout membre peut renommer l'équipe active.
90+pub async fn team_rename(
91+ State(state): State<AppState>,
92+ ctx: ActiveTeam,
93+ Form(form): Form<NameForm>,
94+) -> Result<Redirect, AppError> {
95+ if !ctx.user.check_csrf(&state, &form.csrf) {
96+ return Err(AppError::Csrf);
97+ }
98+ let name = form.nom.trim();
99+ if name.is_empty() {
100+ return Ok(Redirect::to("/parametres?err=eq-vide"));
101+ }
102+ match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await {
103+ Ok(0) => Err(AppError::NotFound),
104+ Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")),
105+ Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")),
106+ Err(e) => Err(e.into()),
107+ }
108+}
109+
110+#[derive(Deserialize)]
111+pub struct CsrfOnly {
112+ csrf: String,
113+}
114+
115+/// POST /equipes/quitter — bloqué sur la dernière équipe (re-vérifié ici,
116+/// pas seulement masqué dans l'UI). L'équipe vidée reste en base.
117+pub async fn team_leave(
118+ State(state): State<AppState>,
119+ ctx: ActiveTeam,
120+ jar: CookieJar,
121+ Form(form): Form<CsrfOnly>,
122+) -> Result<Response, AppError> {
123+ if !ctx.user.check_csrf(&state, &form.csrf) {
124+ return Err(AppError::Csrf);
125+ }
126+ if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {
127+ return Ok(Redirect::to("/parametres?err=derniere").into_response());
128+ }
129+ teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
130+ // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
131+ Ok((
132+ jar.remove(clear_team_cookie()),
133+ Redirect::to("/parametres?ok=quittee"),
134+ )
135+ .into_response())
136+}
137+
138+#[derive(Deserialize)]
139+pub struct InviteForm {
140+ username: String,
141+ csrf: String,
142+}
143+
144+/// POST /equipes/inviter — par username, erreurs explicites (petit groupe
145+/// qui se connaît : pas de réponse neutre anti-énumération).
146+pub async fn invite_create(
147+ State(state): State<AppState>,
148+ ctx: ActiveTeam,
149+ Form(form): Form<InviteForm>,
150+) -> Result<Redirect, AppError> {
151+ if !ctx.user.check_csrf(&state, &form.csrf) {
152+ return Err(AppError::Csrf);
153+ }
154+ let username = form.username.trim();
155+ let enc = urlenc(username);
156+ let Some(target) = users::find_by_username(&state.pool, username).await? else {
157+ return Ok(Redirect::to(&format!("/parametres?err=inconnu&nom={enc}")));
158+ };
159+ if target.id == ctx.user.id
160+ || teams::membership(&state.pool, target.id, ctx.team_id)
161+ .await?
162+ .is_some()
163+ {
164+ return Ok(Redirect::to(&format!(
165+ "/parametres?err=deja-membre&nom={enc}"
166+ )));
167+ }
168+ if invitations::create(&state.pool, ctx.team_id, target.id, ctx.user.id).await? {
169+ Ok(Redirect::to("/parametres?ok=invite"))
170+ } else {
171+ Ok(Redirect::to(&format!(
172+ "/parametres?err=deja-invite&nom={enc}"
173+ )))
174+ }
175+}
176+
177+/// POST /invitations/{id}/accepter — rejoint l'équipe ET bascule dessus.
178+pub async fn invite_accept(
179+ State(state): State<AppState>,
180+ ctx: ActiveTeam,
181+ jar: CookieJar,
182+ Path(id): Path<Uuid>,
183+ Form(form): Form<CsrfOnly>,
184+) -> Result<Response, AppError> {
185+ if !ctx.user.check_csrf(&state, &form.csrf) {
186+ return Err(AppError::Csrf);
187+ }
188+ match invitations::accept(&state.pool, id, ctx.user.id).await? {
189+ Some(team_id) => Ok((
190+ jar.add(team_cookie(team_id)),
191+ Redirect::to("/parametres?ok=rejoint"),
192+ )
193+ .into_response()),
194+ None => Err(AppError::NotFound),
195+ }
196+}
197+
198+/// POST /invitations/{id}/refuser — silencieux, ré-invitable.
199+pub async fn invite_refuse(
200+ State(state): State<AppState>,
201+ ctx: ActiveTeam,
202+ Path(id): Path<Uuid>,
203+ Form(form): Form<CsrfOnly>,
204+) -> Result<Redirect, AppError> {
205+ if !ctx.user.check_csrf(&state, &form.csrf) {
206+ return Err(AppError::Csrf);
207+ }
208+ invitations::delete_owned(&state.pool, id, ctx.user.id).await?;
209+ Ok(Redirect::to("/parametres?ok=refus"))
210+}
211+
212+/// POST /invitations/{id}/annuler — retire une invitation émise par l'équipe.
213+pub async fn invite_cancel(
214+ State(state): State<AppState>,
215+ ctx: ActiveTeam,
216+ Path(id): Path<Uuid>,
217+ Form(form): Form<CsrfOnly>,
218+) -> Result<Redirect, AppError> {
219+ if !ctx.user.check_csrf(&state, &form.csrf) {
220+ return Err(AppError::Csrf);
221+ }
222+ invitations::cancel(&state.pool, id, ctx.team_id, ctx.user.id).await?;
223+ Ok(Redirect::to("/parametres?ok=annulee"))
224+}
new file mode 100644
@@ -0,0 +1,224 @@
1+use axum::Form;
2+use axum::extract::{Path, State};
3+use axum::response::{IntoResponse, Redirect, Response};
4+use axum_extra::extract::CookieJar;
5+use serde::Deserialize;
6+use uuid::Uuid;
7+
8+use crate::db::{invitations, teams, users};
9+use crate::error::AppError;
10+use crate::state::AppState;
11+use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};
12+
13+fn is_unique_violation(e: &sqlx::Error) -> bool {
14+ matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
15+}
16+
17+/// Encodage minimal pour rejouer un username dans une query string.
18+fn urlenc(s: &str) -> String {
19+ let mut out = String::with_capacity(s.len());
20+ for b in s.bytes() {
21+ match b {
22+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
23+ out.push(b as char)
24+ }
25+ _ => out.push_str(&format!("%{b:02X}")),
26+ }
27+ }
28+ out
29+}
30+
31+#[derive(Deserialize)]
32+pub struct NameForm {
33+ nom: String,
34+ csrf: String,
35+}
36+
37+/// POST /equipes — créer une équipe depuis l'UI et basculer dessus.
38+pub async fn team_create(
39+ State(state): State<AppState>,
40+ ctx: ActiveTeam,
41+ jar: CookieJar,
42+ Form(form): Form<NameForm>,
43+) -> Result<Response, AppError> {
44+ if !ctx.user.check_csrf(&state, &form.csrf) {
45+ return Err(AppError::Csrf);
46+ }
47+ let name = form.nom.trim();
48+ if name.is_empty() {
49+ return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
50+ }
51+ match teams::create_with_member(&state.pool, name, ctx.user.id).await {
52+ Ok(id) => Ok((
53+ jar.add(team_cookie(id)),
54+ Redirect::to("/parametres?ok=equipe-creee"),
55+ )
56+ .into_response()),
57+ Err(e) if is_unique_violation(&e) => {
58+ Ok(Redirect::to("/parametres?err=eq-doublon").into_response())
59+ }
60+ Err(e) => Err(e.into()),
61+ }
62+}
63+
64+#[derive(Deserialize)]
65+pub struct SwitchForm {
66+ equipe: Uuid,
67+ csrf: String,
68+}
69+
70+/// POST /equipes/basculer — change l'équipe active (cookie par appareil).
71+pub async fn team_switch(
72+ State(state): State<AppState>,
73+ ctx: ActiveTeam,
74+ jar: CookieJar,
75+ Form(form): Form<SwitchForm>,
76+) -> Result<Response, AppError> {
77+ if !ctx.user.check_csrf(&state, &form.csrf) {
78+ return Err(AppError::Csrf);
79+ }
80+ if teams::membership(&state.pool, ctx.user.id, form.equipe)
81+ .await?
82+ .is_none()
83+ {
84+ return Err(AppError::NotFound);
85+ }
86+ Ok((jar.add(team_cookie(form.equipe)), Redirect::to("/parametres")).into_response())
87+}
88+
89+/// POST /equipes/renommer — tout membre peut renommer l'équipe active.
90+pub async fn team_rename(
91+ State(state): State<AppState>,
92+ ctx: ActiveTeam,
93+ Form(form): Form<NameForm>,
94+) -> Result<Redirect, AppError> {
95+ if !ctx.user.check_csrf(&state, &form.csrf) {
96+ return Err(AppError::Csrf);
97+ }
98+ let name = form.nom.trim();
99+ if name.is_empty() {
100+ return Ok(Redirect::to("/parametres?err=eq-vide"));
101+ }
102+ match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await {
103+ Ok(0) => Err(AppError::NotFound),
104+ Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")),
105+ Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")),
106+ Err(e) => Err(e.into()),
107+ }
108+}
109+
110+#[derive(Deserialize)]
111+pub struct CsrfOnly {
112+ csrf: String,
113+}
114+
115+/// POST /equipes/quitter — bloqué sur la dernière équipe (re-vérifié ici,
116+/// pas seulement masqué dans l'UI). L'équipe vidée reste en base.
117+pub async fn team_leave(
118+ State(state): State<AppState>,
119+ ctx: ActiveTeam,
120+ jar: CookieJar,
121+ Form(form): Form<CsrfOnly>,
122+) -> Result<Response, AppError> {
123+ if !ctx.user.check_csrf(&state, &form.csrf) {
124+ return Err(AppError::Csrf);
125+ }
126+ if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {
127+ return Ok(Redirect::to("/parametres?err=derniere").into_response());
128+ }
129+ teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
130+ // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
131+ Ok((
132+ jar.remove(clear_team_cookie()),
133+ Redirect::to("/parametres?ok=quittee"),
134+ )
135+ .into_response())
136+}
137+
138+#[derive(Deserialize)]
139+pub struct InviteForm {
140+ username: String,
141+ csrf: String,
142+}
143+
144+/// POST /equipes/inviter — par username, erreurs explicites (petit groupe
145+/// qui se connaît : pas de réponse neutre anti-énumération).
146+pub async fn invite_create(
147+ State(state): State<AppState>,
148+ ctx: ActiveTeam,
149+ Form(form): Form<InviteForm>,
150+) -> Result<Redirect, AppError> {
151+ if !ctx.user.check_csrf(&state, &form.csrf) {
152+ return Err(AppError::Csrf);
153+ }
154+ let username = form.username.trim();
155+ let enc = urlenc(username);
156+ let Some(target) = users::find_by_username(&state.pool, username).await? else {
157+ return Ok(Redirect::to(&format!("/parametres?err=inconnu&nom={enc}")));
158+ };
159+ if target.id == ctx.user.id
160+ || teams::membership(&state.pool, target.id, ctx.team_id)
161+ .await?
162+ .is_some()
163+ {
164+ return Ok(Redirect::to(&format!(
165+ "/parametres?err=deja-membre&nom={enc}"
166+ )));
167+ }
168+ if invitations::create(&state.pool, ctx.team_id, target.id, ctx.user.id).await? {
169+ Ok(Redirect::to("/parametres?ok=invite"))
170+ } else {
171+ Ok(Redirect::to(&format!(
172+ "/parametres?err=deja-invite&nom={enc}"
173+ )))
174+ }
175+}
176+
177+/// POST /invitations/{id}/accepter — rejoint l'équipe ET bascule dessus.
178+pub async fn invite_accept(
179+ State(state): State<AppState>,
180+ ctx: ActiveTeam,
181+ jar: CookieJar,
182+ Path(id): Path<Uuid>,
183+ Form(form): Form<CsrfOnly>,
184+) -> Result<Response, AppError> {
185+ if !ctx.user.check_csrf(&state, &form.csrf) {
186+ return Err(AppError::Csrf);
187+ }
188+ match invitations::accept(&state.pool, id, ctx.user.id).await? {
189+ Some(team_id) => Ok((
190+ jar.add(team_cookie(team_id)),
191+ Redirect::to("/parametres?ok=rejoint"),
192+ )
193+ .into_response()),
194+ None => Err(AppError::NotFound),
195+ }
196+}
197+
198+/// POST /invitations/{id}/refuser — silencieux, ré-invitable.
199+pub async fn invite_refuse(
200+ State(state): State<AppState>,
201+ ctx: ActiveTeam,
202+ Path(id): Path<Uuid>,
203+ Form(form): Form<CsrfOnly>,
204+) -> Result<Redirect, AppError> {
205+ if !ctx.user.check_csrf(&state, &form.csrf) {
206+ return Err(AppError::Csrf);
207+ }
208+ invitations::delete_owned(&state.pool, id, ctx.user.id).await?;
209+ Ok(Redirect::to("/parametres?ok=refus"))
210+}
211+
212+/// POST /invitations/{id}/annuler — retire une invitation émise par l'équipe.
213+pub async fn invite_cancel(
214+ State(state): State<AppState>,
215+ ctx: ActiveTeam,
216+ Path(id): Path<Uuid>,
217+ Form(form): Form<CsrfOnly>,
218+) -> Result<Redirect, AppError> {
219+ if !ctx.user.check_csrf(&state, &form.csrf) {
220+ return Err(AppError::Csrf);
221+ }
222+ invitations::cancel(&state.pool, id, ctx.team_id, ctx.user.id).await?;
223+ Ok(Redirect::to("/parametres?ok=annulee"))
224+}
added src/team.rs +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+use axum::extract::FromRequestParts;
2+use axum::http::request::Parts;
3+use axum::response::{IntoResponse, Response};
4+use axum_extra::extract::CookieJar;
5+use axum_extra::extract::cookie::{Cookie, SameSite};
6+use uuid::Uuid;
7+
8+use crate::auth::SESSION_SECONDS;
9+use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate};
10+use crate::db::teams;
11+use crate::error::AppError;
12+use crate::state::AppState;
13+
14+/// Équipe active par appareil — un simple UUID non signé : l'appartenance
15+/// est validée en SQL à chaque requête.
16+pub const TEAM_COOKIE: &str = "tt_team";
17+
18+/// Cookie posé par les actions explicites (bascule, création, acceptation).
19+pub fn team_cookie(team_id: Uuid) -> Cookie<'static> {
20+ Cookie::build((TEAM_COOKIE, team_id.to_string()))
21+ .path("/")
22+ .http_only(true)
23+ .secure(true)
24+ .same_site(SameSite::Strict)
25+ .max_age(time::Duration::seconds(SESSION_SECONDS))
26+ .build()
27+}
28+
29+pub fn clear_team_cookie() -> Cookie<'static> {
30+ Cookie::build((TEAM_COOKIE, "")).path("/").build()
31+}
32+
33+/// Utilisateur authentifié + équipe active résolue.
34+/// Cookie absent, invalide ou vers une équipe quittée → plus ancienne
35+/// appartenance (sans réécrire le cookie).
36+pub struct ActiveTeam {
37+ pub user: AuthUser,
38+ pub team_id: Uuid,
39+ pub team_name: String,
40+}
41+
42+pub enum TeamRejection {
43+ Auth(AuthRedirect),
44+ App(AppError),
45+}
46+
47+impl IntoResponse for TeamRejection {
48+ fn into_response(self) -> Response {
49+ match self {
50+ TeamRejection::Auth(r) => r.into_response(),
51+ TeamRejection::App(e) => e.into_response(),
52+ }
53+ }
54+}
55+
56+impl From<sqlx::Error> for TeamRejection {
57+ fn from(e: sqlx::Error) -> Self {
58+ TeamRejection::App(AppError::Db(e))
59+ }
60+}
61+
62+impl FromRequestParts<AppState> for ActiveTeam {
63+ type Rejection = TeamRejection;
64+
65+ async fn from_request_parts(
66+ parts: &mut Parts,
67+ state: &AppState,
68+ ) -> Result<Self, TeamRejection> {
69+ let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
70+ let jar = CookieJar::from_headers(&parts.headers);
71+ let wanted = jar
72+ .get(TEAM_COOKIE)
73+ .and_then(|c| c.value().parse::<Uuid>().ok());
74+
75+ if let Some(team_id) = wanted {
76+ if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? {
77+ return Ok(ActiveTeam {
78+ user,
79+ team_id: id,
80+ team_name: name,
81+ });
82+ }
83+ }
84+ match teams::oldest_membership(&state.pool, user.id).await? {
85+ Some((id, name)) => Ok(ActiveTeam {
86+ user,
87+ team_id: id,
88+ team_name: name,
89+ }),
90+ // Invariant « toujours au moins une équipe » brisé (compte créé sans
91+ // équipe avant la migration ?) — erreur franche plutôt qu'une boucle.
92+ None => Err(TeamRejection::App(AppError::Internal(anyhow::anyhow!(
93+ "utilisateur {} sans aucune équipe",
94+ user.id
95+ )))),
96+ }
97+ }
98+}
new file mode 100644
@@ -0,0 +1,98 @@
1+use axum::extract::FromRequestParts;
2+use axum::http::request::Parts;
3+use axum::response::{IntoResponse, Response};
4+use axum_extra::extract::CookieJar;
5+use axum_extra::extract::cookie::{Cookie, SameSite};
6+use uuid::Uuid;
7+
8+use crate::auth::SESSION_SECONDS;
9+use crate::auth::extractor::{AuthRedirect, AuthUser, authenticate};
10+use crate::db::teams;
11+use crate::error::AppError;
12+use crate::state::AppState;
13+
14+/// Équipe active par appareil — un simple UUID non signé : l'appartenance
15+/// est validée en SQL à chaque requête.
16+pub const TEAM_COOKIE: &str = "tt_team";
17+
18+/// Cookie posé par les actions explicites (bascule, création, acceptation).
19+pub fn team_cookie(team_id: Uuid) -> Cookie<'static> {
20+ Cookie::build((TEAM_COOKIE, team_id.to_string()))
21+ .path("/")
22+ .http_only(true)
23+ .secure(true)
24+ .same_site(SameSite::Strict)
25+ .max_age(time::Duration::seconds(SESSION_SECONDS))
26+ .build()
27+}
28+
29+pub fn clear_team_cookie() -> Cookie<'static> {
30+ Cookie::build((TEAM_COOKIE, "")).path("/").build()
31+}
32+
33+/// Utilisateur authentifié + équipe active résolue.
34+/// Cookie absent, invalide ou vers une équipe quittée → plus ancienne
35+/// appartenance (sans réécrire le cookie).
36+pub struct ActiveTeam {
37+ pub user: AuthUser,
38+ pub team_id: Uuid,
39+ pub team_name: String,
40+}
41+
42+pub enum TeamRejection {
43+ Auth(AuthRedirect),
44+ App(AppError),
45+}
46+
47+impl IntoResponse for TeamRejection {
48+ fn into_response(self) -> Response {
49+ match self {
50+ TeamRejection::Auth(r) => r.into_response(),
51+ TeamRejection::App(e) => e.into_response(),
52+ }
53+ }
54+}
55+
56+impl From<sqlx::Error> for TeamRejection {
57+ fn from(e: sqlx::Error) -> Self {
58+ TeamRejection::App(AppError::Db(e))
59+ }
60+}
61+
62+impl FromRequestParts<AppState> for ActiveTeam {
63+ type Rejection = TeamRejection;
64+
65+ async fn from_request_parts(
66+ parts: &mut Parts,
67+ state: &AppState,
68+ ) -> Result<Self, TeamRejection> {
69+ let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
70+ let jar = CookieJar::from_headers(&parts.headers);
71+ let wanted = jar
72+ .get(TEAM_COOKIE)
73+ .and_then(|c| c.value().parse::<Uuid>().ok());
74+
75+ if let Some(team_id) = wanted {
76+ if let Some((id, name)) = teams::membership(&state.pool, user.id, team_id).await? {
77+ return Ok(ActiveTeam {
78+ user,
79+ team_id: id,
80+ team_name: name,
81+ });
82+ }
83+ }
84+ match teams::oldest_membership(&state.pool, user.id).await? {
85+ Some((id, name)) => Ok(ActiveTeam {
86+ user,
87+ team_id: id,
88+ team_name: name,
89+ }),
90+ // Invariant « toujours au moins une équipe » brisé (compte créé sans
91+ // équipe avant la migration ?) — erreur franche plutôt qu'une boucle.
92+ None => Err(TeamRejection::App(AppError::Internal(anyhow::anyhow!(
93+ "utilisateur {} sans aucune équipe",
94+ user.id
95+ )))),
96+ }
97+ }
98+}
modified templates/app.html +11 -2
@@ -2,8 +2,14 @@
22 {% block title %}{{ shell.title }} · time-time{% endblock %}
33 {% block body %}
44 <header class="app-header">
5+ <div class="app-topline">
6+ <span class="team-pill">
7+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="3.2"></circle><path d="M22 20v-2a4 4 0 0 0-3-3.8"></path><path d="M16.5 4.2a4 4 0 0 1 0 5.6"></path></svg>
8+ {{ shell.team_name }}
9+ </span>
10+ <span class="who">{{ shell.username }}</span>
11+ </div>
512 <h1>{{ shell.title }}</h1>
6- <span class="who">{{ shell.username }}</span>
713 </header>
814 <main class="app-main">
915 {% block content %}{% endblock %}
@@ -23,7 +29,10 @@
2329 <span>Mes temps</span>
2430 </a>
2531 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>
26- <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg>
32+ <span class="tab-ico">
33+ <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg>
34+ {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %}
35+ </span>
2736 <span>Réglages</span>
2837 </a>
2938 </nav>
@@ -2,8 +2,14 @@
2 {% block title %}{{ shell.title }} · time-time{% endblock %}2 {% block title %}{{ shell.title }} · time-time{% endblock %}
3 {% block body %}3 {% block body %}
4 <header class="app-header">4 <header class="app-header">
5+ <div class="app-topline">
6+ <span class="team-pill">
7+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="3.2"></circle><path d="M22 20v-2a4 4 0 0 0-3-3.8"></path><path d="M16.5 4.2a4 4 0 0 1 0 5.6"></path></svg>
8+ {{ shell.team_name }}
9+ </span>
10+ <span class="who">{{ shell.username }}</span>
11+ </div>
5 <h1>{{ shell.title }}</h1>12 <h1>{{ shell.title }}</h1>
6- <span class="who">{{ shell.username }}</span>
7 </header>13 </header>
8 <main class="app-main">14 <main class="app-main">
9 {% block content %}{% endblock %}15 {% block content %}{% endblock %}
@@ -23,7 +29,10 @@
23 <span>Mes temps</span>29 <span>Mes temps</span>
24 </a>30 </a>
25 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>31 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>
26- <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg>32+ <span class="tab-ico">
33+ <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg>
34+ {% if shell.invite_count > 0 %}<span class="tab-badge">{{ shell.invite_count }}</span>{% endif %}
35+ </span>
27 <span>Réglages</span>36 <span>Réglages</span>
28 </a>37 </a>
29 </nav>38 </nav>
modified templates/login.html +1 -1
@@ -6,7 +6,7 @@
66 <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
77 </div>
88 <h1>time-time</h1>
9- <p class="tagline">Le temps passé sur chaque tâche, partagé entre tous.</p>
9+ <p class="tagline">Le temps passé sur chaque tâche, partagé avec ton équipe.</p>
1010 <form method="post" action="/login">
1111 <div class="field" style="margin-bottom:var(--space-3)">
1212 <label for="username">Identifiant</label>
@@ -6,7 +6,7 @@
6 <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>6 <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
7 </div>7 </div>
8 <h1>time-time</h1>8 <h1>time-time</h1>
9- <p class="tagline">Le temps passé sur chaque tâche, partagé entre tous.</p>9+ <p class="tagline">Le temps passé sur chaque tâche, partagé avec ton équipe.</p>
10 <form method="post" action="/login">10 <form method="post" action="/login">
11 <div class="field" style="margin-bottom:var(--space-3)">11 <div class="field" style="margin-bottom:var(--space-3)">
12 <label for="username">Identifiant</label>12 <label for="username">Identifiant</label>
modified templates/settings.html +108 -3
@@ -1,12 +1,117 @@
11 {% extends "app.html" %}
22 {% block content %}
3-<div class="card" style="margin-bottom:var(--space-4)">
3+{% if !invites.is_empty() %}
4+<div style="margin-bottom:var(--space-6)">
5+ {% for inv in invites %}
6+ <div class="invite-card">
7+ <div class="invite-kicker"><span class="idot"></span>Invitation</div>
8+ <p>{{ inv.text }}</p>
9+ <div style="display:flex;gap:var(--space-2)">
10+ <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex">
11+ <input type="hidden" name="csrf" value="{{ csrf }}">
12+ <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button>
13+ </form>
14+ <form method="post" action="{{ inv.refuse_action }}" style="display:flex">
15+ <input type="hidden" name="csrf" value="{{ csrf }}">
16+ <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button>
17+ </form>
18+ </div>
19+ </div>
20+ {% endfor %}
21+</div>
22+{% endif %}
23+
24+<div class="card" style="margin-bottom:var(--space-6)">
425 <div class="card-kicker">Compte</div>
526 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>
6- <p class="card-body">Les comptes sont créés en ligne de commande. Tu peux changer ton mot de passe ici.</p>
27+ <p class="card-body">{{ account_line }}</p>
28+</div>
29+
30+<h4 style="margin:0 0 var(--space-2)">Équipe active</h4>
31+<p style="font-size:13px;opacity:.6;margin:0 0 var(--space-3)">Tâches, temps et statistiques sont propres à l'équipe active.</p>
32+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-4)">
33+ {% for t in my_teams %}
34+ <form method="post" action="/equipes/basculer" style="margin:0">
35+ <input type="hidden" name="csrf" value="{{ csrf }}">
36+ <input type="hidden" name="equipe" value="{{ t.id }}">
37+ <button type="submit" class="team-choice{% if t.current %} current{% endif %}">
38+ <span class="team-dot"></span>
39+ <span style="flex:1;text-align:left">
40+ <span style="display:block;font-size:16px">{{ t.name }}</span>
41+ <span style="display:block;font-size:12px;opacity:.6">{{ t.meta }}</span>
42+ </span>
43+ {% if t.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
44+ </button>
45+ </form>
46+ {% endfor %}
47+</div>
48+
49+<form method="post" action="/equipes/renommer">
50+ <input type="hidden" name="csrf" value="{{ csrf }}">
51+ <div class="field" style="margin-bottom:var(--space-2)">
52+ <label for="eq-nom">Nom de l'équipe</label>
53+ <div style="display:flex;gap:var(--space-2)">
54+ <input class="input" id="eq-nom" name="nom" value="{{ shell.team_name }}" required style="flex:1;min-height:44px">
55+ <button type="submit" class="btn btn-primary" style="min-height:44px">Renommer</button>
56+ </div>
57+ </div>
58+</form>
59+<p style="font-size:12px;opacity:.55;margin:0 0 var(--space-6)">Le nouveau nom est visible par tous les membres.</p>
60+
61+<h4 style="margin:0 0 var(--space-2)">Membres de {{ shell.team_name }}</h4>
62+<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)">
63+ {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %}
64+</div>
65+
66+<form method="post" action="/equipes/inviter">
67+ <input type="hidden" name="csrf" value="{{ csrf }}">
68+ <div class="field" style="margin-bottom:var(--space-2)">
69+ <label for="invite-nom">Inviter un utilisateur</label>
70+ <div style="display:flex;gap:var(--space-2)">
71+ <input class="input" id="invite-nom" name="username" placeholder="identifiant" autocapitalize="none" autocorrect="off" required style="flex:1;min-height:44px">
72+ <button type="submit" class="btn btn-primary" style="min-height:44px">Inviter</button>
73+ </div>
74+ </div>
75+</form>
76+{% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %}
77+<p style="font-size:12px;opacity:.55;margin:0">L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.</p>
78+
79+{% if !sent.is_empty() %}
80+<h4 style="margin:var(--space-6) 0 var(--space-2)">Invitations en attente</h4>
81+{% for si in sent %}
82+<div class="sent-row">
83+ <div style="flex:1;min-width:0">
84+ <div style="font-size:15px">{{ si.name }}</div>
85+ <div style="font-size:12px;opacity:.55">{{ si.meta }}</div>
86+ </div>
87+ <form method="post" action="{{ si.cancel_action }}" style="margin:0">
88+ <input type="hidden" name="csrf" value="{{ csrf }}">
89+ <button type="submit" class="btn btn-ghost" style="font-size:12px">Annuler</button>
90+ </form>
791 </div>
92+{% endfor %}
93+{% endif %}
94+
95+<h4 style="margin:var(--space-8) 0 var(--space-2)">Créer une équipe</h4>
96+<form method="post" action="/equipes">
97+ <input type="hidden" name="csrf" value="{{ csrf }}">
98+ <div style="display:flex;gap:var(--space-2)">
99+ <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px">
100+ <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button>
101+ </div>
102+</form>
103+<p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p>
104+
105+{% if can_leave %}
106+<form method="post" action="/equipes/quitter">
107+ <input type="hidden" name="csrf" value="{{ csrf }}">
108+ <button type="submit" class="btn btn-block"
109+ style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Quitter « {{ shell.team_name }} »</button>
110+</form>
111+<p style="font-size:11px;opacity:.45;margin-top:var(--space-2);text-align:center">Tes temps déjà saisis restent dans les stats de l'équipe.</p>
112+{% endif %}
8113
9-<h4 style="margin:var(--space-6) 0 var(--space-3)">Changer de mot de passe</h4>
114+<h4 style="margin:var(--space-8) 0 var(--space-3)">Changer de mot de passe</h4>
10115 <form method="post" action="/parametres/mot-de-passe">
11116 <input type="hidden" name="csrf" value="{{ csrf }}">
12117 <div class="field" style="margin-bottom:var(--space-3)">
@@ -1,12 +1,117 @@
1 {% extends "app.html" %}1 {% extends "app.html" %}
2 {% block content %}2 {% block content %}
3-<div class="card" style="margin-bottom:var(--space-4)">3+{% if !invites.is_empty() %}
4+<div style="margin-bottom:var(--space-6)">
5+ {% for inv in invites %}
6+ <div class="invite-card">
7+ <div class="invite-kicker"><span class="idot"></span>Invitation</div>
8+ <p>{{ inv.text }}</p>
9+ <div style="display:flex;gap:var(--space-2)">
10+ <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex">
11+ <input type="hidden" name="csrf" value="{{ csrf }}">
12+ <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button>
13+ </form>
14+ <form method="post" action="{{ inv.refuse_action }}" style="display:flex">
15+ <input type="hidden" name="csrf" value="{{ csrf }}">
16+ <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button>
17+ </form>
18+ </div>
19+ </div>
20+ {% endfor %}
21+</div>
22+{% endif %}
23+
24+<div class="card" style="margin-bottom:var(--space-6)">
4 <div class="card-kicker">Compte</div>25 <div class="card-kicker">Compte</div>
5 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>26 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>
6- <p class="card-body">Les comptes sont créés en ligne de commande. Tu peux changer ton mot de passe ici.</p>27+ <p class="card-body">{{ account_line }}</p>
28+</div>
29+
30+<h4 style="margin:0 0 var(--space-2)">Équipe active</h4>
31+<p style="font-size:13px;opacity:.6;margin:0 0 var(--space-3)">Tâches, temps et statistiques sont propres à l'équipe active.</p>
32+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-4)">
33+ {% for t in my_teams %}
34+ <form method="post" action="/equipes/basculer" style="margin:0">
35+ <input type="hidden" name="csrf" value="{{ csrf }}">
36+ <input type="hidden" name="equipe" value="{{ t.id }}">
37+ <button type="submit" class="team-choice{% if t.current %} current{% endif %}">
38+ <span class="team-dot"></span>
39+ <span style="flex:1;text-align:left">
40+ <span style="display:block;font-size:16px">{{ t.name }}</span>
41+ <span style="display:block;font-size:12px;opacity:.6">{{ t.meta }}</span>
42+ </span>
43+ {% if t.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
44+ </button>
45+ </form>
46+ {% endfor %}
47+</div>
48+
49+<form method="post" action="/equipes/renommer">
50+ <input type="hidden" name="csrf" value="{{ csrf }}">
51+ <div class="field" style="margin-bottom:var(--space-2)">
52+ <label for="eq-nom">Nom de l'équipe</label>
53+ <div style="display:flex;gap:var(--space-2)">
54+ <input class="input" id="eq-nom" name="nom" value="{{ shell.team_name }}" required style="flex:1;min-height:44px">
55+ <button type="submit" class="btn btn-primary" style="min-height:44px">Renommer</button>
56+ </div>
57+ </div>
58+</form>
59+<p style="font-size:12px;opacity:.55;margin:0 0 var(--space-6)">Le nouveau nom est visible par tous les membres.</p>
60+
61+<h4 style="margin:0 0 var(--space-2)">Membres de {{ shell.team_name }}</h4>
62+<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-4)">
63+ {% for m in members %}<span class="tag tag-neutral">{{ m }}</span>{% endfor %}
64+</div>
65+
66+<form method="post" action="/equipes/inviter">
67+ <input type="hidden" name="csrf" value="{{ csrf }}">
68+ <div class="field" style="margin-bottom:var(--space-2)">
69+ <label for="invite-nom">Inviter un utilisateur</label>
70+ <div style="display:flex;gap:var(--space-2)">
71+ <input class="input" id="invite-nom" name="username" placeholder="identifiant" autocapitalize="none" autocorrect="off" required style="flex:1;min-height:44px">
72+ <button type="submit" class="btn btn-primary" style="min-height:44px">Inviter</button>
73+ </div>
74+ </div>
75+</form>
76+{% if let Some(e) = invite_error %}<p class="form-error" style="margin:0 0 var(--space-2)">{{ e }}</p>{% endif %}
77+<p style="font-size:12px;opacity:.55;margin:0">L'utilisateur doit déjà avoir un compte. Il reçoit une invitation à accepter de son côté.</p>
78+
79+{% if !sent.is_empty() %}
80+<h4 style="margin:var(--space-6) 0 var(--space-2)">Invitations en attente</h4>
81+{% for si in sent %}
82+<div class="sent-row">
83+ <div style="flex:1;min-width:0">
84+ <div style="font-size:15px">{{ si.name }}</div>
85+ <div style="font-size:12px;opacity:.55">{{ si.meta }}</div>
86+ </div>
87+ <form method="post" action="{{ si.cancel_action }}" style="margin:0">
88+ <input type="hidden" name="csrf" value="{{ csrf }}">
89+ <button type="submit" class="btn btn-ghost" style="font-size:12px">Annuler</button>
90+ </form>
7 </div>91 </div>
92+{% endfor %}
93+{% endif %}
94+
95+<h4 style="margin:var(--space-8) 0 var(--space-2)">Créer une équipe</h4>
96+<form method="post" action="/equipes">
97+ <input type="hidden" name="csrf" value="{{ csrf }}">
98+ <div style="display:flex;gap:var(--space-2)">
99+ <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px">
100+ <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button>
101+ </div>
102+</form>
103+<p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p>
104+
105+{% if can_leave %}
106+<form method="post" action="/equipes/quitter">
107+ <input type="hidden" name="csrf" value="{{ csrf }}">
108+ <button type="submit" class="btn btn-block"
109+ style="min-height:46px;margin-top:var(--space-6);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Quitter « {{ shell.team_name }} »</button>
110+</form>
111+<p style="font-size:11px;opacity:.45;margin-top:var(--space-2);text-align:center">Tes temps déjà saisis restent dans les stats de l'équipe.</p>
112+{% endif %}
8 113
9-<h4 style="margin:var(--space-6) 0 var(--space-3)">Changer de mot de passe</h4>114+<h4 style="margin:var(--space-8) 0 var(--space-3)">Changer de mot de passe</h4>
10 <form method="post" action="/parametres/mot-de-passe">115 <form method="post" action="/parametres/mot-de-passe">
11 <input type="hidden" name="csrf" value="{{ csrf }}">116 <input type="hidden" name="csrf" value="{{ csrf }}">
12 <div class="field" style="margin-bottom:var(--space-3)">117 <div class="field" style="margin-bottom:var(--space-3)">
modified templates/tasks.html +1 -1
@@ -1,6 +1,6 @@
11 {% extends "app.html" %}
22 {% block content %}
3-<p class="step-hint">Les tâches sont communes à tout le monde. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p>
3+<p class="step-hint">Les tâches appartiennent à l'équipe {{ shell.team_name }} et sont modifiables par tous ses membres. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p>
44 <form method="post" action="/parametres/taches" class="task-add-row">
55 <input type="hidden" name="csrf" value="{{ csrf }}">
66 <input class="input" name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche">
@@ -1,6 +1,6 @@
1 {% extends "app.html" %}1 {% extends "app.html" %}
2 {% block content %}2 {% block content %}
3-<p class="step-hint">Les tâches sont communes à tout le monde. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p>3+<p class="step-hint">Les tâches appartiennent à l'équipe {{ shell.team_name }} et sont modifiables par tous ses membres. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p>
4 <form method="post" action="/parametres/taches" class="task-add-row">4 <form method="post" action="/parametres/taches" class="task-add-row">
5 <input type="hidden" name="csrf" value="{{ csrf }}">5 <input type="hidden" name="csrf" value="{{ csrf }}">
6 <input class="input" name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche">6 <input class="input" name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche">
modified templates/times.html +1 -1
@@ -1,7 +1,7 @@
11 {% extends "app.html" %}
22 {% block content %}
33 {% if entries.is_empty() %}
4-<p class="chart-empty">Tu n'as encore enregistré aucun temps.</p>
4+<p class="chart-empty">Tu n'as encore enregistré aucun temps dans cette équipe.</p>
55 {% else %}
66 <ul class="times">
77 {% for e in entries %}
@@ -1,7 +1,7 @@
1 {% extends "app.html" %}1 {% extends "app.html" %}
2 {% block content %}2 {% block content %}
3 {% if entries.is_empty() %}3 {% if entries.is_empty() %}
4-<p class="chart-empty">Tu n'as encore enregistré aucun temps.</p>4+<p class="chart-empty">Tu n'as encore enregistré aucun temps dans cette équipe.</p>
5 {% else %}5 {% else %}
6 <ul class="times">6 <ul class="times">
7 {% for e in entries %}7 {% for e in entries %}