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)
2bcbeb3 parent: c7e60c0 modified
assets/style.css +64 -4 | @@ -20,6 +20,11 @@ | ||
| 20 | 20 | --color-accent-300: #ffc6a5; |
| 21 | 21 | --color-accent-600: #b2622d; |
| 22 | 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 | 29 | --font-heading: "Caprasimo", system-ui, sans-serif; |
| 25 | 30 | --font-heading-weight: 400; |
| @@ -112,6 +117,13 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 112 | 117 | .card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } |
| 113 | 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 | 127 | /* ═══ Couche applicative time-time ═══ */ |
| 116 | 128 | |
| 117 | 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 | 134 | padding-top: env(safe-area-inset-top, 0px); |
| 123 | 135 | } |
| 124 | 136 | |
| 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); | |
| 128 | 143 | } |
| 144 | +.app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } | |
| 129 | 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 | 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 | 164 | font-size: 11px; |
| 150 | 165 | } |
| 151 | 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 | 212 | .tab svg { display: block; } |
| 153 | 213 | |
| 154 | 214 | /* — 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 { | ||
| 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 | 44 | let jar = CookieJar::from_headers(&parts.headers); |
| 45 | 45 | let cookie = jar.get(SESSION_COOKIE)?; |
| 46 | 46 | 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 { | ||
| 199 | 199 | fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow { |
| 200 | 200 | StatRow { |
| 201 | 201 | user_id: Uuid::from_u128(uid), |
| 202 | + username: String::new(), | |
| 202 | 203 | task_id: Uuid::from_u128(tid), |
| 203 | 204 | task_name: task.into(), |
| 204 | 205 | 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 @@ | ||
| 1 | 1 | use anyhow::{Context, Result, bail}; |
| 2 | 2 | use biscuit_auth::KeyPair; |
| 3 | +use sqlx::PgPool; | |
| 3 | 4 | use sqlx::postgres::PgPoolOptions; |
| 5 | +use uuid::Uuid; | |
| 4 | 6 | |
| 5 | 7 | use crate::auth::password; |
| 6 | 8 | use crate::config; |
| 7 | -use crate::db::users; | |
| 9 | +use crate::db::teams; | |
| 8 | 10 | |
| 9 | 11 | /// Génère un keypair racine et l'affiche (à faire une fois, en local). |
| 10 | 12 | pub fn keygen() { |
| @@ -13,8 +15,38 @@ pub fn keygen() { | ||
| 13 | 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. | |
| 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<()> { | |
| 18 | 50 | let username = username.trim(); |
| 19 | 51 | if username.is_empty() { |
| 20 | 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 | 67 | bail!("le mot de passe doit faire au moins 8 caractères"); |
| 36 | 68 | } |
| 37 | 69 | |
| 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()))?; | |
| 44 | 74 | |
| 45 | 75 | 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(_) => {} | |
| 51 | 86 | Err(sqlx::Error::Database(db)) if db.is_unique_violation() => { |
| 52 | 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 | } |
| @@ -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 | - .await | 73 | + .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 { | ||
| 11 | 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 | 16 | pub async fn insert( |
| 15 | 17 | pool: &PgPool, |
| 16 | 18 | user_id: Uuid, |
| 19 | + team_id: Uuid, | |
| 17 | 20 | task_id: Uuid, |
| 18 | 21 | minutes: i32, |
| 19 | 22 | 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( | |
| 23 | 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 | 30 | .bind(user_id) |
| 28 | 31 | .bind(task_id) |
| 29 | 32 | .bind(minutes) |
| 30 | 33 | .bind(entry_date) |
| 34 | + .bind(team_id) | |
| 31 | 35 | .execute(pool) |
| 32 | 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` 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). | |
| 38 | 42 | /// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes. |
| 39 | 43 | pub async fn list_first( |
| 40 | 44 | pool: &PgPool, |
| 41 | 45 | user_id: Uuid, |
| 46 | + team_id: Uuid, | |
| 42 | 47 | limit: i64, |
| 43 | 48 | ) -> sqlx::Result<(Vec<EntryRow>, bool)> { |
| 44 | 49 | let mut rows: Vec<EntryRow> = sqlx::query_as( |
| 45 | 50 | "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date |
| 46 | 51 | 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 | |
| 48 | 53 | ORDER BY e.entry_date DESC, e.id DESC |
| 49 | - LIMIT $2", | |
| 54 | + LIMIT $3", | |
| 50 | 55 | ) |
| 51 | 56 | .bind(user_id) |
| 57 | + .bind(team_id) | |
| 52 | 58 | .bind(limit + 1) |
| 53 | 59 | .fetch_all(pool) |
| 54 | 60 | .await?; |
| @@ -57,66 +63,90 @@ pub async fn list_first( | ||
| 57 | 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 | 67 | pub async fn week_totals_for_user( |
| 62 | 68 | pool: &PgPool, |
| 63 | 69 | user_id: Uuid, |
| 70 | + team_id: Uuid, | |
| 64 | 71 | from: NaiveDate, |
| 65 | 72 | to: NaiveDate, |
| 66 | 73 | ) -> sqlx::Result<Vec<(Uuid, i64)>> { |
| 67 | 74 | 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", | |
| 71 | 79 | ) |
| 72 | 80 | .bind(user_id) |
| 81 | + .bind(team_id) | |
| 73 | 82 | .bind(from) |
| 74 | 83 | .bind(to) |
| 75 | 84 | .fetch_all(pool) |
| 76 | 85 | .await |
| 77 | 86 | } |
| 78 | 87 | |
| 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>> { | |
| 81 | 95 | sqlx::query_as( |
| 82 | 96 | "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date |
| 83 | 97 | 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 | 100 | .bind(id) |
| 87 | 101 | .bind(user_id) |
| 102 | + .bind(team_id) | |
| 88 | 103 | .fetch_optional(pool) |
| 89 | 104 | .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 | 109 | pub async fn update_owned( |
| 94 | 110 | pool: &PgPool, |
| 95 | 111 | id: Uuid, |
| 96 | 112 | user_id: Uuid, |
| 113 | + team_id: Uuid, | |
| 97 | 114 | task_id: Uuid, |
| 98 | 115 | minutes: i32, |
| 99 | 116 | entry_date: NaiveDate, |
| 100 | 117 | ) -> sqlx::Result<u64> { |
| 101 | 118 | let res = sqlx::query( |
| 102 | 119 | "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 | 124 | .bind(task_id) |
| 106 | 125 | .bind(minutes) |
| 107 | 126 | .bind(entry_date) |
| 108 | 127 | .bind(id) |
| 109 | 128 | .bind(user_id) |
| 129 | + .bind(team_id) | |
| 110 | 130 | .execute(pool) |
| 111 | 131 | .await?; |
| 112 | 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> { | |
| 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?; | |
| 121 | 151 | Ok(res.rows_affected()) |
| 122 | 152 | } |
| @@ -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` premiers | 40 | +/// 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_date | 50 | "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_id | 51 | 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 |
| 48 | ORDER BY e.entry_date DESC, e.id DESC | 53 | 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_entries | 75 | + "SELECT e.task_id, SUM(e.minutes)::BIGINT |
| 69 | - WHERE user_id = $1 AND entry_date >= $2 AND entry_date < $3 | 76 | + 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 | .await | 85 | .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_date | 96 | "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_id | 97 | 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 | .await | 104 | .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 = $3 | 119 | "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 @@ | ||
| 1 | 1 | pub mod entries; |
| 2 | +pub mod invitations; | |
| 2 | 3 | pub mod stats; |
| 3 | 4 | pub mod tasks; |
| 5 | +pub mod teams; | |
| 4 | 6 | 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; | ||
| 5 | 5 | #[derive(sqlx::FromRow)] |
| 6 | 6 | pub struct StatRow { |
| 7 | 7 | pub user_id: Uuid, |
| 8 | + /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact). | |
| 9 | + pub username: String, | |
| 8 | 10 | pub task_id: Uuid, |
| 9 | 11 | pub task_name: String, |
| 10 | 12 | pub total: i64, |
| @@ -12,22 +14,27 @@ pub struct StatRow { | ||
| 12 | 14 | pub entries: i64, |
| 13 | 15 | } |
| 14 | 16 | |
| 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). | |
| 17 | 20 | pub async fn totals( |
| 18 | 21 | pool: &PgPool, |
| 22 | + team_id: Uuid, | |
| 19 | 23 | from: Option<NaiveDate>, |
| 20 | 24 | to: Option<NaiveDate>, |
| 21 | 25 | ) -> sqlx::Result<Vec<StatRow>> { |
| 22 | 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 | 28 | SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries |
| 25 | 29 | FROM time_entries e |
| 26 | 30 | 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", | |
| 30 | 36 | ) |
| 37 | + .bind(team_id) | |
| 31 | 38 | .bind(from) |
| 32 | 39 | .bind(to) |
| 33 | 40 | .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 entries | 28 | SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries |
| 25 | FROM time_entries e | 29 | FROM time_entries e |
| 26 | JOIN tasks t ON t.id = e.task_id | 30 | 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 { | ||
| 8 | 8 | pub archived: bool, |
| 9 | 9 | } |
| 10 | 10 | |
| 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) | |
| 14 | 15 | .fetch_all(pool) |
| 15 | 16 | .await |
| 16 | 17 | } |
| 17 | 18 | |
| 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) | |
| 22 | 24 | .fetch_all(pool) |
| 23 | 25 | .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 | 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 | 31 | .bind(id) |
| 32 | + .bind(team_id) | |
| 30 | 33 | .bind(name) |
| 31 | 34 | .execute(pool) |
| 32 | 35 | .await?; |
| 33 | 36 | Ok(id) |
| 34 | 37 | } |
| 35 | 38 | |
| 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") | |
| 38 | 42 | .bind(name) |
| 39 | 43 | .bind(id) |
| 44 | + .bind(team_id) | |
| 40 | 45 | .execute(pool) |
| 41 | 46 | .await?; |
| 42 | 47 | Ok(res.rows_affected()) |
| 43 | 48 | } |
| 44 | 49 | |
| 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") | |
| 47 | 52 | .bind(id) |
| 53 | + .bind(team_id) | |
| 48 | 54 | .execute(pool) |
| 49 | 55 | .await?; |
| 50 | 56 | 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 | .await | 16 | .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 | .await | 25 | .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 { | ||
| 13 | 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 | 16 | pub async fn find_credentials(pool: &PgPool, username: &str) -> sqlx::Result<Option<Credentials>> { |
| 28 | 17 | sqlx::query_as("SELECT id, password_hash FROM users WHERE username = $1") |
| 29 | 18 | .bind(username) |
| @@ -56,8 +45,10 @@ pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> { | ||
| 56 | 45 | Ok(row.map(|(u,)| u)) |
| 57 | 46 | } |
| 58 | 47 | |
| 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) | |
| 62 | 53 | .await |
| 63 | 54 | } |
| @@ -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 | .await | 53 | .await |
| 63 | } | 54 | } |
modified
src/main.rs +25 -3 | @@ -8,6 +8,7 @@ mod period; | ||
| 8 | 8 | mod render; |
| 9 | 9 | mod routes; |
| 10 | 10 | mod state; |
| 11 | +mod team; | |
| 11 | 12 | |
| 12 | 13 | use anyhow::{Context, Result}; |
| 13 | 14 | use clap::{Parser, Subcommand}; |
| @@ -31,20 +32,34 @@ enum Command { | ||
| 31 | 32 | #[command(subcommand)] |
| 32 | 33 | command: UserCommand, |
| 33 | 34 | }, |
| 35 | + /// Gestion des équipes | |
| 36 | + Team { | |
| 37 | + #[command(subcommand)] | |
| 38 | + command: TeamCommand, | |
| 39 | + }, | |
| 34 | 40 | /// Génère la paire de clés Biscuit racine |
| 35 | 41 | Keygen, |
| 36 | 42 | } |
| 37 | 43 | |
| 38 | 44 | #[derive(Subcommand)] |
| 39 | 45 | enum UserCommand { |
| 40 | - /// Crée un utilisateur | |
| 46 | + /// Crée un utilisateur (toujours rattaché à une équipe) | |
| 41 | 47 | Add { |
| 42 | 48 | username: String, |
| 43 | 49 | #[arg(long)] |
| 44 | 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 | 63 | #[tokio::main] |
| 49 | 64 | async fn main() -> Result<()> { |
| 50 | 65 | let cli = Cli::parse(); |
| @@ -55,8 +70,15 @@ async fn main() -> Result<()> { | ||
| 55 | 70 | Ok(()) |
| 56 | 71 | } |
| 57 | 72 | 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, | |
| 60 | 82 | } |
| 61 | 83 | } |
| 62 | 84 | |
| @@ -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 racine | 40 | /// 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 utilisateur | 46 | + /// 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> { | ||
| 20 | 20 | pub struct Shell { |
| 21 | 21 | pub title: String, |
| 22 | 22 | pub username: String, |
| 23 | + /// Nom de l'équipe active (pill de l'en-tête). | |
| 24 | + pub team_name: String, | |
| 23 | 25 | /// "add" | "stats" | "times" | "settings" |
| 24 | 26 | pub tab: &'static str, |
| 25 | 27 | pub toast: Option<String>, |
| 28 | + /// Invitations en attente — badge sur l'onglet Réglages. | |
| 29 | + pub invite_count: i64, | |
| 26 | 30 | } |
| @@ -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}; | ||
| 8 | 8 | use serde::Deserialize; |
| 9 | 9 | use uuid::Uuid; |
| 10 | 10 | |
| 11 | -use crate::auth::extractor::AuthUser; | |
| 12 | 11 | use crate::charts::{color_map, fmt_minutes}; |
| 13 | 12 | use crate::db::entries::{self, EntryRow}; |
| 14 | 13 | use crate::db::tasks::{self, TaskRow}; |
| @@ -17,6 +16,7 @@ use crate::period::{Week, day_label, paris_today}; | ||
| 17 | 16 | use crate::render::{HtmlTemplate, Shell}; |
| 18 | 17 | use crate::routes::shell; |
| 19 | 18 | use crate::state::AppState; |
| 19 | +use crate::team::ActiveTeam; | |
| 20 | 20 | |
| 21 | 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 | 83 | pub async fn entry_new( |
| 84 | 84 | State(state): State<AppState>, |
| 85 | - auth: AuthUser, | |
| 85 | + ctx: ActiveTeam, | |
| 86 | 86 | Query(q): Query<NewQuery>, |
| 87 | 87 | ) -> Result<Response, AppError> { |
| 88 | 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 | 90 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 91 | 91 | |
| 92 | 92 | // Étape 2 si une tâche (active) est choisie. |
| @@ -106,8 +106,8 @@ pub async fn entry_new( | ||
| 106 | 106 | }) |
| 107 | 107 | .collect(); |
| 108 | 108 | 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?, | |
| 111 | 111 | tache: picked.id, |
| 112 | 112 | picked_color: colors[&picked.id], |
| 113 | 113 | picked_name: picked.name.clone(), |
| @@ -128,10 +128,11 @@ pub async fn entry_new( | ||
| 128 | 128 | |
| 129 | 129 | // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives. |
| 130 | 130 | 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(); | |
| 135 | 136 | let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect(); |
| 136 | 137 | active.sort_by(|a, b| a.name.cmp(&b.name)); |
| 137 | 138 | let tasks = active |
| @@ -147,7 +148,7 @@ pub async fn entry_new( | ||
| 147 | 148 | }) |
| 148 | 149 | .collect(); |
| 149 | 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 | 152 | tasks, |
| 152 | 153 | }) |
| 153 | 154 | .into_response()) |
| @@ -172,14 +173,27 @@ fn validate(form: &EntryForm) -> Result<(), AppError> { | ||
| 172 | 173 | |
| 173 | 174 | pub async fn entry_create( |
| 174 | 175 | State(state): State<AppState>, |
| 175 | - auth: AuthUser, | |
| 176 | + ctx: ActiveTeam, | |
| 176 | 177 | Form(form): Form<EntryForm>, |
| 177 | 178 | ) -> Result<Redirect, AppError> { |
| 178 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 179 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 179 | 180 | return Err(AppError::Csrf); |
| 180 | 181 | } |
| 181 | 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 | 197 | Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) |
| 184 | 198 | } |
| 185 | 199 | |
| @@ -209,7 +223,7 @@ pub struct PageQuery { | ||
| 209 | 223 | |
| 210 | 224 | pub async fn entries_list( |
| 211 | 225 | State(state): State<AppState>, |
| 212 | - auth: AuthUser, | |
| 226 | + ctx: ActiveTeam, | |
| 213 | 227 | Query(q): Query<PageQuery>, |
| 214 | 228 | ) -> Result<HtmlTemplate<TimesPage>, AppError> { |
| 215 | 229 | let toast = match q.ok.as_deref() { |
| @@ -218,9 +232,10 @@ pub async fn entries_list( | ||
| 218 | 232 | _ => None, |
| 219 | 233 | }; |
| 220 | 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 | 239 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 225 | 240 | |
| 226 | 241 | let entries = rows |
| @@ -234,7 +249,7 @@ pub async fn entries_list( | ||
| 234 | 249 | }) |
| 235 | 250 | .collect(); |
| 236 | 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 | 253 | entries, |
| 239 | 254 | more_href: has_more.then(|| format!("/temps?page={}", page + 1)), |
| 240 | 255 | })) |
| @@ -262,14 +277,14 @@ pub struct EditPage { | ||
| 262 | 277 | |
| 263 | 278 | pub async fn entry_edit( |
| 264 | 279 | State(state): State<AppState>, |
| 265 | - auth: AuthUser, | |
| 280 | + ctx: ActiveTeam, | |
| 266 | 281 | Path(id): Path<Uuid>, |
| 267 | 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 | 284 | .await? |
| 270 | 285 | .ok_or(AppError::NotFound)?; |
| 271 | 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 | 288 | .await? |
| 274 | 289 | .into_iter() |
| 275 | 290 | .filter(|t| !t.archived || t.id == entry.task_id) |
| @@ -285,8 +300,8 @@ pub async fn entry_edit( | ||
| 285 | 300 | .collect(); |
| 286 | 301 | options.sort_by(|a, b| a.name.cmp(&b.name)); |
| 287 | 302 | 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?, | |
| 290 | 305 | action: format!("/temps/{id}"), |
| 291 | 306 | delete_action: format!("/temps/{id}/supprimer"), |
| 292 | 307 | tasks: options, |
| @@ -297,18 +312,19 @@ pub async fn entry_edit( | ||
| 297 | 312 | |
| 298 | 313 | pub async fn entry_update( |
| 299 | 314 | State(state): State<AppState>, |
| 300 | - auth: AuthUser, | |
| 315 | + ctx: ActiveTeam, | |
| 301 | 316 | Path(id): Path<Uuid>, |
| 302 | 317 | Form(form): Form<EntryForm>, |
| 303 | 318 | ) -> Result<Redirect, AppError> { |
| 304 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 319 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 305 | 320 | return Err(AppError::Csrf); |
| 306 | 321 | } |
| 307 | 322 | validate(&form)?; |
| 308 | 323 | let n = entries::update_owned( |
| 309 | 324 | &state.pool, |
| 310 | 325 | id, |
| 311 | - auth.id, | |
| 326 | + ctx.user.id, | |
| 327 | + ctx.team_id, | |
| 312 | 328 | form.tache, |
| 313 | 329 | form.minutes, |
| 314 | 330 | form.date, |
| @@ -327,14 +343,14 @@ pub struct CsrfOnly { | ||
| 327 | 343 | |
| 328 | 344 | pub async fn entry_delete( |
| 329 | 345 | State(state): State<AppState>, |
| 330 | - auth: AuthUser, | |
| 346 | + ctx: ActiveTeam, | |
| 331 | 347 | Path(id): Path<Uuid>, |
| 332 | 348 | Form(form): Form<CsrfOnly>, |
| 333 | 349 | ) -> Result<Redirect, AppError> { |
| 334 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 350 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 335 | 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 | 354 | return Err(AppError::NotFound); |
| 339 | 355 | } |
| 340 | 356 | 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 = active | 138 | 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 = rows | 241 | 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( | ||
| 87 | 87 | revocation::revoke(&state.pool, &state.revocations, &auth.rev_ids) |
| 88 | 88 | .await |
| 89 | 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 | 93 | Ok((jar, Redirect::to("/login")).into_response()) |
| 92 | 94 | } |
| @@ -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 | .await | 88 | .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; | ||
| 3 | 3 | mod settings; |
| 4 | 4 | mod statics; |
| 5 | 5 | mod stats; |
| 6 | +mod teams; | |
| 6 | 7 | |
| 7 | 8 | use axum::Router; |
| 8 | 9 | use axum::response::Redirect; |
| 9 | 10 | use axum::routing::{get, post}; |
| 10 | 11 | |
| 11 | 12 | use crate::auth::extractor::AuthUser; |
| 13 | +use crate::db::{invitations, users}; | |
| 12 | 14 | use crate::error::AppError; |
| 13 | 15 | use crate::render::Shell; |
| 14 | 16 | use crate::state::AppState; |
| 17 | +use crate::team::ActiveTeam; | |
| 15 | 18 | |
| 16 | 19 | pub fn router(state: AppState) -> Router { |
| 17 | 20 | Router::new() |
| @@ -38,6 +41,14 @@ pub fn router(state: AppState) -> Router { | ||
| 38 | 41 | "/parametres/mot-de-passe", |
| 39 | 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 | 52 | .route("/static/style.css", get(statics::css)) |
| 42 | 53 | .route("/manifest.json", get(statics::manifest)) |
| 43 | 54 | .route("/icon-512.png", get(statics::icon)) |
| @@ -54,21 +65,24 @@ async fn fallback_404() -> AppError { | ||
| 54 | 65 | 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 | 69 | pub async fn shell( |
| 59 | 70 | state: &AppState, |
| 60 | - auth: &AuthUser, | |
| 71 | + ctx: &ActiveTeam, | |
| 61 | 72 | title: &str, |
| 62 | 73 | tab: &'static str, |
| 63 | 74 | toast: Option<String>, |
| 64 | 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 | 77 | .await? |
| 67 | 78 | .unwrap_or_default(); |
| 79 | + let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; | |
| 68 | 80 | Ok(Shell { |
| 69 | 81 | title: title.to_string(), |
| 70 | 82 | username, |
| 83 | + team_name: ctx.team_name.clone(), | |
| 71 | 84 | tab, |
| 72 | 85 | toast, |
| 86 | + invite_count, | |
| 73 | 87 | }) |
| 74 | 88 | } |
| @@ -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::NotFound | 65 | 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; | ||
| 5 | 5 | use serde::Deserialize; |
| 6 | 6 | use uuid::Uuid; |
| 7 | 7 | |
| 8 | -use crate::auth::extractor::AuthUser; | |
| 9 | 8 | use crate::auth::password; |
| 10 | 9 | use crate::charts::color_map; |
| 11 | -use crate::db::{tasks, users}; | |
| 10 | +use crate::db::{invitations, tasks, teams, users}; | |
| 12 | 11 | use crate::error::AppError; |
| 13 | 12 | use crate::render::{HtmlTemplate, Shell}; |
| 14 | 13 | use crate::routes::shell; |
| 15 | 14 | use crate::state::AppState; |
| 15 | +use crate::team::ActiveTeam; | |
| 16 | 16 | |
| 17 | 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 | 38 | #[derive(Template)] |
| 20 | 39 | #[template(path = "settings.html")] |
| 21 | 40 | pub struct SettingsPage { |
| 22 | 41 | pub shell: Shell, |
| 23 | 42 | pub csrf: String, |
| 43 | + /// Erreur inline du bloc mot de passe. | |
| 24 | 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 | 55 | #[derive(Deserialize)] |
| 28 | 56 | pub struct SettingsQuery { |
| 29 | 57 | ok: Option<String>, |
| 30 | 58 | err: Option<String>, |
| 59 | + /// Username rejoué dans les erreurs d'invitation. | |
| 60 | + nom: Option<String>, | |
| 31 | 61 | } |
| 32 | 62 | |
| 33 | 63 | pub async fn settings_page( |
| 34 | 64 | State(state): State<AppState>, |
| 35 | - auth: AuthUser, | |
| 65 | + ctx: ActiveTeam, | |
| 36 | 66 | Query(q): Query<SettingsQuery>, |
| 37 | 67 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 38 | 68 | let toast = match q.ok.as_deref() { |
| 39 | 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 | 84 | let error = match q.err.as_deref() { |
| 43 | 85 | Some("mdp") => Some("L'ancien mot de passe est incorrect."), |
| @@ -45,10 +87,71 @@ pub async fn settings_page( | ||
| 45 | 87 | Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), |
| 46 | 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 | 144 | 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?, | |
| 51 | 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 | 176 | pub async fn tasks_page( |
| 74 | 177 | State(state): State<AppState>, |
| 75 | - auth: AuthUser, | |
| 178 | + ctx: ActiveTeam, | |
| 76 | 179 | Query(q): Query<SettingsQuery>, |
| 77 | 180 | ) -> Result<HtmlTemplate<TasksPage>, AppError> { |
| 78 | 181 | let toast = match (q.ok.as_deref(), q.err.as_deref()) { |
| @@ -86,7 +189,7 @@ pub async fn tasks_page( | ||
| 86 | 189 | (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()), |
| 87 | 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 | 193 | let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 91 | 194 | let mut list = by_creation; |
| 92 | 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 | 205 | .collect(); |
| 103 | 206 | 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?, | |
| 106 | 209 | tasks, |
| 107 | 210 | })) |
| 108 | 211 | } |
| @@ -119,17 +222,17 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | ||
| 119 | 222 | |
| 120 | 223 | pub async fn task_create( |
| 121 | 224 | State(state): State<AppState>, |
| 122 | - auth: AuthUser, | |
| 225 | + ctx: ActiveTeam, | |
| 123 | 226 | Form(form): Form<TaskForm>, |
| 124 | 227 | ) -> Result<Redirect, AppError> { |
| 125 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 228 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 126 | 229 | return Err(AppError::Csrf); |
| 127 | 230 | } |
| 128 | 231 | let name = form.name.trim(); |
| 129 | 232 | if name.is_empty() { |
| 130 | 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 | 236 | Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")), |
| 134 | 237 | Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")), |
| 135 | 238 | Err(e) => Err(e.into()), |
| @@ -138,18 +241,18 @@ pub async fn task_create( | ||
| 138 | 241 | |
| 139 | 242 | pub async fn task_rename( |
| 140 | 243 | State(state): State<AppState>, |
| 141 | - auth: AuthUser, | |
| 244 | + ctx: ActiveTeam, | |
| 142 | 245 | Path(id): Path<Uuid>, |
| 143 | 246 | Form(form): Form<TaskForm>, |
| 144 | 247 | ) -> Result<Redirect, AppError> { |
| 145 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 248 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 146 | 249 | return Err(AppError::Csrf); |
| 147 | 250 | } |
| 148 | 251 | let name = form.name.trim(); |
| 149 | 252 | if name.is_empty() { |
| 150 | 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 | 256 | Ok(0) => Err(AppError::NotFound), |
| 154 | 257 | Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")), |
| 155 | 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 | 270 | pub async fn task_toggle_archive( |
| 168 | 271 | State(state): State<AppState>, |
| 169 | - auth: AuthUser, | |
| 272 | + ctx: ActiveTeam, | |
| 170 | 273 | Path(id): Path<Uuid>, |
| 171 | 274 | Form(form): Form<ToggleForm>, |
| 172 | 275 | ) -> Result<Redirect, AppError> { |
| 173 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 276 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 174 | 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 | 280 | return Err(AppError::NotFound); |
| 178 | 281 | } |
| 179 | 282 | Ok(Redirect::to(if form.restaure.is_some() { |
| @@ -193,10 +296,10 @@ pub struct PasswordForm { | ||
| 193 | 296 | |
| 194 | 297 | pub async fn password_change( |
| 195 | 298 | State(state): State<AppState>, |
| 196 | - auth: AuthUser, | |
| 299 | + ctx: ActiveTeam, | |
| 197 | 300 | Form(form): Form<PasswordForm>, |
| 198 | 301 | ) -> Result<Redirect, AppError> { |
| 199 | - if !auth.check_csrf(&state, &form.csrf) { | |
| 302 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 200 | 303 | return Err(AppError::Csrf); |
| 201 | 304 | } |
| 202 | 305 | if form.nouveau != form.confirmation { |
| @@ -205,13 +308,13 @@ pub async fn password_change( | ||
| 205 | 308 | if form.nouveau.len() < 8 { |
| 206 | 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 | 312 | .await? |
| 210 | 313 | .ok_or(AppError::NotFound)?; |
| 211 | 314 | if !password::verify(&form.ancien, ¤t) { |
| 212 | 315 | return Ok(Redirect::to("/parametres?err=mdp")); |
| 213 | 316 | } |
| 214 | 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 | 319 | Ok(Redirect::to("/parametres?ok=mdp")) |
| 217 | 320 | } |
| @@ -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, ¤t) { | 314 | if !password::verify(&form.ancien, ¤t) { |
| 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 | + | |
| 1 | 3 | use askama::Template; |
| 2 | 4 | use axum::extract::{Query, State}; |
| 3 | 5 | use serde::Deserialize; |
| 6 | +use uuid::Uuid; | |
| 4 | 7 | |
| 5 | -use crate::auth::extractor::AuthUser; | |
| 6 | 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 | 11 | use crate::error::AppError; |
| 9 | 12 | use crate::period::{Month, Week}; |
| 10 | 13 | use crate::render::{HtmlTemplate, Shell}; |
| 11 | 14 | use crate::routes::shell; |
| 12 | 15 | use crate::state::AppState; |
| 16 | +use crate::team::ActiveTeam; | |
| 13 | 17 | |
| 14 | 18 | #[derive(Template)] |
| 15 | 19 | #[template(path = "stats.html")] |
| @@ -32,7 +36,7 @@ fn href(week: Week, month: Month) -> String { | ||
| 32 | 36 | |
| 33 | 37 | pub async fn stats_page( |
| 34 | 38 | State(state): State<AppState>, |
| 35 | - auth: AuthUser, | |
| 39 | + ctx: ActiveTeam, | |
| 36 | 40 | Query(q): Query<StatsQuery>, |
| 37 | 41 | ) -> Result<HtmlTemplate<StatsPage>, AppError> { |
| 38 | 42 | let week = q |
| @@ -49,13 +53,31 @@ pub async fn stats_page( | ||
| 49 | 53 | let (week_from, week_to) = week.bounds(); |
| 50 | 54 | let (month_from, month_to) = month.bounds(); |
| 51 | 55 | |
| 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?; | |
| 54 | 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?; | |
| 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(); | |
| 59 | 81 | |
| 60 | 82 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 61 | 83 | |
| @@ -96,7 +118,7 @@ pub async fn stats_page( | ||
| 96 | 118 | sheets.extend(s3); |
| 97 | 119 | |
| 98 | 120 | Ok(HtmlTemplate(StatsPage { |
| 99 | - shell: shell(&state, &auth, "Statistiques", "stats", None).await?, | |
| 121 | + shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, | |
| 100 | 122 | legend, |
| 101 | 123 | charts: vec![week_chart, month_chart, total_chart], |
| 102 | 124 | 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 = q | 42 | 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 @@ | ||
| 2 | 2 | {% block title %}{{ shell.title }} · time-time{% endblock %} |
| 3 | 3 | {% block body %} |
| 4 | 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 | 12 | <h1>{{ shell.title }}</h1> |
| 6 | - <span class="who">{{ shell.username }}</span> | |
| 7 | 13 | </header> |
| 8 | 14 | <main class="app-main"> |
| 9 | 15 | {% block content %}{% endblock %} |
| @@ -23,7 +29,10 @@ | ||
| 23 | 29 | <span>Mes temps</span> |
| 24 | 30 | </a> |
| 25 | 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 | 36 | <span>Réglages</span> |
| 28 | 37 | </a> |
| 29 | 38 | </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 @@ | ||
| 6 | 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 | 7 | </div> |
| 8 | 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 | 10 | <form method="post" action="/login"> |
| 11 | 11 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 12 | 12 | <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 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 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 | 25 | <div class="card-kicker">Compte</div> |
| 5 | 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 | 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 | 115 | <form method="post" action="/parametres/mot-de-passe"> |
| 11 | 116 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | 117 | <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 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 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 | 4 | <form method="post" action="/parametres/taches" class="task-add-row"> |
| 5 | 5 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 6 | 6 | <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 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | 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 | 5 | {% else %} |
| 6 | 6 | <ul class="times"> |
| 7 | 7 | {% 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 %} |