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.
83a5aa4 parent: 8ea453c modified
src/routes/mod.rs +15 -1 | @@ -1,5 +1,6 @@ | ||
| 1 | 1 | mod entries; |
| 2 | 2 | pub(crate) mod login; |
| 3 | +mod security; | |
| 3 | 4 | mod settings; |
| 4 | 5 | mod signup; |
| 5 | 6 | mod statics; |
| @@ -50,9 +51,22 @@ pub fn router(state: AppState) -> Router { | ||
| 50 | 51 | "/parametres/categories/{id}/archiver", |
| 51 | 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 | 67 | .route( |
| 54 | 68 | "/parametres/mot-de-passe", |
| 55 | - post(settings::password_change), | |
| 69 | + post(security::password_change), | |
| 56 | 70 | ) |
| 57 | 71 | .route("/parametres/langue", post(settings::lang_change)) |
| 58 | 72 | .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, ¤t) { | |
| 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, ¤t) { | |
| 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, ¤t) { | ||
| 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, ¤t) { | ||
| 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; | ||
| 7 | 7 | use serde::Deserialize; |
| 8 | 8 | use uuid::Uuid; |
| 9 | 9 | |
| 10 | -use crate::auth::password; | |
| 11 | 10 | use crate::charts::color_map; |
| 12 | 11 | use crate::db::{invitations, categories, teams, users}; |
| 13 | 12 | use crate::error::AppError; |
| @@ -36,8 +35,6 @@ pub struct SentView { | ||
| 36 | 35 | pub struct SettingsPage { |
| 37 | 36 | pub shell: Shell, |
| 38 | 37 | pub csrf: String, |
| 39 | - /// Erreur inline du bloc mot de passe. | |
| 40 | - pub error: Option<&'static str>, | |
| 41 | 38 | pub invites: Vec<InviteView>, |
| 42 | 39 | pub account_line: String, |
| 43 | 40 | pub members: Vec<String>, |
| @@ -93,7 +90,6 @@ pub async fn settings_page( | ||
| 93 | 90 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 94 | 91 | let tr = ctx.tr(); |
| 95 | 92 | let toast = match q.ok.as_deref() { |
| 96 | - Some("mdp") => Some(tr.toast_password_updated.to_string()), | |
| 97 | 93 | Some("invite") => Some(tr.toast_invite_sent.to_string()), |
| 98 | 94 | Some("rejoint") => Some(p1(tr.toast_joined_pat, &ctx.team_name)), |
| 99 | 95 | Some("refus") => Some(tr.toast_invite_refused.to_string()), |
| @@ -111,12 +107,6 @@ pub async fn settings_page( | ||
| 111 | 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 | 110 | let nom = q.nom.as_deref().unwrap_or(""); |
| 121 | 111 | let invite_error = match q.err.as_deref() { |
| 122 | 112 | Some("inconnu") => Some(p1(tr.invite_unknown_pat, nom)), |
| @@ -205,7 +195,6 @@ pub async fn settings_page( | ||
| 205 | 195 | Ok(HtmlTemplate(SettingsPage { |
| 206 | 196 | csrf: ctx.user.csrf_token(&state), |
| 207 | 197 | shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, |
| 208 | - error, | |
| 209 | 198 | langs, |
| 210 | 199 | themes, |
| 211 | 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, ¤t) { | |
| 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, ¤t) { | ||
| 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 @@ | ||
| 40 | 40 | {% call c::chevron_right(17) %}{% endcall %} |
| 41 | 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 | 44 | <span style="flex:1"> |
| 45 | 45 | <span style="display:block;font-family:var(--font-heading);font-size:17px">{{ shell.tr.title_categories }}</span> |
| 46 | 46 | <span style="display:block;font-size:13px;opacity:.7">{{ shell.tr.manage_categories_desc }}</span> |
| @@ -48,6 +48,14 @@ | ||
| 48 | 48 | {% call c::chevron_right(17) %}{% endcall %} |
| 49 | 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 | 59 | <form method="post" action="/equipes/renommer"> |
| 52 | 60 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 53 | 61 | <div class="field" style="margin-bottom:var(--space-2)"> |
| @@ -189,24 +197,6 @@ | ||
| 189 | 197 | </div> |
| 190 | 198 | |
| 191 | 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 | 200 | <form method="post" action="/logout"> |
| 211 | 201 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 212 | 202 | <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" |