diff --git a/client b/client index 7c491c38..ebd62ba6 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 7c491c38cab7c363396efa6fcbab0d2c14b1d068 +Subproject commit ebd62ba605252d4f129e2e48d3ed22ea3904b618 diff --git a/client-server-drift.md b/client-server-drift.md new file mode 100644 index 00000000..9b82880a --- /dev/null +++ b/client-server-drift.md @@ -0,0 +1,63 @@ +# Client ↔ server drift — auth, rbac, forms + +Window: **2026-07-21 → 2026-08-11** (last 3 weeks). +Scope: TUI client (`client/`) vs server (`server/`) against the shared API (`common/proto`). Admin/backup/superadmin tooling is excluded. + +## Auth + +**1. One-time password bootstrap is missing in the client.** +The server added passwordless bootstrap accounts — a fresh DB ships `superadmin`/`admin` with no password — and the `SetInitialPassword` RPC (`common/proto/auth.proto`, commits 83899c0, 4d6c189, a5c8e08). The client has zero references to it: on a fresh install you cannot claim the admin account from the TUI client (web or a manual call is required). + +**2. Self-registration is aligned (verified, no drift).** +The server ignores any requested role and always creates `guest` (`server/src/auth/handlers/register.rs`); the client sends no role (`client/src/services/auth.rs`) and shows the returned role. + +**3. JWT slimming is a non-issue for the client (verified).** +The server stripped authorization claims from the token (server 70a2062); the client never decodes the JWT, it reads `LoginResponse` fields (username, role, timezone, phone_country, authorization) which still exist. + +## RBAC + +**4. RBAC is intentionally server-only (verified, no drift).** +The server owns the full grant model and enforces it on every data-plane call: `struct:` manage, `data:/`, `data:/*`, `data:*`, `journal:`, `journal:*` with read/insert/update/delete (`server/src/auth/rbac/objects.rs`, `guard.rs`; commits a5c8e08, e66b025). The client does not download permission snapshots or hide individual controls based on grants. It uses the structural `admin`/`superadmin` distinction only to select the administration workspace versus the data-entry workspace; this is presentation routing, not authorization. Server rejections are reported in an error dialog. + +**5. Role / grant / user administration is intentionally not a client feature (verified, no drift).** +The server implements `ListRoles`, `AddRole`, `RemoveRole`, `GrantPermission`, `RevokePermission`, `ListRolePermissions`, `ListGrantableObjects`, `AssignUserRole`, `ListUsers` (`common/proto/auth.proto`, `server/src/auth/grpc.rs`). These server-side administration operations are outside the TUI client's scope. + +## Forms page (table definition + data entry) + +**6. `_id` is free on the server, still reserved in the client.** +Commits 5be1a5c "_id is free" and 8881041/58b7d0b (alias is surface, physical name internal — user columns are stored under ordinals, the API exposes aliases). The server's system columns are exactly `id`, `deleted`, `row_revision`, `created_at` (+ `account` on accounting tables) — `common/src/system_column.rs`. The client still: + +- rejects column names ending `_id` in the add-table form (`client/src/pages/add_table/state.rs:449`); +- treats every `*_id` name as a system column (`client/src/utils/columns.rs:5`) — used to hide columns in pickers (`picker/object.rs:543,656`) and UI service (`ui_service.rs:377,441`); +- filters `*_id` columns out of the form and converts them into link fields by name-suffix matching (`ui_service.rs:104,127–129`). + +So legal columns are blocked at creation, hidden in forms, or misrendered as links. + +**7. Link detection is by name, not by type.** +Links are now a declarable `LINK(table)` column type with arbitrary names and multiple links to the same target allowed (751bfd9, 0d78556). The client decides `is_link` purely from the `_id` suffix: a LINK column named `customer` renders as a plain BIGINT text field with no picker, and a non-link column named e.g. `invoice_id` is hidden or misdetected as a link. (Child-reference resolution does use the new `Dependency.column_name` — `ui_service.rs:204–222` — that part is synced.) + +**8. The add-table form cannot create LINK or parameterized DECIMAL columns.** +The Relations pane lists available tables but selection is display-only (`add_table/data.rs:376`, `ui.rs:311`) — nothing is added to the request. The column-type input only accepts bare spellings (`supports_column_type` requires `ColumnTypeSpelling::Bare`), and a test asserts `decimal(10,2)` is rejected (`add_table/state.rs:589`). The server's `ListColumnTypes` advertises the Decimal and Link spellings. The two argument-taking types the server supports are uncreatable from the client. + +**9. `required` flag is not exposed in the add-table UI.** +Commit 29ccd8d added `ColumnDefinition.required` end-to-end (definition → validation → insert/update enforcement: `post_table_definition.rs:372`, `table_validation/runtime.rs:75`). The client always sends `required: false` (`add_table/logic.rs:33`) with no UI to mark a column required. The data-entry form does honor `required` returned by `GetTableValidation` — that path is synced. + +**10. `account` field is bare.** +The server exposes physical `account_id` as API column `account` — TEXT, slash-delimited, required on ACCOUNTING tables, and sending `account_id` directly is rejected (`server/src/tables_data/account_binding.rs`, `table_structure/query.rs:258`). The client removed its old `account_id → accounts` special case (client 827e6a4) and added no `account` handling: the form shows it as a plain TEXT field with no account picker or format validation. The ledger display part was fixed (`ledger.rs` renders `line.account`). + +**11. No alias rename UI.** +`rename_column_alias` is defined in the client's gRPC client (`grpc_client.rs:441`) but never called; the server's renameable-alias feature (8881041) is unreachable from the client. + +**12. `CreateInvoiceTemplateTable` not used.** +The server added table bundles generated from Typst invoice templates (a5c8e08); the client ships the `typst-template` feature but has no call or screen for it. + +## Verified synced (no drift) + +- register (no role, always `guest`), login response fields +- server-only RBAC enforcement; structural roles select the client workspace, while grants are not interpreted by the client +- mask + `storage_mode` (raw/formatted) in validation (`forms/validation.rs:261–272`) +- `row_display_values` / `row_display_columns` (repeated) in forms, pickers, link display (`forms/logic.rs:46`, `link_display.rs:32`) +- `Dependency.column_name` for child references +- `row_revision` optimistic concurrency; `journal_id` / `recomputed_rows` removal left no client residue +- per-column currency, `accounting_currency`, `quantity_ledger` in add-table +- `ListColumnTypes` is fetched and drives the column-type picker diff --git a/common/proto/auth.proto b/common/proto/auth.proto index 55283319..9ee65430 100644 --- a/common/proto/auth.proto +++ b/common/proto/auth.proto @@ -7,8 +7,8 @@ import "common.proto"; service AuthService { rpc Register(RegisterRequest) returns (AuthResponse); rpc Login(LoginRequest) returns (LoginResponse); - // Claims a bootstrap account that has never had a password set. - rpc SetInitialPassword(SetInitialPasswordRequest) returns (AuthResponse); + // Changes the authenticated user's password after verifying the current one. + rpc ChangePassword(ChangePasswordRequest) returns (PasswordOperationResponse); rpc GetAuthorization(GetAuthorizationRequest) returns (AuthorizationSnapshot); rpc SetTimezone(SetTimezoneRequest) returns (UserPreferences); @@ -26,6 +26,8 @@ service AuthService { // User administration. rpc AssignUserRole(AssignUserRoleRequest) returns (UserSummary); + // Resets a lower-ranked user's password. + rpc ResetUserPassword(ResetUserPasswordRequest) returns (PasswordOperationResponse); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); } @@ -45,12 +47,14 @@ message AuthResponse { string role = 4; // Always 'guest' for a self-registration } -message SetInitialPasswordRequest { - string username = 1; - string password = 2; - string password_confirmation = 3; +message ChangePasswordRequest { + string current_password = 1; + string new_password = 2; + string new_password_confirmation = 3; } +message PasswordOperationResponse {} + message LoginRequest { string identifier = 1; // Can be username or email string password = 2; @@ -175,6 +179,12 @@ message AssignUserRoleRequest { string role = 2; } +message ResetUserPasswordRequest { + string username = 1; + string new_password = 2; + string new_password_confirmation = 3; +} + message UserSummary { string id = 1; string username = 2; diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index dad784ea..41529625 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.auth.rs b/common/src/proto/komp_ac.auth.rs index fcec578c..bdc6dd63 100644 --- a/common/src/proto/komp_ac.auth.rs +++ b/common/src/proto/komp_ac.auth.rs @@ -32,14 +32,16 @@ pub struct AuthResponse { pub role: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SetInitialPasswordRequest { +pub struct ChangePasswordRequest { #[prost(string, tag = "1")] - pub username: ::prost::alloc::string::String, + pub current_password: ::prost::alloc::string::String, #[prost(string, tag = "2")] - pub password: ::prost::alloc::string::String, + pub new_password: ::prost::alloc::string::String, #[prost(string, tag = "3")] - pub password_confirmation: ::prost::alloc::string::String, + pub new_password_confirmation: ::prost::alloc::string::String, } +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PasswordOperationResponse {} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoginRequest { /// Can be username or email @@ -216,6 +218,15 @@ pub struct AssignUserRoleRequest { pub role: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ResetUserPasswordRequest { + #[prost(string, tag = "1")] + pub username: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub new_password: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub new_password_confirmation: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct UserSummary { #[prost(string, tag = "1")] pub id: ::prost::alloc::string::String, @@ -366,11 +377,14 @@ pub mod auth_service_client { .insert(GrpcMethod::new("komp_ac.auth.AuthService", "Login")); self.inner.unary(req, path, codec).await } - /// Claims a bootstrap account that has never had a password set. - pub async fn set_initial_password( + /// Changes the authenticated user's password after verifying the current one. + pub async fn change_password( &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> { + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { self.inner .ready() .await @@ -381,13 +395,11 @@ pub mod auth_service_client { })?; let codec = tonic_prost::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( - "/komp_ac.auth.AuthService/SetInitialPassword", + "/komp_ac.auth.AuthService/ChangePassword", ); let mut req = request.into_request(); req.extensions_mut() - .insert( - GrpcMethod::new("komp_ac.auth.AuthService", "SetInitialPassword"), - ); + .insert(GrpcMethod::new("komp_ac.auth.AuthService", "ChangePassword")); self.inner.unary(req, path, codec).await } pub async fn get_authorization( @@ -629,6 +641,33 @@ pub mod auth_service_client { .insert(GrpcMethod::new("komp_ac.auth.AuthService", "AssignUserRole")); self.inner.unary(req, path, codec).await } + /// Resets a lower-ranked user's password. + pub async fn reset_user_password( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.auth.AuthService/ResetUserPassword", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("komp_ac.auth.AuthService", "ResetUserPassword"), + ); + self.inner.unary(req, path, codec).await + } pub async fn list_users( &mut self, request: impl tonic::IntoRequest, @@ -676,11 +715,14 @@ pub mod auth_service_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; - /// Claims a bootstrap account that has never had a password set. - async fn set_initial_password( + /// Changes the authenticated user's password after verifying the current one. + async fn change_password( &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn get_authorization( &self, request: tonic::Request, @@ -734,6 +776,14 @@ pub mod auth_service_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + /// Resets a lower-ranked user's password. + async fn reset_user_password( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn list_users( &self, request: tonic::Request, @@ -906,26 +956,25 @@ pub mod auth_service_server { }; Box::pin(fut) } - "/komp_ac.auth.AuthService/SetInitialPassword" => { + "/komp_ac.auth.AuthService/ChangePassword" => { #[allow(non_camel_case_types)] - struct SetInitialPasswordSvc(pub Arc); + struct ChangePasswordSvc(pub Arc); impl< T: AuthService, - > tonic::server::UnaryService - for SetInitialPasswordSvc { - type Response = super::AuthResponse; + > tonic::server::UnaryService + for ChangePasswordSvc { + type Response = super::PasswordOperationResponse; type Future = BoxFuture< tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::set_initial_password(&inner, request) - .await + ::change_password(&inner, request).await }; Box::pin(fut) } @@ -936,7 +985,7 @@ pub mod auth_service_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = SetInitialPasswordSvc(inner); + let method = ChangePasswordSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -1404,6 +1453,52 @@ pub mod auth_service_server { }; Box::pin(fut) } + "/komp_ac.auth.AuthService/ResetUserPassword" => { + #[allow(non_camel_case_types)] + struct ResetUserPasswordSvc(pub Arc); + impl< + T: AuthService, + > tonic::server::UnaryService + for ResetUserPasswordSvc { + type Response = super::PasswordOperationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::reset_user_password(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ResetUserPasswordSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/komp_ac.auth.AuthService/ListUsers" => { #[allow(non_camel_case_types)] struct ListUsersSvc(pub Arc); diff --git a/server b/server index e0e60382..3bf72f51 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit e0e6038205b855aa03e38f5911da160dd4275f6a +Subproject commit 3bf72f51a61614837c1a1703afca42e334c78511 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 60e7ee85..6224d989 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -25,8 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). its grant matrix for every editable data role. Creating a table redirects to that table in the definition workspace so its initial grants can be assigned immediately. -- **Bootstrap administrator claim** — `/initial-password` consumes - `SetInitialPassword` for the one-time `admin` and `superadmin` setup flow. +- **Password management** — `/password` consumes `ChangePassword` for the + signed-in user, while the user table consumes `ResetUserPassword` for + administrator resets allowed by the backend role hierarchy. - **`TableDefinition.ListColumnTypes`** — called by the add-table and table-definition loaders. The whole response is consumed: `name`, `group`, `declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`, diff --git a/web/src/lib.rs b/web/src/lib.rs index 1d531423..d6554185 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -247,12 +247,9 @@ mod tests { } #[tokio::test] - async fn fresh_installation_can_open_the_initial_password_form() { - let (status, body) = get("/initial-password").await; - assert!(status.is_success()); - assert!(body.contains("Claim bootstrap administrator")); - assert!(body.contains("value=\"admin\"")); - assert!(body.contains("value=\"superadmin\"")); + async fn password_page_requires_a_session() { + let (status, _) = get("/password").await; + assert_eq!(status, axum::http::StatusCode::SEE_OTHER); } /// The register form carries every user-provided `RegisterRequest` field. diff --git a/web/src/pages/admin/permissions/logic.rs b/web/src/pages/admin/permissions/logic.rs index f372a42e..52f48262 100644 --- a/web/src/pages/admin/permissions/logic.rs +++ b/web/src/pages/admin/permissions/logic.rs @@ -9,14 +9,17 @@ use crate::{ AppState, auth::{ AddRoleRequest, AssignUserRoleRequest, GrantPermissionRequest, RemoveRoleRequest, - RevokePermissionRequest, + ResetUserPasswordRequest, RevokePermissionRequest, }, services::{authenticated_request, reject_cross_site}, }; use super::{ loader, - state::{AddRoleForm, AssignRoleForm, LoadError, PermissionForm, RoleForm, Selection}, + state::{ + AddRoleForm, AssignRoleForm, LoadError, PermissionForm, ResetPasswordForm, RoleForm, + Selection, + }, ui, }; @@ -123,6 +126,29 @@ pub(crate) async fn assign_user_role( }).await } +pub(crate) async fn reset_user_password( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let destination = "/admin/permissions?updated=true"; + let request_headers = headers.clone(); + mutate(&headers, destination, async move { + let mut auth = state.auth; + auth.reset_user_password(authenticated_request( + &request_headers, + ResetUserPasswordRequest { + username: form.username, + new_password: form.new_password, + new_password_confirmation: form.new_password_confirmation, + }, + ).map_err(|_| "Missing session".to_string())?) + .await + .map_err(|error| error.message().to_string())?; + Ok(()) + }).await +} + async fn mutate(headers: &HeaderMap, destination: &str, operation: F) -> Response where F: std::future::Future>, diff --git a/web/src/pages/admin/permissions/mod.rs b/web/src/pages/admin/permissions/mod.rs index db9f96c4..7202447e 100644 --- a/web/src/pages/admin/permissions/mod.rs +++ b/web/src/pages/admin/permissions/mod.rs @@ -15,4 +15,5 @@ pub(crate) fn router() -> Router { .route("/admin/permissions/grant", post(logic::grant)) .route("/admin/permissions/revoke", post(logic::revoke)) .route("/admin/permissions/users/role", post(logic::assign_user_role)) + .route("/admin/permissions/users/password", post(logic::reset_user_password)) } diff --git a/web/src/pages/admin/permissions/state.rs b/web/src/pages/admin/permissions/state.rs index fd95acb8..5364f902 100644 --- a/web/src/pages/admin/permissions/state.rs +++ b/web/src/pages/admin/permissions/state.rs @@ -33,6 +33,13 @@ pub(crate) struct AssignRoleForm { pub role: String, } +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct ResetPasswordForm { + pub username: String, + pub new_password: String, + pub new_password_confirmation: String, +} + pub(crate) struct PermissionPageState { pub nav: crate::ui::Nav, pub roles: Vec, @@ -78,6 +85,14 @@ impl PermissionPageState { .find(|role| role.name == self.selected_role) .is_some_and(|role| !role.built_in && role.kind == "data") } + + pub(crate) fn can_reset_password(&self, user: &UserSummary) -> bool { + match self.nav.role.as_str() { + "superadmin" => user.role != "superadmin", + "admin" => !matches!(user.role.as_str(), "superadmin" | "admin"), + _ => false, + } + } } pub(crate) enum LoadError { diff --git a/web/src/pages/login/logic.rs b/web/src/pages/login/logic.rs index 5a548e46..6ee29d4f 100644 --- a/web/src/pages/login/logic.rs +++ b/web/src/pages/login/logic.rs @@ -1,68 +1,55 @@ use axum::{ Form, - extract::{Query, State}, + extract::State, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, }; use tonic::Request; -use crate::{AppState, auth::{LoginRequest, SetInitialPasswordRequest}, services::reject_cross_site, ui::Nav}; +use crate::{ + AppState, + auth::{ChangePasswordRequest, LoginRequest}, + services::{authenticated_request, reject_cross_site}, + ui::Nav, +}; -use super::{state::{InitialPasswordInput, LoginInput, LoginQuery}, ui}; +use super::{state::{ChangePasswordInput, LoginInput}, ui}; pub(crate) async fn login_page( headers: HeaderMap, - Query(query): Query, ) -> Html { - Html(ui::render_page( - Nav::new(&headers, "login"), - query.initial_password_set, - )) + Html(ui::render_page(Nav::new(&headers, "login"))) } -pub(crate) async fn initial_password_page(headers: HeaderMap) -> Html { - Html(ui::render_initial_password_page(Nav::new(&headers, "login"))) +pub(crate) async fn password_page(headers: HeaderMap) -> Response { + if authenticated_request(&headers, ()).is_err() { + return axum::response::Redirect::to("/login").into_response(); + } + Html(ui::render_password_page(Nav::new(&headers, ""))).into_response() } -pub(crate) async fn set_initial_password( +pub(crate) async fn change_password( State(state): State, headers: HeaderMap, - Form(input): Form, + Form(input): Form, ) -> Response { if let Some(rejection) = reject_cross_site(&headers) { return rejection; } - let username = input.username.trim(); - if !matches!(username, "admin" | "superadmin") { - return error( - StatusCode::UNPROCESSABLE_ENTITY, - "Only the bootstrap admin or superadmin account can be claimed here.", - ); - } + let request = match authenticated_request(&headers, ChangePasswordRequest { + current_password: input.current_password, + new_password: input.new_password, + new_password_confirmation: input.new_password_confirmation, + }) { + Ok(request) => request, + Err(_) => return axum::response::Redirect::to("/login").into_response(), + }; let mut auth = state.auth; - match auth - .set_initial_password(tonic::Request::new(SetInitialPasswordRequest { - username: username.to_string(), - password: input.password, - password_confirmation: input.password_confirmation, - })) - .await - { - Ok(_) => { - let mut response = StatusCode::SEE_OTHER.into_response(); - response.headers_mut().insert( - header::LOCATION, - HeaderValue::from_static("/login?initial_password_set=1"), - ); - response.headers_mut().insert( - "hx-redirect", - HeaderValue::from_static("/login?initial_password_set=1"), - ); - response - } + match auth.change_password(request).await { + Ok(_) => Html(ui::render_password_success()).into_response(), Err(status) => ( StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_initial_password_error(status.message())), + Html(ui::render_password_error(status.message())), ) .into_response(), } diff --git a/web/src/pages/login/mod.rs b/web/src/pages/login/mod.rs index a74f7487..4e669b7c 100644 --- a/web/src/pages/login/mod.rs +++ b/web/src/pages/login/mod.rs @@ -14,8 +14,5 @@ pub(crate) mod ui; pub(crate) fn router() -> Router { Router::new() .route("/login", get(logic::login_page).post(logic::login)) - .route( - "/initial-password", - get(logic::initial_password_page).post(logic::set_initial_password), - ) + .route("/password", get(logic::password_page).post(logic::change_password)) } diff --git a/web/src/pages/login/state.rs b/web/src/pages/login/state.rs index e14917cd..43e6f463 100644 --- a/web/src/pages/login/state.rs +++ b/web/src/pages/login/state.rs @@ -5,15 +5,10 @@ pub(crate) struct LoginInput { pub password: String, } -#[derive(Default, serde::Deserialize)] -pub(crate) struct LoginQuery { - #[serde(default)] - pub initial_password_set: bool, -} - #[derive(serde::Deserialize)] -pub(crate) struct InitialPasswordInput { - pub username: String, - pub password: String, - pub password_confirmation: String, +pub(crate) struct ChangePasswordInput { + #[serde(default)] + pub current_password: String, + pub new_password: String, + pub new_password_confirmation: String, } diff --git a/web/src/pages/login/ui.rs b/web/src/pages/login/ui.rs index 05563845..c813ad88 100644 --- a/web/src/pages/login/ui.rs +++ b/web/src/pages/login/ui.rs @@ -7,17 +7,10 @@ use crate::ui::{Alert, Nav, render}; #[template(path = "pages/login/login.html")] struct LoginPage { nav: Nav, - initial_password_set: bool, } -pub(crate) fn render_page( - nav: Nav, - initial_password_set: bool, -) -> String { - render(&LoginPage { - nav, - initial_password_set, - }) +pub(crate) fn render_page(nav: Nav) -> String { + render(&LoginPage { nav }) } /// POST /login — the #login-status swap when the credentials are rejected. @@ -26,15 +19,19 @@ pub(crate) fn render_error(message: &str) -> String { } #[derive(Template)] -#[template(path = "pages/login/initial_password.html")] -struct InitialPasswordPage { +#[template(path = "pages/login/password.html")] +struct PasswordPage { nav: Nav, } -pub(crate) fn render_initial_password_page(nav: Nav) -> String { - render(&InitialPasswordPage { nav }) +pub(crate) fn render_password_page(nav: Nav) -> String { + render(&PasswordPage { nav }) } -pub(crate) fn render_initial_password_error(message: &str) -> String { - render(&Alert::error("Could not claim the bootstrap account", message)) +pub(crate) fn render_password_error(message: &str) -> String { + render(&Alert::error("Could not change password", message)) +} + +pub(crate) fn render_password_success() -> String { + render(&Alert::success("Password changed", "Your new password is active.")) } diff --git a/web/templates/pages/admin/permissions/permissions.html b/web/templates/pages/admin/permissions/permissions.html index a0e46940..3dcd9b7b 100644 --- a/web/templates/pages/admin/permissions/permissions.html +++ b/web/templates/pages/admin/permissions/permissions.html @@ -13,7 +13,7 @@ - {% if page.updated %}

Authorization updated. The current session remains valid and all subsequent requests use the new policy.

{% endif %} + {% if page.updated %}

Administration updated. The current session remains valid and all subsequent requests use the new state.

{% endif %} {% if page.can_manage_roles %}
@@ -84,7 +84,7 @@

Users

- + {% for user in page.users %} + {% endfor %}
UsernameEmailCurrent roleAssign role
UsernameEmailCurrent roleAssign roleReset password
{{ user.username }}{{ user.email }}{{ user.role }}
@@ -92,6 +92,12 @@
{% if page.can_reset_password(user) %}
+ + + + +
{% endif %}
diff --git a/web/templates/pages/login/initial_password.html b/web/templates/pages/login/initial_password.html deleted file mode 100644 index 00b9a65f..00000000 --- a/web/templates/pages/login/initial_password.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "ui/base.html" %} - -{% block title %}Claim administrator{% endblock %} - -{% block content %} -
- -
-{% endblock %} diff --git a/web/templates/pages/login/login.html b/web/templates/pages/login/login.html index 6501b4b1..beffd383 100644 --- a/web/templates/pages/login/login.html +++ b/web/templates/pages/login/login.html @@ -8,12 +8,10 @@

Sign in

- {% if initial_password_set %}

The initial password was set. You can sign in now.

{% endif %} -
diff --git a/web/templates/pages/login/password.html b/web/templates/pages/login/password.html new file mode 100644 index 00000000..b06a5f26 --- /dev/null +++ b/web/templates/pages/login/password.html @@ -0,0 +1,17 @@ +{% extends "ui/base.html" %} + +{% block title %}Change password{% endblock %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index b2e1768d..6422c8e5 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -27,6 +27,7 @@ {% if nav.can_import %}
  • Import
  • {% endif %} {% if nav.can_export %}
  • Export
  • {% endif %} {% if nav.authenticated %} +
  • Password
  • {% else %}
  • Login
  • @@ -49,6 +50,7 @@ {% if nav.can_import %}
  • Import
  • {% endif %} {% if nav.can_export %}
  • Export
  • {% endif %} {% if nav.authenticated %} +
  • Password
  • {% else %}
  • Login