pws reset

This commit is contained in:
Priec
2026-08-11 08:40:49 +02:00
parent 8ddabf78f6
commit 71106a04cb
20 changed files with 323 additions and 136 deletions

2
client

Submodule client updated: 7c491c38ca...ebd62ba605

63
client-server-drift.md Normal file
View File

@@ -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:<area>` manage, `data:<profile>/<table>`, `data:<profile>/*`, `data:*`, `journal:<profile>`, `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,127129`).
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:204222` — 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:261272`)
- `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

View File

@@ -7,8 +7,8 @@ import "common.proto";
service AuthService { service AuthService {
rpc Register(RegisterRequest) returns (AuthResponse); rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (LoginResponse); rpc Login(LoginRequest) returns (LoginResponse);
// Claims a bootstrap account that has never had a password set. // Changes the authenticated user's password after verifying the current one.
rpc SetInitialPassword(SetInitialPasswordRequest) returns (AuthResponse); rpc ChangePassword(ChangePasswordRequest) returns (PasswordOperationResponse);
rpc GetAuthorization(GetAuthorizationRequest) returns (AuthorizationSnapshot); rpc GetAuthorization(GetAuthorizationRequest) returns (AuthorizationSnapshot);
rpc SetTimezone(SetTimezoneRequest) returns (UserPreferences); rpc SetTimezone(SetTimezoneRequest) returns (UserPreferences);
@@ -26,6 +26,8 @@ service AuthService {
// User administration. // User administration.
rpc AssignUserRole(AssignUserRoleRequest) returns (UserSummary); rpc AssignUserRole(AssignUserRoleRequest) returns (UserSummary);
// Resets a lower-ranked user's password.
rpc ResetUserPassword(ResetUserPasswordRequest) returns (PasswordOperationResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
} }
@@ -45,12 +47,14 @@ message AuthResponse {
string role = 4; // Always 'guest' for a self-registration string role = 4; // Always 'guest' for a self-registration
} }
message SetInitialPasswordRequest { message ChangePasswordRequest {
string username = 1; string current_password = 1;
string password = 2; string new_password = 2;
string password_confirmation = 3; string new_password_confirmation = 3;
} }
message PasswordOperationResponse {}
message LoginRequest { message LoginRequest {
string identifier = 1; // Can be username or email string identifier = 1; // Can be username or email
string password = 2; string password = 2;
@@ -175,6 +179,12 @@ message AssignUserRoleRequest {
string role = 2; string role = 2;
} }
message ResetUserPasswordRequest {
string username = 1;
string new_password = 2;
string new_password_confirmation = 3;
}
message UserSummary { message UserSummary {
string id = 1; string id = 1;
string username = 2; string username = 2;

Binary file not shown.

View File

@@ -32,14 +32,16 @@ pub struct AuthResponse {
pub role: ::prost::alloc::string::String, pub role: ::prost::alloc::string::String,
} }
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SetInitialPasswordRequest { pub struct ChangePasswordRequest {
#[prost(string, tag = "1")] #[prost(string, tag = "1")]
pub username: ::prost::alloc::string::String, pub current_password: ::prost::alloc::string::String,
#[prost(string, tag = "2")] #[prost(string, tag = "2")]
pub password: ::prost::alloc::string::String, pub new_password: ::prost::alloc::string::String,
#[prost(string, tag = "3")] #[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)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct LoginRequest { pub struct LoginRequest {
/// Can be username or email /// Can be username or email
@@ -216,6 +218,15 @@ pub struct AssignUserRoleRequest {
pub role: ::prost::alloc::string::String, pub role: ::prost::alloc::string::String,
} }
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] #[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 { pub struct UserSummary {
#[prost(string, tag = "1")] #[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String, pub id: ::prost::alloc::string::String,
@@ -366,11 +377,14 @@ pub mod auth_service_client {
.insert(GrpcMethod::new("komp_ac.auth.AuthService", "Login")); .insert(GrpcMethod::new("komp_ac.auth.AuthService", "Login"));
self.inner.unary(req, path, codec).await self.inner.unary(req, path, codec).await
} }
/// Claims a bootstrap account that has never had a password set. /// Changes the authenticated user's password after verifying the current one.
pub async fn set_initial_password( pub async fn change_password(
&mut self, &mut self,
request: impl tonic::IntoRequest<super::SetInitialPasswordRequest>, request: impl tonic::IntoRequest<super::ChangePasswordRequest>,
) -> std::result::Result<tonic::Response<super::AuthResponse>, tonic::Status> { ) -> std::result::Result<
tonic::Response<super::PasswordOperationResponse>,
tonic::Status,
> {
self.inner self.inner
.ready() .ready()
.await .await
@@ -381,13 +395,11 @@ pub mod auth_service_client {
})?; })?;
let codec = tonic_prost::ProstCodec::default(); let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static( let path = http::uri::PathAndQuery::from_static(
"/komp_ac.auth.AuthService/SetInitialPassword", "/komp_ac.auth.AuthService/ChangePassword",
); );
let mut req = request.into_request(); let mut req = request.into_request();
req.extensions_mut() req.extensions_mut()
.insert( .insert(GrpcMethod::new("komp_ac.auth.AuthService", "ChangePassword"));
GrpcMethod::new("komp_ac.auth.AuthService", "SetInitialPassword"),
);
self.inner.unary(req, path, codec).await self.inner.unary(req, path, codec).await
} }
pub async fn get_authorization( pub async fn get_authorization(
@@ -629,6 +641,33 @@ pub mod auth_service_client {
.insert(GrpcMethod::new("komp_ac.auth.AuthService", "AssignUserRole")); .insert(GrpcMethod::new("komp_ac.auth.AuthService", "AssignUserRole"));
self.inner.unary(req, path, codec).await 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<super::ResetUserPasswordRequest>,
) -> std::result::Result<
tonic::Response<super::PasswordOperationResponse>,
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( pub async fn list_users(
&mut self, &mut self,
request: impl tonic::IntoRequest<super::ListUsersRequest>, request: impl tonic::IntoRequest<super::ListUsersRequest>,
@@ -676,11 +715,14 @@ pub mod auth_service_server {
&self, &self,
request: tonic::Request<super::LoginRequest>, request: tonic::Request<super::LoginRequest>,
) -> std::result::Result<tonic::Response<super::LoginResponse>, tonic::Status>; ) -> std::result::Result<tonic::Response<super::LoginResponse>, tonic::Status>;
/// Claims a bootstrap account that has never had a password set. /// Changes the authenticated user's password after verifying the current one.
async fn set_initial_password( async fn change_password(
&self, &self,
request: tonic::Request<super::SetInitialPasswordRequest>, request: tonic::Request<super::ChangePasswordRequest>,
) -> std::result::Result<tonic::Response<super::AuthResponse>, tonic::Status>; ) -> std::result::Result<
tonic::Response<super::PasswordOperationResponse>,
tonic::Status,
>;
async fn get_authorization( async fn get_authorization(
&self, &self,
request: tonic::Request<super::GetAuthorizationRequest>, request: tonic::Request<super::GetAuthorizationRequest>,
@@ -734,6 +776,14 @@ pub mod auth_service_server {
&self, &self,
request: tonic::Request<super::AssignUserRoleRequest>, request: tonic::Request<super::AssignUserRoleRequest>,
) -> std::result::Result<tonic::Response<super::UserSummary>, tonic::Status>; ) -> std::result::Result<tonic::Response<super::UserSummary>, tonic::Status>;
/// Resets a lower-ranked user's password.
async fn reset_user_password(
&self,
request: tonic::Request<super::ResetUserPasswordRequest>,
) -> std::result::Result<
tonic::Response<super::PasswordOperationResponse>,
tonic::Status,
>;
async fn list_users( async fn list_users(
&self, &self,
request: tonic::Request<super::ListUsersRequest>, request: tonic::Request<super::ListUsersRequest>,
@@ -906,26 +956,25 @@ pub mod auth_service_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/komp_ac.auth.AuthService/SetInitialPassword" => { "/komp_ac.auth.AuthService/ChangePassword" => {
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
struct SetInitialPasswordSvc<T: AuthService>(pub Arc<T>); struct ChangePasswordSvc<T: AuthService>(pub Arc<T>);
impl< impl<
T: AuthService, T: AuthService,
> tonic::server::UnaryService<super::SetInitialPasswordRequest> > tonic::server::UnaryService<super::ChangePasswordRequest>
for SetInitialPasswordSvc<T> { for ChangePasswordSvc<T> {
type Response = super::AuthResponse; type Response = super::PasswordOperationResponse;
type Future = BoxFuture< type Future = BoxFuture<
tonic::Response<Self::Response>, tonic::Response<Self::Response>,
tonic::Status, tonic::Status,
>; >;
fn call( fn call(
&mut self, &mut self,
request: tonic::Request<super::SetInitialPasswordRequest>, request: tonic::Request<super::ChangePasswordRequest>,
) -> Self::Future { ) -> Self::Future {
let inner = Arc::clone(&self.0); let inner = Arc::clone(&self.0);
let fut = async move { let fut = async move {
<T as AuthService>::set_initial_password(&inner, request) <T as AuthService>::change_password(&inner, request).await
.await
}; };
Box::pin(fut) Box::pin(fut)
} }
@@ -936,7 +985,7 @@ pub mod auth_service_server {
let max_encoding_message_size = self.max_encoding_message_size; let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone(); let inner = self.inner.clone();
let fut = async move { let fut = async move {
let method = SetInitialPasswordSvc(inner); let method = ChangePasswordSvc(inner);
let codec = tonic_prost::ProstCodec::default(); let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec) let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config( .apply_compression_config(
@@ -1404,6 +1453,52 @@ pub mod auth_service_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/komp_ac.auth.AuthService/ResetUserPassword" => {
#[allow(non_camel_case_types)]
struct ResetUserPasswordSvc<T: AuthService>(pub Arc<T>);
impl<
T: AuthService,
> tonic::server::UnaryService<super::ResetUserPasswordRequest>
for ResetUserPasswordSvc<T> {
type Response = super::PasswordOperationResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::ResetUserPasswordRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as AuthService>::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" => { "/komp_ac.auth.AuthService/ListUsers" => {
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
struct ListUsersSvc<T: AuthService>(pub Arc<T>); struct ListUsersSvc<T: AuthService>(pub Arc<T>);

2
server

Submodule server updated: e0e6038205...3bf72f51a6

View File

@@ -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 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 that table in the definition workspace so its initial grants can be assigned
immediately. immediately.
- **Bootstrap administrator claim** — `/initial-password` consumes - **Password management** — `/password` consumes `ChangePassword` for the
`SetInitialPassword` for the one-time `admin` and `superadmin` setup flow. 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 - **`TableDefinition.ListColumnTypes`** — called by the add-table and
table-definition loaders. The whole response is consumed: `name`, `group`, table-definition loaders. The whole response is consumed: `name`, `group`,
`declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`, `declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`,

View File

@@ -247,12 +247,9 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn fresh_installation_can_open_the_initial_password_form() { async fn password_page_requires_a_session() {
let (status, body) = get("/initial-password").await; let (status, _) = get("/password").await;
assert!(status.is_success()); assert_eq!(status, axum::http::StatusCode::SEE_OTHER);
assert!(body.contains("Claim bootstrap administrator"));
assert!(body.contains("value=\"admin\""));
assert!(body.contains("value=\"superadmin\""));
} }
/// The register form carries every user-provided `RegisterRequest` field. /// The register form carries every user-provided `RegisterRequest` field.

View File

@@ -9,14 +9,17 @@ use crate::{
AppState, AppState,
auth::{ auth::{
AddRoleRequest, AssignUserRoleRequest, GrantPermissionRequest, RemoveRoleRequest, AddRoleRequest, AssignUserRoleRequest, GrantPermissionRequest, RemoveRoleRequest,
RevokePermissionRequest, ResetUserPasswordRequest, RevokePermissionRequest,
}, },
services::{authenticated_request, reject_cross_site}, services::{authenticated_request, reject_cross_site},
}; };
use super::{ use super::{
loader, loader,
state::{AddRoleForm, AssignRoleForm, LoadError, PermissionForm, RoleForm, Selection}, state::{
AddRoleForm, AssignRoleForm, LoadError, PermissionForm, ResetPasswordForm, RoleForm,
Selection,
},
ui, ui,
}; };
@@ -123,6 +126,29 @@ pub(crate) async fn assign_user_role(
}).await }).await
} }
pub(crate) async fn reset_user_password(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ResetPasswordForm>,
) -> 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<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
where where
F: std::future::Future<Output = Result<(), String>>, F: std::future::Future<Output = Result<(), String>>,

View File

@@ -15,4 +15,5 @@ pub(crate) fn router() -> Router<AppState> {
.route("/admin/permissions/grant", post(logic::grant)) .route("/admin/permissions/grant", post(logic::grant))
.route("/admin/permissions/revoke", post(logic::revoke)) .route("/admin/permissions/revoke", post(logic::revoke))
.route("/admin/permissions/users/role", post(logic::assign_user_role)) .route("/admin/permissions/users/role", post(logic::assign_user_role))
.route("/admin/permissions/users/password", post(logic::reset_user_password))
} }

View File

@@ -33,6 +33,13 @@ pub(crate) struct AssignRoleForm {
pub role: String, 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(crate) struct PermissionPageState {
pub nav: crate::ui::Nav, pub nav: crate::ui::Nav,
pub roles: Vec<Role>, pub roles: Vec<Role>,
@@ -78,6 +85,14 @@ impl PermissionPageState {
.find(|role| role.name == self.selected_role) .find(|role| role.name == self.selected_role)
.is_some_and(|role| !role.built_in && role.kind == "data") .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 { pub(crate) enum LoadError {

View File

@@ -1,68 +1,55 @@
use axum::{ use axum::{
Form, Form,
extract::{Query, State}, extract::State,
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use tonic::Request; 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( pub(crate) async fn login_page(
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<LoginQuery>,
) -> Html<String> { ) -> Html<String> {
Html(ui::render_page( Html(ui::render_page(Nav::new(&headers, "login")))
Nav::new(&headers, "login"),
query.initial_password_set,
))
} }
pub(crate) async fn initial_password_page(headers: HeaderMap) -> Html<String> { pub(crate) async fn password_page(headers: HeaderMap) -> Response {
Html(ui::render_initial_password_page(Nav::new(&headers, "login"))) 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<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Form(input): Form<InitialPasswordInput>, Form(input): Form<ChangePasswordInput>,
) -> Response { ) -> Response {
if let Some(rejection) = reject_cross_site(&headers) { if let Some(rejection) = reject_cross_site(&headers) {
return rejection; return rejection;
} }
let username = input.username.trim(); let request = match authenticated_request(&headers, ChangePasswordRequest {
if !matches!(username, "admin" | "superadmin") { current_password: input.current_password,
return error( new_password: input.new_password,
StatusCode::UNPROCESSABLE_ENTITY, new_password_confirmation: input.new_password_confirmation,
"Only the bootstrap admin or superadmin account can be claimed here.", }) {
); Ok(request) => request,
} Err(_) => return axum::response::Redirect::to("/login").into_response(),
};
let mut auth = state.auth; let mut auth = state.auth;
match auth match auth.change_password(request).await {
.set_initial_password(tonic::Request::new(SetInitialPasswordRequest { Ok(_) => Html(ui::render_password_success()).into_response(),
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
}
Err(status) => ( Err(status) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_initial_password_error(status.message())), Html(ui::render_password_error(status.message())),
) )
.into_response(), .into_response(),
} }

View File

@@ -14,8 +14,5 @@ pub(crate) mod ui;
pub(crate) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/login", get(logic::login_page).post(logic::login)) .route("/login", get(logic::login_page).post(logic::login))
.route( .route("/password", get(logic::password_page).post(logic::change_password))
"/initial-password",
get(logic::initial_password_page).post(logic::set_initial_password),
)
} }

View File

@@ -5,15 +5,10 @@ pub(crate) struct LoginInput {
pub password: String, pub password: String,
} }
#[derive(Default, serde::Deserialize)]
pub(crate) struct LoginQuery {
#[serde(default)]
pub initial_password_set: bool,
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub(crate) struct InitialPasswordInput { pub(crate) struct ChangePasswordInput {
pub username: String, #[serde(default)]
pub password: String, pub current_password: String,
pub password_confirmation: String, pub new_password: String,
pub new_password_confirmation: String,
} }

View File

@@ -7,17 +7,10 @@ use crate::ui::{Alert, Nav, render};
#[template(path = "pages/login/login.html")] #[template(path = "pages/login/login.html")]
struct LoginPage { struct LoginPage {
nav: Nav, nav: Nav,
initial_password_set: bool,
} }
pub(crate) fn render_page( pub(crate) fn render_page(nav: Nav) -> String {
nav: Nav, render(&LoginPage { nav })
initial_password_set: bool,
) -> String {
render(&LoginPage {
nav,
initial_password_set,
})
} }
/// POST /login — the #login-status swap when the credentials are rejected. /// 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)] #[derive(Template)]
#[template(path = "pages/login/initial_password.html")] #[template(path = "pages/login/password.html")]
struct InitialPasswordPage { struct PasswordPage {
nav: Nav, nav: Nav,
} }
pub(crate) fn render_initial_password_page(nav: Nav) -> String { pub(crate) fn render_password_page(nav: Nav) -> String {
render(&InitialPasswordPage { nav }) render(&PasswordPage { nav })
} }
pub(crate) fn render_initial_password_error(message: &str) -> String { pub(crate) fn render_password_error(message: &str) -> String {
render(&Alert::error("Could not claim the bootstrap account", message)) 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."))
} }

View File

@@ -13,7 +13,7 @@
<div class="actions"><a href="/admin">← Admin panel</a></div> <div class="actions"><a href="/admin">← Admin panel</a></div>
</section> </section>
{% if page.updated %}<p class="notice">Authorization updated. The current session remains valid and all subsequent requests use the new policy.</p>{% endif %} {% if page.updated %}<p class="notice">Administration updated. The current session remains valid and all subsequent requests use the new state.</p>{% endif %}
{% if page.can_manage_roles %} {% if page.can_manage_roles %}
<section class="panel"> <section class="panel">
@@ -84,7 +84,7 @@
<section class="panel"> <section class="panel">
<h2>Users</h2> <h2>Users</h2>
<table class="builder-table"> <table class="builder-table">
<thead><tr><th>Username</th><th>Email</th><th>Current role</th><th>Assign role</th></tr></thead> <thead><tr><th>Username</th><th>Email</th><th>Current role</th><th>Assign role</th><th>Reset password</th></tr></thead>
<tbody>{% for user in page.users %}<tr> <tbody>{% for user in page.users %}<tr>
<td>{{ user.username }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td> <td>{{ user.username }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td>
<td><form hx-post="/admin/permissions/users/role" hx-target="#permission-status" class="actions"> <td><form hx-post="/admin/permissions/users/role" hx-target="#permission-status" class="actions">
@@ -92,6 +92,12 @@
<select name="role">{% for role in page.assignable_roles() %}<option value="{{ role.name }}" {% if user.role == role.name %}selected{% endif %}>{{ role.name }}</option>{% endfor %}</select> <select name="role">{% for role in page.assignable_roles() %}<option value="{{ role.name }}" {% if user.role == role.name %}selected{% endif %}>{{ role.name }}</option>{% endfor %}</select>
<button type="submit">Assign</button> <button type="submit">Assign</button>
</form></td> </form></td>
<td>{% if page.can_reset_password(user) %}<form hx-post="/admin/permissions/users/password" hx-target="#permission-status" class="actions">
<input type="hidden" name="username" value="{{ user.username }}">
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm password" required>
<button type="submit">Reset</button>
</form>{% endif %}</td>
</tr>{% endfor %}</tbody> </tr>{% endfor %}</tbody>
</table> </table>
</section> </section>

View File

@@ -1,20 +0,0 @@
{% extends "ui/base.html" %}
{% block title %}Claim administrator{% endblock %}
{% block content %}
<main class="login-main">
<form class="login-card" hx-post="/initial-password" hx-target="#initial-password-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate>
<h1>Claim bootstrap administrator</h1>
<p class="hint">This works once for a fresh passwordless <code>admin</code> or <code>superadmin</code> account.</p>
<label>Account
<select name="username"><option value="admin">admin</option><option value="superadmin">superadmin</option></select>
</label>
<label>Password<input name="password" type="password" autocomplete="new-password" required></label>
<label>Confirm password<input name="password_confirmation" type="password" autocomplete="new-password" required></label>
<button type="submit">Set initial password</button>
<p class="login-alt"><a href="/login">Back to sign in</a></p>
<div id="initial-password-status" aria-live="polite"></div>
</form>
</main>
{% endblock %}

View File

@@ -8,12 +8,10 @@
<form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" <form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML"
hx-disabled-elt="button" novalidate> hx-disabled-elt="button" novalidate>
<h1>Sign in</h1> <h1>Sign in</h1>
{% if initial_password_set %}<p class="notice">The initial password was set. You can sign in now.</p>{% endif %}
<label>Username or email<input name="identifier" autocomplete="username"></label> <label>Username or email<input name="identifier" autocomplete="username"></label>
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label> <label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Login</button> <button type="submit">Login</button>
<p class="login-alt">No account yet? <a href="/register">Register</a></p> <p class="login-alt">No account yet? <a href="/register">Register</a></p>
<p class="login-alt">Fresh installation? <a href="/initial-password">Claim a bootstrap administrator</a></p>
<div id="login-status" aria-live="polite"></div> <div id="login-status" aria-live="polite"></div>
</form> </form>
</main> </main>

View File

@@ -0,0 +1,17 @@
{% extends "ui/base.html" %}
{% block title %}Change password{% endblock %}
{% block content %}
<main class="login-main">
<form class="login-card" hx-post="/password" hx-target="#password-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate>
<h1>Change password</h1>
<p class="hint">For a freshly seeded admin or superadmin account, leave the current password empty.</p>
<label>Current password<input name="current_password" type="password" autocomplete="current-password"></label>
<label>New password<input name="new_password" type="password" autocomplete="new-password" required></label>
<label>Confirm new password<input name="new_password_confirmation" type="password" autocomplete="new-password" required></label>
<button type="submit">Change password</button>
<div id="password-status" aria-live="polite"></div>
</form>
</main>
{% endblock %}

View File

@@ -27,6 +27,7 @@
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %} {% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %} {% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %}
{% if nav.authenticated %} {% if nav.authenticated %}
<li><a href="/password" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Password</a></li>
<li><form hx-post="/logout" hx-swap="none"><button type="submit" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Log out</button></form></li> <li><form hx-post="/logout" hx-swap="none"><button type="submit" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Log out</button></form></li>
{% else %} {% else %}
<li><a href="/login" class="{% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li> <li><a href="/login" class="{% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>
@@ -49,6 +50,7 @@
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %} {% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %}
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %} {% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %}
{% if nav.authenticated %} {% if nav.authenticated %}
<li class="py-4"><a href="/password" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Password</a></li>
<li class="py-4"><form hx-post="/logout" hx-swap="none"><button type="submit" class="w-full text-left text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Log out</button></form></li> <li class="py-4"><form hx-post="/logout" hx-swap="none"><button type="submit" class="w-full text-left text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Log out</button></form></li>
{% else %} {% else %}
<li class="py-4"><a href="/login" class="w-full text-lg {% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li> <li class="py-4"><a href="/login" class="w-full text-lg {% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>