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

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

Download ZIP

feat(webauthn): page Sécurité — clés d'accès + mot de passe déménagéUnverified

/parametres/securite (tab Réglages) : liste/renommage/suppression des clés
(mdp exigé pour supprimer), formulaire de changement de mot de passe déplacé
depuis Réglages, retombée zéro-JS du formulaire d'ajout. Carte-lien dans
Réglages.
Julien Durillon committed 2026-08-26T14:07:00+02:00 Browse files
83a5aa4 parent: 8ea453c
modified src/routes/mod.rs +15 -1
@@ -1,5 +1,6 @@
11 mod entries;
22 pub(crate) mod login;
3+mod security;
34 mod settings;
45 mod signup;
56 mod statics;
@@ -50,9 +51,22 @@ pub fn router(state: AppState) -> Router {
5051 "/parametres/categories/{id}/archiver",
5152 post(settings::category_toggle_archive),
5253 )
54+ .route("/parametres/securite", get(security::security_page))
55+ .route(
56+ "/parametres/securite/cles",
57+ post(security::passkey_add_fallback),
58+ )
59+ .route(
60+ "/parametres/securite/cles/{id}",
61+ post(security::passkey_rename),
62+ )
63+ .route(
64+ "/parametres/securite/cles/{id}/supprimer",
65+ post(security::passkey_delete),
66+ )
5367 .route(
5468 "/parametres/mot-de-passe",
55- post(settings::password_change),
69+ post(security::password_change),
5670 )
5771 .route("/parametres/langue", post(settings::lang_change))
5872 .route("/parametres/affichage", post(settings::theme_change))
@@ -1,5 +1,6 @@
1 mod entries;1 mod entries;
2 pub(crate) mod login;2 pub(crate) mod login;
3+mod security;
3 mod settings;4 mod settings;
4 mod signup;5 mod signup;
5 mod statics;6 mod statics;
@@ -50,9 +51,22 @@ pub fn router(state: AppState) -> Router {
50 "/parametres/categories/{id}/archiver",51 "/parametres/categories/{id}/archiver",
51 post(settings::category_toggle_archive),52 post(settings::category_toggle_archive),
52 )53 )
54+ .route("/parametres/securite", get(security::security_page))
55+ .route(
56+ "/parametres/securite/cles",
57+ post(security::passkey_add_fallback),
58+ )
59+ .route(
60+ "/parametres/securite/cles/{id}",
61+ post(security::passkey_rename),
62+ )
63+ .route(
64+ "/parametres/securite/cles/{id}/supprimer",
65+ post(security::passkey_delete),
66+ )
53 .route(67 .route(
54 "/parametres/mot-de-passe",68 "/parametres/mot-de-passe",
55- post(settings::password_change),69+ post(security::password_change),
56 )70 )
57 .route("/parametres/langue", post(settings::lang_change))71 .route("/parametres/langue", post(settings::lang_change))
58 .route("/parametres/affichage", post(settings::theme_change))72 .route("/parametres/affichage", post(settings::theme_change))
added src/routes/security.rs +223 -0
new file mode 100644
@@ -0,0 +1,223 @@
1+//! Page Sécurité : changement de mot de passe (déménagé de Réglages) et
2+//! gestion des clés d'accès WebAuthn.
3+//!
4+//! La page vit TOUJOURS — c'est le foyer du mot de passe. Seule la section
5+//! clés est gouvernée par `WEBAUTHN_ORIGIN` : absente de l'écran quand la
6+//! feature est éteinte… sauf si des clés existent encore en base (liste et
7+//! suppression restent alors possibles — nettoyage d'orphelins, par exemple
8+//! après un changement de domaine qui a invalidé les credentials).
9+//!
10+//! Le mot de passe actuel est exigé pour ajouter comme pour supprimer une
11+//! clé : la session biscuit dure six mois, un appareil déverrouillé volé ne
12+//! doit pas pouvoir s'installer un accès durable ni saboter les clés.
13+
14+use askama::Template;
15+use axum::Form;
16+use axum::extract::{Path, Query, State};
17+use axum::response::Redirect;
18+use chrono::Datelike;
19+use serde::Deserialize;
20+use uuid::Uuid;
21+
22+use crate::auth::password;
23+use crate::db::{passkeys, users};
24+use crate::error::AppError;
25+use crate::i18n::p2;
26+use crate::period;
27+use crate::render::{HtmlTemplate, Shell};
28+use crate::routes::shell;
29+use crate::state::AppState;
30+use crate::team::ActiveTeam;
31+
32+pub struct KeyView {
33+ pub name: String,
34+ pub created: String,
35+ pub rename_action: String,
36+ pub delete_action: String,
37+}
38+
39+#[derive(Template)]
40+#[template(path = "security.html")]
41+pub struct SecurityPage {
42+ pub shell: Shell,
43+ pub csrf: String,
44+ /// La connexion par clé est-elle configurée (`WEBAUTHN_ORIGIN`) ?
45+ pub webauthn: bool,
46+ /// Erreur inline du bloc mot de passe.
47+ pub error: Option<&'static str>,
48+ /// Erreur inline du bloc clés d'accès.
49+ pub key_error: Option<&'static str>,
50+ pub keys: Vec<KeyView>,
51+}
52+
53+#[derive(Deserialize)]
54+pub struct SecurityQuery {
55+ ok: Option<String>,
56+ err: Option<String>,
57+}
58+
59+pub async fn security_page(
60+ State(state): State<AppState>,
61+ ctx: ActiveTeam,
62+ Query(q): Query<SecurityQuery>,
63+) -> Result<HtmlTemplate<SecurityPage>, AppError> {
64+ let tr = ctx.tr();
65+ let toast = match q.ok.as_deref() {
66+ Some("mdp") => Some(tr.toast_password_updated.to_string()),
67+ Some("cle-ajoutee") => Some(tr.toast_passkey_added.to_string()),
68+ Some("cle-renommee") => Some(tr.toast_passkey_renamed.to_string()),
69+ Some("cle-supprimee") => Some(tr.toast_passkey_deleted.to_string()),
70+ _ => None,
71+ };
72+ let error = match q.err.as_deref() {
73+ Some("mdp") => Some(tr.err_password_wrong),
74+ Some("confirm") => Some(tr.err_password_mismatch),
75+ Some("court") => Some(tr.err_password_short),
76+ _ => None,
77+ };
78+ let key_error = match q.err.as_deref() {
79+ Some("cle-mdp") => Some(tr.err_password_wrong),
80+ Some("cle") => Some(tr.err_passkey_failed),
81+ Some("cle-existe") => Some(tr.err_passkey_exists),
82+ Some("cle-nom") => Some(tr.err_passkey_name_empty),
83+ Some("cle-js") => Some(tr.passkey_js_required),
84+ _ => None,
85+ };
86+
87+ let keys = passkeys::list_for(&state.pool, ctx.user.id)
88+ .await?
89+ .into_iter()
90+ .map(|k| {
91+ // Les dates de l'app sont des jours Europe/Paris (cf. period).
92+ let day = k.created_at.with_timezone(&chrono_tz::Europe::Paris).date_naive();
93+ KeyView {
94+ name: k.label,
95+ created: p2(
96+ tr.passkey_added_on_pat,
97+ period::day_month(ctx.lang, day),
98+ day.year(),
99+ ),
100+ rename_action: format!("/parametres/securite/cles/{}", k.id),
101+ delete_action: format!("/parametres/securite/cles/{}/supprimer", k.id),
102+ }
103+ })
104+ .collect();
105+
106+ Ok(HtmlTemplate(SecurityPage {
107+ shell: shell(&state, &ctx, tr.title_security, "settings", toast).await?,
108+ csrf: ctx.user.csrf_token(&state),
109+ webauthn: state.webauthn.is_some(),
110+ error,
111+ key_error,
112+ keys,
113+ }))
114+}
115+
116+#[derive(Deserialize)]
117+pub struct PasswordForm {
118+ ancien: String,
119+ nouveau: String,
120+ confirmation: String,
121+ csrf: String,
122+}
123+
124+pub async fn password_change(
125+ State(state): State<AppState>,
126+ ctx: ActiveTeam,
127+ Form(form): Form<PasswordForm>,
128+) -> Result<Redirect, AppError> {
129+ if !ctx.user.check_csrf(&state, &form.csrf) {
130+ return Err(AppError::Csrf);
131+ }
132+ if form.nouveau != form.confirmation {
133+ return Ok(Redirect::to("/parametres/securite?err=confirm"));
134+ }
135+ if form.nouveau.len() < 8 {
136+ return Ok(Redirect::to("/parametres/securite?err=court"));
137+ }
138+ let current = users::password_hash(&state.pool, ctx.user.id)
139+ .await?
140+ .ok_or(AppError::NotFound)?;
141+ if !password::verify(&form.ancien, &current) {
142+ return Ok(Redirect::to("/parametres/securite?err=mdp"));
143+ }
144+ let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
145+ users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
146+ Ok(Redirect::to("/parametres/securite?ok=mdp"))
147+}
148+
149+#[derive(Deserialize)]
150+pub struct AddFallbackForm {
151+ csrf: String,
152+}
153+
154+/// Retombée zéro-JS du formulaire d'ajout de clé : WebAuthn n'existe qu'en
155+/// JavaScript (`navigator.credentials`), le POST nu ne peut donc qu'expliquer.
156+/// Jamais un échec silencieux — et 404 si la feature est éteinte, comme les
157+/// endpoints de cérémonie.
158+pub async fn passkey_add_fallback(
159+ State(state): State<AppState>,
160+ ctx: ActiveTeam,
161+ Form(form): Form<AddFallbackForm>,
162+) -> Result<Redirect, AppError> {
163+ if !ctx.user.check_csrf(&state, &form.csrf) {
164+ return Err(AppError::Csrf);
165+ }
166+ if state.webauthn.is_none() {
167+ return Err(AppError::NotFound);
168+ }
169+ Ok(Redirect::to("/parametres/securite?err=cle-js"))
170+}
171+
172+#[derive(Deserialize)]
173+pub struct RenameForm {
174+ nom: String,
175+ csrf: String,
176+}
177+
178+pub async fn passkey_rename(
179+ State(state): State<AppState>,
180+ ctx: ActiveTeam,
181+ Path(id): Path<Uuid>,
182+ Form(form): Form<RenameForm>,
183+) -> Result<Redirect, AppError> {
184+ if !ctx.user.check_csrf(&state, &form.csrf) {
185+ return Err(AppError::Csrf);
186+ }
187+ let nom = form.nom.trim();
188+ if nom.is_empty() {
189+ return Ok(Redirect::to("/parametres/securite?err=cle-nom"));
190+ }
191+ if passkeys::rename(&state.pool, id, ctx.user.id, nom).await? == 0 {
192+ return Err(AppError::NotFound);
193+ }
194+ Ok(Redirect::to("/parametres/securite?ok=cle-renommee"))
195+}
196+
197+#[derive(Deserialize)]
198+pub struct DeleteForm {
199+ mdp: String,
200+ csrf: String,
201+}
202+
203+/// Supprimer la dernière clé est permis : le mot de passe, lui, reste.
204+pub async fn passkey_delete(
205+ State(state): State<AppState>,
206+ ctx: ActiveTeam,
207+ Path(id): Path<Uuid>,
208+ Form(form): Form<DeleteForm>,
209+) -> Result<Redirect, AppError> {
210+ if !ctx.user.check_csrf(&state, &form.csrf) {
211+ return Err(AppError::Csrf);
212+ }
213+ let current = users::password_hash(&state.pool, ctx.user.id)
214+ .await?
215+ .ok_or(AppError::NotFound)?;
216+ if !password::verify(&form.mdp, &current) {
217+ return Ok(Redirect::to("/parametres/securite?err=cle-mdp"));
218+ }
219+ if passkeys::delete(&state.pool, id, ctx.user.id).await? == 0 {
220+ return Err(AppError::NotFound);
221+ }
222+ Ok(Redirect::to("/parametres/securite?ok=cle-supprimee"))
223+}
new file mode 100644
@@ -0,0 +1,223 @@
1+//! Page Sécurité : changement de mot de passe (déménagé de Réglages) et
2+//! gestion des clés d'accès WebAuthn.
3+//!
4+//! La page vit TOUJOURS — c'est le foyer du mot de passe. Seule la section
5+//! clés est gouvernée par `WEBAUTHN_ORIGIN` : absente de l'écran quand la
6+//! feature est éteinte… sauf si des clés existent encore en base (liste et
7+//! suppression restent alors possibles — nettoyage d'orphelins, par exemple
8+//! après un changement de domaine qui a invalidé les credentials).
9+//!
10+//! Le mot de passe actuel est exigé pour ajouter comme pour supprimer une
11+//! clé : la session biscuit dure six mois, un appareil déverrouillé volé ne
12+//! doit pas pouvoir s'installer un accès durable ni saboter les clés.
13+
14+use askama::Template;
15+use axum::Form;
16+use axum::extract::{Path, Query, State};
17+use axum::response::Redirect;
18+use chrono::Datelike;
19+use serde::Deserialize;
20+use uuid::Uuid;
21+
22+use crate::auth::password;
23+use crate::db::{passkeys, users};
24+use crate::error::AppError;
25+use crate::i18n::p2;
26+use crate::period;
27+use crate::render::{HtmlTemplate, Shell};
28+use crate::routes::shell;
29+use crate::state::AppState;
30+use crate::team::ActiveTeam;
31+
32+pub struct KeyView {
33+ pub name: String,
34+ pub created: String,
35+ pub rename_action: String,
36+ pub delete_action: String,
37+}
38+
39+#[derive(Template)]
40+#[template(path = "security.html")]
41+pub struct SecurityPage {
42+ pub shell: Shell,
43+ pub csrf: String,
44+ /// La connexion par clé est-elle configurée (`WEBAUTHN_ORIGIN`) ?
45+ pub webauthn: bool,
46+ /// Erreur inline du bloc mot de passe.
47+ pub error: Option<&'static str>,
48+ /// Erreur inline du bloc clés d'accès.
49+ pub key_error: Option<&'static str>,
50+ pub keys: Vec<KeyView>,
51+}
52+
53+#[derive(Deserialize)]
54+pub struct SecurityQuery {
55+ ok: Option<String>,
56+ err: Option<String>,
57+}
58+
59+pub async fn security_page(
60+ State(state): State<AppState>,
61+ ctx: ActiveTeam,
62+ Query(q): Query<SecurityQuery>,
63+) -> Result<HtmlTemplate<SecurityPage>, AppError> {
64+ let tr = ctx.tr();
65+ let toast = match q.ok.as_deref() {
66+ Some("mdp") => Some(tr.toast_password_updated.to_string()),
67+ Some("cle-ajoutee") => Some(tr.toast_passkey_added.to_string()),
68+ Some("cle-renommee") => Some(tr.toast_passkey_renamed.to_string()),
69+ Some("cle-supprimee") => Some(tr.toast_passkey_deleted.to_string()),
70+ _ => None,
71+ };
72+ let error = match q.err.as_deref() {
73+ Some("mdp") => Some(tr.err_password_wrong),
74+ Some("confirm") => Some(tr.err_password_mismatch),
75+ Some("court") => Some(tr.err_password_short),
76+ _ => None,
77+ };
78+ let key_error = match q.err.as_deref() {
79+ Some("cle-mdp") => Some(tr.err_password_wrong),
80+ Some("cle") => Some(tr.err_passkey_failed),
81+ Some("cle-existe") => Some(tr.err_passkey_exists),
82+ Some("cle-nom") => Some(tr.err_passkey_name_empty),
83+ Some("cle-js") => Some(tr.passkey_js_required),
84+ _ => None,
85+ };
86+
87+ let keys = passkeys::list_for(&state.pool, ctx.user.id)
88+ .await?
89+ .into_iter()
90+ .map(|k| {
91+ // Les dates de l'app sont des jours Europe/Paris (cf. period).
92+ let day = k.created_at.with_timezone(&chrono_tz::Europe::Paris).date_naive();
93+ KeyView {
94+ name: k.label,
95+ created: p2(
96+ tr.passkey_added_on_pat,
97+ period::day_month(ctx.lang, day),
98+ day.year(),
99+ ),
100+ rename_action: format!("/parametres/securite/cles/{}", k.id),
101+ delete_action: format!("/parametres/securite/cles/{}/supprimer", k.id),
102+ }
103+ })
104+ .collect();
105+
106+ Ok(HtmlTemplate(SecurityPage {
107+ shell: shell(&state, &ctx, tr.title_security, "settings", toast).await?,
108+ csrf: ctx.user.csrf_token(&state),
109+ webauthn: state.webauthn.is_some(),
110+ error,
111+ key_error,
112+ keys,
113+ }))
114+}
115+
116+#[derive(Deserialize)]
117+pub struct PasswordForm {
118+ ancien: String,
119+ nouveau: String,
120+ confirmation: String,
121+ csrf: String,
122+}
123+
124+pub async fn password_change(
125+ State(state): State<AppState>,
126+ ctx: ActiveTeam,
127+ Form(form): Form<PasswordForm>,
128+) -> Result<Redirect, AppError> {
129+ if !ctx.user.check_csrf(&state, &form.csrf) {
130+ return Err(AppError::Csrf);
131+ }
132+ if form.nouveau != form.confirmation {
133+ return Ok(Redirect::to("/parametres/securite?err=confirm"));
134+ }
135+ if form.nouveau.len() < 8 {
136+ return Ok(Redirect::to("/parametres/securite?err=court"));
137+ }
138+ let current = users::password_hash(&state.pool, ctx.user.id)
139+ .await?
140+ .ok_or(AppError::NotFound)?;
141+ if !password::verify(&form.ancien, &current) {
142+ return Ok(Redirect::to("/parametres/securite?err=mdp"));
143+ }
144+ let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
145+ users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
146+ Ok(Redirect::to("/parametres/securite?ok=mdp"))
147+}
148+
149+#[derive(Deserialize)]
150+pub struct AddFallbackForm {
151+ csrf: String,
152+}
153+
154+/// Retombée zéro-JS du formulaire d'ajout de clé : WebAuthn n'existe qu'en
155+/// JavaScript (`navigator.credentials`), le POST nu ne peut donc qu'expliquer.
156+/// Jamais un échec silencieux — et 404 si la feature est éteinte, comme les
157+/// endpoints de cérémonie.
158+pub async fn passkey_add_fallback(
159+ State(state): State<AppState>,
160+ ctx: ActiveTeam,
161+ Form(form): Form<AddFallbackForm>,
162+) -> Result<Redirect, AppError> {
163+ if !ctx.user.check_csrf(&state, &form.csrf) {
164+ return Err(AppError::Csrf);
165+ }
166+ if state.webauthn.is_none() {
167+ return Err(AppError::NotFound);
168+ }
169+ Ok(Redirect::to("/parametres/securite?err=cle-js"))
170+}
171+
172+#[derive(Deserialize)]
173+pub struct RenameForm {
174+ nom: String,
175+ csrf: String,
176+}
177+
178+pub async fn passkey_rename(
179+ State(state): State<AppState>,
180+ ctx: ActiveTeam,
181+ Path(id): Path<Uuid>,
182+ Form(form): Form<RenameForm>,
183+) -> Result<Redirect, AppError> {
184+ if !ctx.user.check_csrf(&state, &form.csrf) {
185+ return Err(AppError::Csrf);
186+ }
187+ let nom = form.nom.trim();
188+ if nom.is_empty() {
189+ return Ok(Redirect::to("/parametres/securite?err=cle-nom"));
190+ }
191+ if passkeys::rename(&state.pool, id, ctx.user.id, nom).await? == 0 {
192+ return Err(AppError::NotFound);
193+ }
194+ Ok(Redirect::to("/parametres/securite?ok=cle-renommee"))
195+}
196+
197+#[derive(Deserialize)]
198+pub struct DeleteForm {
199+ mdp: String,
200+ csrf: String,
201+}
202+
203+/// Supprimer la dernière clé est permis : le mot de passe, lui, reste.
204+pub async fn passkey_delete(
205+ State(state): State<AppState>,
206+ ctx: ActiveTeam,
207+ Path(id): Path<Uuid>,
208+ Form(form): Form<DeleteForm>,
209+) -> Result<Redirect, AppError> {
210+ if !ctx.user.check_csrf(&state, &form.csrf) {
211+ return Err(AppError::Csrf);
212+ }
213+ let current = users::password_hash(&state.pool, ctx.user.id)
214+ .await?
215+ .ok_or(AppError::NotFound)?;
216+ if !password::verify(&form.mdp, &current) {
217+ return Ok(Redirect::to("/parametres/securite?err=cle-mdp"));
218+ }
219+ if passkeys::delete(&state.pool, id, ctx.user.id).await? == 0 {
220+ return Err(AppError::NotFound);
221+ }
222+ Ok(Redirect::to("/parametres/securite?ok=cle-supprimee"))
223+}
modified src/routes/settings.rs +0 -43
@@ -7,7 +7,6 @@ use axum::response::Redirect;
77 use serde::Deserialize;
88 use uuid::Uuid;
99
10-use crate::auth::password;
1110 use crate::charts::color_map;
1211 use crate::db::{invitations, categories, teams, users};
1312 use crate::error::AppError;
@@ -36,8 +35,6 @@ pub struct SentView {
3635 pub struct SettingsPage {
3736 pub shell: Shell,
3837 pub csrf: String,
39- /// Erreur inline du bloc mot de passe.
40- pub error: Option<&'static str>,
4138 pub invites: Vec<InviteView>,
4239 pub account_line: String,
4340 pub members: Vec<String>,
@@ -93,7 +90,6 @@ pub async fn settings_page(
9390 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
9491 let tr = ctx.tr();
9592 let toast = match q.ok.as_deref() {
96- Some("mdp") => Some(tr.toast_password_updated.to_string()),
9793 Some("invite") => Some(tr.toast_invite_sent.to_string()),
9894 Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)),
9995 Some("refus") => Some(tr.toast_invite_refused.to_string()),
@@ -111,12 +107,6 @@ pub async fn settings_page(
111107 _ => None,
112108 },
113109 };
114- let error = match q.err.as_deref() {
115- Some("mdp") => Some(tr.err_password_wrong),
116- Some("confirm") => Some(tr.err_password_mismatch),
117- Some("court") => Some(tr.err_password_short),
118- _ => None,
119- };
120110 let nom = q.nom.as_deref().unwrap_or("");
121111 let invite_error = match q.err.as_deref() {
122112 Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)),
@@ -205,7 +195,6 @@ pub async fn settings_page(
205195 Ok(HtmlTemplate(SettingsPage {
206196 csrf: ctx.user.csrf_token(&state),
207197 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
208- error,
209198 langs,
210199 themes,
211200 chart_styles,
@@ -535,35 +524,3 @@ pub async fn category_toggle_archive(
535524 }))
536525 }
537526
538-#[derive(Deserialize)]
539-pub struct PasswordForm {
540- ancien: String,
541- nouveau: String,
542- confirmation: String,
543- csrf: String,
544-}
545-
546-pub async fn password_change(
547- State(state): State<AppState>,
548- ctx: ActiveTeam,
549- Form(form): Form<PasswordForm>,
550-) -> Result<Redirect, AppError> {
551- if !ctx.user.check_csrf(&state, &form.csrf) {
552- return Err(AppError::Csrf);
553- }
554- if form.nouveau != form.confirmation {
555- return Ok(Redirect::to("/parametres?err=confirm"));
556- }
557- if form.nouveau.len() < 8 {
558- return Ok(Redirect::to("/parametres?err=court"));
559- }
560- let current = users::password_hash(&state.pool, ctx.user.id)
561- .await?
562- .ok_or(AppError::NotFound)?;
563- if !password::verify(&form.ancien, &current) {
564- return Ok(Redirect::to("/parametres?err=mdp"));
565- }
566- let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
567- users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
568- Ok(Redirect::to("/parametres?ok=mdp"))
569-}
@@ -7,7 +7,6 @@ use axum::response::Redirect;
7 use serde::Deserialize;7 use serde::Deserialize;
8 use uuid::Uuid;8 use uuid::Uuid;
9 9
10-use crate::auth::password;
11 use crate::charts::color_map;10 use crate::charts::color_map;
12 use crate::db::{invitations, categories, teams, users};11 use crate::db::{invitations, categories, teams, users};
13 use crate::error::AppError;12 use crate::error::AppError;
@@ -36,8 +35,6 @@ pub struct SentView {
36 pub struct SettingsPage {35 pub struct SettingsPage {
37 pub shell: Shell,36 pub shell: Shell,
38 pub csrf: String,37 pub csrf: String,
39- /// Erreur inline du bloc mot de passe.
40- pub error: Option<&'static str>,
41 pub invites: Vec<InviteView>,38 pub invites: Vec<InviteView>,
42 pub account_line: String,39 pub account_line: String,
43 pub members: Vec<String>,40 pub members: Vec<String>,
@@ -93,7 +90,6 @@ pub async fn settings_page(
93 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {90 ) -> Result<HtmlTemplate<SettingsPage>, AppError> {
94 let tr = ctx.tr();91 let tr = ctx.tr();
95 let toast = match q.ok.as_deref() {92 let toast = match q.ok.as_deref() {
96- Some("mdp") => Some(tr.toast_password_updated.to_string()),
97 Some("invite") => Some(tr.toast_invite_sent.to_string()),93 Some("invite") => Some(tr.toast_invite_sent.to_string()),
98 Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)),94 Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)),
99 Some("refus") => Some(tr.toast_invite_refused.to_string()),95 Some("refus") => Some(tr.toast_invite_refused.to_string()),
@@ -111,12 +107,6 @@ pub async fn settings_page(
111 _ => None,107 _ => None,
112 },108 },
113 };109 };
114- let error = match q.err.as_deref() {
115- Some("mdp") => Some(tr.err_password_wrong),
116- Some("confirm") => Some(tr.err_password_mismatch),
117- Some("court") => Some(tr.err_password_short),
118- _ => None,
119- };
120 let nom = q.nom.as_deref().unwrap_or("");110 let nom = q.nom.as_deref().unwrap_or("");
121 let invite_error = match q.err.as_deref() {111 let invite_error = match q.err.as_deref() {
122 Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)),112 Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)),
@@ -205,7 +195,6 @@ pub async fn settings_page(
205 Ok(HtmlTemplate(SettingsPage {195 Ok(HtmlTemplate(SettingsPage {
206 csrf: ctx.user.csrf_token(&state),196 csrf: ctx.user.csrf_token(&state),
207 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,197 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
208- error,
209 langs,198 langs,
210 themes,199 themes,
211 chart_styles,200 chart_styles,
@@ -535,35 +524,3 @@ pub async fn category_toggle_archive(
535 }))524 }))
536 }525 }
537 526
538-#[derive(Deserialize)]
539-pub struct PasswordForm {
540- ancien: String,
541- nouveau: String,
542- confirmation: String,
543- csrf: String,
544-}
545-
546-pub async fn password_change(
547- State(state): State<AppState>,
548- ctx: ActiveTeam,
549- Form(form): Form<PasswordForm>,
550-) -> Result<Redirect, AppError> {
551- if !ctx.user.check_csrf(&state, &form.csrf) {
552- return Err(AppError::Csrf);
553- }
554- if form.nouveau != form.confirmation {
555- return Ok(Redirect::to("/parametres?err=confirm"));
556- }
557- if form.nouveau.len() < 8 {
558- return Ok(Redirect::to("/parametres?err=court"));
559- }
560- let current = users::password_hash(&state.pool, ctx.user.id)
561- .await?
562- .ok_or(AppError::NotFound)?;
563- if !password::verify(&form.ancien, &current) {
564- return Ok(Redirect::to("/parametres?err=mdp"));
565- }
566- let new_hash = password::hash(&form.nouveau).map_err(AppError::Internal)?;
567- users::update_password(&state.pool, ctx.user.id, &new_hash).await?;
568- Ok(Redirect::to("/parametres?ok=mdp"))
569-}
added templates/security.html +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<p class="step-hint">{{ shell.tr.security_hint }}</p>
4+
5+{% if webauthn || !keys.is_empty() %}
6+<h2 style="font-size:16px;margin:0 0 var(--space-2)">{{ shell.tr.passkeys_title }}</h2>
7+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.passkeys_hint }}</p>
8+{% if !webauthn %}
9+<p class="form-error" style="margin:0 0 var(--space-3)">{{ shell.tr.passkey_disabled_note }}</p>
10+{% endif %}
11+
12+{% for k in keys %}
13+<div class="category-row">
14+ <form method="post" action="{{ k.rename_action }}" class="rename">
15+ <input type="hidden" name="csrf" value="{{ csrf }}">
16+ <input class="input" name="nom" value="{{ k.name }}" required aria-label="{{ shell.tr.passkey_rename_aria }}">
17+ {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input (cf. categories.html). #}
18+ <button type="submit" class="visually-hidden" tabindex="-1">{{ shell.tr.rename }}</button>
19+ </form>
20+ <span class="text-muted" style="font-size:12px;white-space:nowrap">{{ k.created }}</span>
21+</div>
22+<details style="margin:0 0 var(--space-3)">
23+ <summary class="btn btn-ghost" style="font-size:12px">{{ shell.tr.passkey_delete }}</summary>
24+ <form method="post" action="{{ k.delete_action }}" style="display:flex;gap:var(--space-2);align-items:center;margin-top:var(--space-2)">
25+ <input type="hidden" name="csrf" value="{{ csrf }}">
26+ <input class="input" name="mdp" type="password" autocomplete="current-password" required
27+ aria-label="{{ shell.tr.current_password }}" placeholder="{{ shell.tr.current_password }}"
28+ style="flex:1;min-height:44px">
29+ <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.passkey_delete_confirm }}</button>
30+ </form>
31+ <p class="field-hint" style="margin-top:var(--space-1)">{{ shell.tr.passkey_delete_why }}</p>
32+</details>
33+{% endfor %}
34+{% if keys.is_empty() && webauthn %}
35+<p class="text-muted" style="font-size:13px;margin:0 0 var(--space-3)">{{ shell.tr.passkey_none }}</p>
36+{% endif %}
37+
38+{% if webauthn %}
39+<form method="post" action="/parametres/securite/cles" data-webauthn-add style="margin-top:var(--space-3)">
40+ <input type="hidden" name="csrf" value="{{ csrf }}">
41+ <div class="field" style="margin-bottom:var(--space-3)">
42+ <label for="cle-nom">{{ shell.tr.passkey_name_label }}</label>
43+ <input class="input" id="cle-nom" name="nom" placeholder="{{ shell.tr.passkey_name_ph }}" required style="min-height:44px">
44+ </div>
45+ <div class="field" style="margin-bottom:var(--space-3)">
46+ <label for="cle-mdp">{{ shell.tr.current_password }}</label>
47+ <input class="input" id="cle-mdp" name="mdp" type="password" autocomplete="current-password" required
48+ aria-describedby="cle-mdp-aide" style="min-height:44px">
49+ <p class="field-hint" id="cle-mdp-aide">{{ shell.tr.passkey_password_why }}</p>
50+ </div>
51+ {% if let Some(e) = key_error %}<p class="form-error">{{ e }}</p>{% endif %}
52+ <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript>
53+ <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button>
54+</form>
55+{% endif %}
56+{% endif %}
57+
58+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
59+<form method="post" action="/parametres/mot-de-passe">
60+ <input type="hidden" name="csrf" value="{{ csrf }}">
61+ <div class="field" style="margin-bottom:var(--space-3)">
62+ <label for="ancien">{{ shell.tr.current_password }}</label>
63+ <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px">
64+ </div>
65+ <div class="field" style="margin-bottom:var(--space-3)">
66+ <label for="nouveau">{{ shell.tr.new_password }}</label>
67+ <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
68+ </div>
69+ <div class="field" style="margin-bottom:var(--space-4)">
70+ <label for="confirmation">{{ shell.tr.confirmation }}</label>
71+ <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
72+ </div>
73+ {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
74+ <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button>
75+</form>
76+
77+<a class="btn btn-ghost" style="margin-top:var(--space-6)" href="/parametres">{{ shell.tr.back_settings }}</a>
78+{% endblock %}
new file mode 100644
@@ -0,0 +1,78 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<p class="step-hint">{{ shell.tr.security_hint }}</p>
4+
5+{% if webauthn || !keys.is_empty() %}
6+<h2 style="font-size:16px;margin:0 0 var(--space-2)">{{ shell.tr.passkeys_title }}</h2>
7+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.passkeys_hint }}</p>
8+{% if !webauthn %}
9+<p class="form-error" style="margin:0 0 var(--space-3)">{{ shell.tr.passkey_disabled_note }}</p>
10+{% endif %}
11+
12+{% for k in keys %}
13+<div class="category-row">
14+ <form method="post" action="{{ k.rename_action }}" class="rename">
15+ <input type="hidden" name="csrf" value="{{ csrf }}">
16+ <input class="input" name="nom" value="{{ k.name }}" required aria-label="{{ shell.tr.passkey_rename_aria }}">
17+ {# tabindex=-1 : la soumission clavier passe par Entrée dans l'input (cf. categories.html). #}
18+ <button type="submit" class="visually-hidden" tabindex="-1">{{ shell.tr.rename }}</button>
19+ </form>
20+ <span class="text-muted" style="font-size:12px;white-space:nowrap">{{ k.created }}</span>
21+</div>
22+<details style="margin:0 0 var(--space-3)">
23+ <summary class="btn btn-ghost" style="font-size:12px">{{ shell.tr.passkey_delete }}</summary>
24+ <form method="post" action="{{ k.delete_action }}" style="display:flex;gap:var(--space-2);align-items:center;margin-top:var(--space-2)">
25+ <input type="hidden" name="csrf" value="{{ csrf }}">
26+ <input class="input" name="mdp" type="password" autocomplete="current-password" required
27+ aria-label="{{ shell.tr.current_password }}" placeholder="{{ shell.tr.current_password }}"
28+ style="flex:1;min-height:44px">
29+ <button type="submit" class="btn btn-secondary" style="min-height:44px">{{ shell.tr.passkey_delete_confirm }}</button>
30+ </form>
31+ <p class="field-hint" style="margin-top:var(--space-1)">{{ shell.tr.passkey_delete_why }}</p>
32+</details>
33+{% endfor %}
34+{% if keys.is_empty() && webauthn %}
35+<p class="text-muted" style="font-size:13px;margin:0 0 var(--space-3)">{{ shell.tr.passkey_none }}</p>
36+{% endif %}
37+
38+{% if webauthn %}
39+<form method="post" action="/parametres/securite/cles" data-webauthn-add style="margin-top:var(--space-3)">
40+ <input type="hidden" name="csrf" value="{{ csrf }}">
41+ <div class="field" style="margin-bottom:var(--space-3)">
42+ <label for="cle-nom">{{ shell.tr.passkey_name_label }}</label>
43+ <input class="input" id="cle-nom" name="nom" placeholder="{{ shell.tr.passkey_name_ph }}" required style="min-height:44px">
44+ </div>
45+ <div class="field" style="margin-bottom:var(--space-3)">
46+ <label for="cle-mdp">{{ shell.tr.current_password }}</label>
47+ <input class="input" id="cle-mdp" name="mdp" type="password" autocomplete="current-password" required
48+ aria-describedby="cle-mdp-aide" style="min-height:44px">
49+ <p class="field-hint" id="cle-mdp-aide">{{ shell.tr.passkey_password_why }}</p>
50+ </div>
51+ {% if let Some(e) = key_error %}<p class="form-error">{{ e }}</p>{% endif %}
52+ <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript>
53+ <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button>
54+</form>
55+{% endif %}
56+{% endif %}
57+
58+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
59+<form method="post" action="/parametres/mot-de-passe">
60+ <input type="hidden" name="csrf" value="{{ csrf }}">
61+ <div class="field" style="margin-bottom:var(--space-3)">
62+ <label for="ancien">{{ shell.tr.current_password }}</label>
63+ <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px">
64+ </div>
65+ <div class="field" style="margin-bottom:var(--space-3)">
66+ <label for="nouveau">{{ shell.tr.new_password }}</label>
67+ <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
68+ </div>
69+ <div class="field" style="margin-bottom:var(--space-4)">
70+ <label for="confirmation">{{ shell.tr.confirmation }}</label>
71+ <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
72+ </div>
73+ {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
74+ <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button>
75+</form>
76+
77+<a class="btn btn-ghost" style="margin-top:var(--space-6)" href="/parametres">{{ shell.tr.back_settings }}</a>
78+{% endblock %}
modified templates/settings.html +9 -19
@@ -40,7 +40,7 @@
4040 {% call c::chevron_right(17) %}{% endcall %}
4141 </a>
4242
43-<a class="card times-link" href="/parametres/categories" style="margin-bottom:var(--space-6)">
43+<a class="card times-link" href="/parametres/categories" style="margin-bottom:var(--space-3)">
4444 <span style="flex:1">
4545 <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_categories }}</span>
4646 <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.manage_categories_desc }}</span>
@@ -48,6 +48,14 @@
4848 {% call c::chevron_right(17) %}{% endcall %}
4949 </a>
5050
51+<a class="card times-link" href="/parametres/securite" style="margin-bottom:var(--space-6)">
52+ <span style="flex:1">
53+ <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_security }}</span>
54+ <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.security_card_desc }}</span>
55+ </span>
56+ {% call c::chevron_right(17) %}{% endcall %}
57+</a>
58+
5159 <form method="post" action="/equipes/renommer">
5260 <input type="hidden" name="csrf" value="{{ csrf }}">
5361 <div class="field" style="margin-bottom:var(--space-2)">
@@ -189,24 +197,6 @@
189197 </div>
190198
191199 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
192-<form method="post" action="/parametres/mot-de-passe">
193- <input type="hidden" name="csrf" value="{{ csrf }}">
194- <div class="field" style="margin-bottom:var(--space-3)">
195- <label for="ancien">{{ shell.tr.current_password }}</label>
196- <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px">
197- </div>
198- <div class="field" style="margin-bottom:var(--space-3)">
199- <label for="nouveau">{{ shell.tr.new_password }}</label>
200- <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
201- </div>
202- <div class="field" style="margin-bottom:var(--space-4)">
203- <label for="confirmation">{{ shell.tr.confirmation }}</label>
204- <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
205- </div>
206- {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
207- <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button>
208-</form>
209-
210200 <form method="post" action="/logout">
211201 <input type="hidden" name="csrf" value="{{ csrf }}">
212202 <button type="submit" class="btn btn-block"
@@ -40,7 +40,7 @@
40 {% call c::chevron_right(17) %}{% endcall %}40 {% call c::chevron_right(17) %}{% endcall %}
41 </a>41 </a>
42 42
43-<a class="card times-link" href="/parametres/categories" style="margin-bottom:var(--space-6)">43+<a class="card times-link" href="/parametres/categories" style="margin-bottom:var(--space-3)">
44 <span style="flex:1">44 <span style="flex:1">
45 <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_categories }}</span>45 <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_categories }}</span>
46 <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.manage_categories_desc }}</span>46 <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.manage_categories_desc }}</span>
@@ -48,6 +48,14 @@
48 {% call c::chevron_right(17) %}{% endcall %}48 {% call c::chevron_right(17) %}{% endcall %}
49 </a>49 </a>
50 50
51+<a class="card times-link" href="/parametres/securite" style="margin-bottom:var(--space-6)">
52+ <span style="flex:1">
53+ <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_security }}</span>
54+ <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.security_card_desc }}</span>
55+ </span>
56+ {% call c::chevron_right(17) %}{% endcall %}
57+</a>
58+
51 <form method="post" action="/equipes/renommer">59 <form method="post" action="/equipes/renommer">
52 <input type="hidden" name="csrf" value="{{ csrf }}">60 <input type="hidden" name="csrf" value="{{ csrf }}">
53 <div class="field" style="margin-bottom:var(--space-2)">61 <div class="field" style="margin-bottom:var(--space-2)">
@@ -189,24 +197,6 @@
189 </div>197 </div>
190 198
191 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>199 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
192-<form method="post" action="/parametres/mot-de-passe">
193- <input type="hidden" name="csrf" value="{{ csrf }}">
194- <div class="field" style="margin-bottom:var(--space-3)">
195- <label for="ancien">{{ shell.tr.current_password }}</label>
196- <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px">
197- </div>
198- <div class="field" style="margin-bottom:var(--space-3)">
199- <label for="nouveau">{{ shell.tr.new_password }}</label>
200- <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
201- </div>
202- <div class="field" style="margin-bottom:var(--space-4)">
203- <label for="confirmation">{{ shell.tr.confirmation }}</label>
204- <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px">
205- </div>
206- {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
207- <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.change_password }}</button>
208-</form>
209-
210 <form method="post" action="/logout">200 <form method="post" action="/logout">
211 <input type="hidden" name="csrf" value="{{ csrf }}">201 <input type="hidden" name="csrf" value="{{ csrf }}">
212 <button type="submit" class="btn btn-block"202 <button type="submit" class="btn btn-block"