crates/cli/ents-web/src/pages/account.rs
account.rshistorycomment on this file
| 1 | //! `GET /account`, `POST /account`: who the current session is (the |
| 2 | //! serving identity's enrolled member, `roots.web-signing` -- there is no |
| 3 | //! login flow, the signing key *is* the identity), followed by the |
| 4 | //! generic *view* of [`ents_model::Account`] (`crate::render::view`, |
| 5 | //! reflection-driven, the same mechanism [`super::members`] and |
| 6 | //! [`super::redactions`] use), paired with this crate's one demonstrated |
| 7 | //! generic-edit write flow (`roots.web-session`'s signed, CSRF-checked |
| 8 | //! mutation path). |
| 9 | //! |
| 10 | //! Account is the write-flow demo rather than every entity because it is |
| 11 | //! the simplest possible case -- two string-shaped fields, one fixed ref, |
| 12 | //! no anchor or recipe machinery to special-case -- so the CSRF/session/ |
| 13 | //! signing plumbing this page exercises is visible without also chasing a |
| 14 | //! more complex entity's own domain logic. Every other write flow this |
| 15 | //! crate ships ([`super::comments::add`]) is a legitimate custom page for |
| 16 | //! exactly the reason `ents-forge`'s own comment command is: anchoring |
| 17 | //! needs a repository checkout and a projection, not a bare form. |
| 18 | |
| 19 | use std::sync::Arc; |
| 20 | |
| 21 | use axum::Form; |
| 22 | use axum::extract::State; |
| 23 | use axum::response::{IntoResponse, Redirect}; |
| 24 | use ents_model::{Account, Member, MemberId, namespace}; |
| 25 | use ents_receive::propose_entity; |
| 26 | use gix_object::{Find, Write}; |
| 27 | use maud::html; |
| 28 | use serde::Deserialize; |
| 29 | |
| 30 | use crate::error::{Error, Result}; |
| 31 | use crate::session::Session; |
| 32 | use crate::state::AppState; |
| 33 | |
| 34 | /// `GET /account`: who the current session is, first -- the enrolled |
| 35 | /// member whose key the serving identity signs with, as the same identity |
| 36 | /// card `crate::pages::members` renders, or the unenrolled key itself -- |
| 37 | /// then the recorded [`Account`] (the hosted login mapping) with its edit |
| 38 | /// form. There is no login flow to land on: a local root's identity *is* |
| 39 | /// the signing key `git ents serve` resolved at startup |
| 40 | /// (`roots.web-signing`), so this page states that rather than asking for |
| 41 | /// credentials. |
| 42 | /// |
| 43 | /// # Errors |
| 44 | /// |
| 45 | /// Propagates a ref-store or object read failure. |
| 46 | pub async fn show<O>( |
| 47 | State(state): State<Arc<AppState<O>>>, |
| 48 | axum::Extension(session): axum::Extension<Session>, |
| 49 | ) -> Result<maud::Markup> |
| 50 | where |
| 51 | O: Find + Write + Send + 'static, |
| 52 | { |
| 53 | let pubkey = state.identity.public_openssh(); |
| 54 | let enrolled = resolve_member_by_key(&state, &pubkey).ok(); |
| 55 | let current = read(&state)?; |
| 56 | let (member_value, login_value) = match ¤t { |
| 57 | Some(account) => (account.member.as_str().to_owned(), account.login.clone()), |
| 58 | None => (String::new(), String::new()), |
| 59 | }; |
| 60 | let view = current |
| 61 | .as_ref() |
| 62 | .map(crate::render::view) |
| 63 | .unwrap_or_else(|| html! { p.muted { "No login mapping recorded." } }); |
| 64 | |
| 65 | Ok(super::layout( |
| 66 | &super::RepoHeader::from_state(&state), |
| 67 | &super::identity_label(&state), |
| 68 | super::Tab::Account, |
| 69 | "Account", |
| 70 | html! { |
| 71 | div.readable { |
| 72 | h2 { "Signing key" } |
| 73 | @match &enrolled { |
| 74 | Some((username, member)) => { |
| 75 | p { |
| 76 | "Signed in as the member below. Every web edit is a " |
| 77 | "mutation commit signed with this key, exactly as " |
| 78 | code { "git ents" } |
| 79 | " itself would sign it — a local root has no separate login." |
| 80 | } |
| 81 | (super::members::member_card(username.as_str(), member, true)) |
| 82 | } |
| 83 | None => { |
| 84 | div.card.member-card { |
| 85 | div.member-head { |
| 86 | span.member-name { "Unenrolled key" } |
| 87 | @if let Some(key_type) = pubkey.split_whitespace().next() { |
| 88 | span.key-badge { (key_type) } |
| 89 | } |
| 90 | } |
| 91 | p { |
| 92 | "This signing key is not enrolled as a " |
| 93 | a href="/members" { "member" } |
| 94 | " of this repository. Edits still sign with it; enroll " |
| 95 | "the key to have them attributed to a username." |
| 96 | } |
| 97 | div.member-key { |
| 98 | pre { (pubkey) } |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | h2 { "Hosted login" } |
| 104 | p.muted { |
| 105 | "A hosted deployment maps an external login to an enrolled " |
| 106 | "member so its pushes can be attributed. A local root never " |
| 107 | "needs one — the key above is the identity." |
| 108 | } |
| 109 | (view) |
| 110 | details.disclosure { |
| 111 | summary { "Edit login mapping" } |
| 112 | form method="post" action="/account" { |
| 113 | (super::csrf_input(&session)) |
| 114 | label { "Member" input type="text" name="member" value=(member_value) list="members"; } |
| 115 | label { "Login" input type="text" name="login" value=(login_value); } |
| 116 | button type="submit" { "Save" } |
| 117 | } |
| 118 | (super::members_datalist(&state)) |
| 119 | } |
| 120 | } |
| 121 | }, |
| 122 | )) |
| 123 | } |
| 124 | |
| 125 | /// The form fields `POST /account` accepts. |
| 126 | #[derive(Debug, Deserialize)] |
| 127 | pub struct AccountForm { |
| 128 | /// The member this account belongs to; if blank, resolved from the |
| 129 | /// signing identity's own enrolled key (mirrors |
| 130 | /// `git_ents::commands::account::create`'s identical default). |
| 131 | #[serde(default)] |
| 132 | member: String, |
| 133 | /// The login identity to record. |
| 134 | login: String, |
| 135 | /// The per-session CSRF token (`roots.web-session`). |
| 136 | csrf: String, |
| 137 | } |
| 138 | |
| 139 | /// `POST /account`: create or update the account, signed |
| 140 | /// (`roots.web-signing`) on behalf of the current session |
| 141 | /// (`roots.web-session`). |
| 142 | /// |
| 143 | /// # Errors |
| 144 | /// |
| 145 | /// [`Error::BadCsrf`] if `form.csrf` does not match the session's own |
| 146 | /// token; [`Error::NotFound`] if `member` is blank and the signing |
| 147 | /// identity's key is not an enrolled member; otherwise propagates a |
| 148 | /// serialization or `receive` failure. |
| 149 | // @relation(roots.web-signing, roots.web-session, scope=function) |
| 150 | pub async fn update<O>( |
| 151 | State(state): State<Arc<AppState<O>>>, |
| 152 | axum::Extension(session): axum::Extension<Session>, |
| 153 | Form(form): Form<AccountForm>, |
| 154 | ) -> Result<impl IntoResponse> |
| 155 | where |
| 156 | O: Find + Write + Send + 'static, |
| 157 | { |
| 158 | super::require_csrf(&session, &form.csrf)?; |
| 159 | |
| 160 | let member = if form.member.trim().is_empty() { |
| 161 | resolve_member_by_key(&state, &state.identity.public_openssh())?.0 |
| 162 | } else { |
| 163 | MemberId::new(form.member.trim()) |
| 164 | }; |
| 165 | let account = Account { |
| 166 | member, |
| 167 | login: form.login, |
| 168 | }; |
| 169 | |
| 170 | #[expect( |
| 171 | clippy::expect_used, |
| 172 | reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal, mirroring \ |
| 173 | git_ents::commands::account's identical unguarded conversion" |
| 174 | )] |
| 175 | let name: gix::refs::FullName = namespace::ACCOUNT_REF |
| 176 | .try_into() |
| 177 | .expect("fixed, valid refname"); |
| 178 | |
| 179 | let identity = state.identity.as_ref(); |
| 180 | let outcome = propose_entity( |
| 181 | state.refs.as_ref(), |
| 182 | &*state.objects(), |
| 183 | state.events.as_ref(), |
| 184 | name, |
| 185 | &account, |
| 186 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 187 | "Create account (web)", |
| 188 | state.mode, |
| 189 | )?; |
| 190 | crate::error::outcome_to_result(outcome)?; |
| 191 | Ok(Redirect::to("/account")) |
| 192 | } |
| 193 | |
| 194 | fn read<O: Find>(state: &AppState<O>) -> Result<Option<Account>> { |
| 195 | #[expect( |
| 196 | clippy::expect_used, |
| 197 | clippy::unwrap_in_result, |
| 198 | reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal" |
| 199 | )] |
| 200 | let name: gix::refs::FullName = namespace::ACCOUNT_REF |
| 201 | .try_into() |
| 202 | .expect("fixed, valid refname"); |
| 203 | let Some(tip) = state.refs.get(name.as_ref())? else { |
| 204 | return Ok(None); |
| 205 | }; |
| 206 | let tree = super::commit_tree(&*state.objects(), tip)?; |
| 207 | Ok(Some(facet_git_tree::deserialize::<Account>( |
| 208 | &tree, |
| 209 | &*state.objects(), |
| 210 | )?)) |
| 211 | } |
| 212 | |
| 213 | /// Resolve `pubkey` to the enrolled member whose stored key matches it |
| 214 | /// (its id and full [`Member`] record), or [`Error::NotFound`] when none |
| 215 | /// does — shared with `crate::pages::commits::review`, which needs the |
| 216 | /// same "which member is this session" lookup to key a review's composite |
| 217 | /// `refs/meta/reviews/<target>/<member>` ref (`model.review`), and with |
| 218 | /// [`show`]'s own signed-in-as card. |
| 219 | /// |
| 220 | /// # Errors |
| 221 | /// |
| 222 | /// [`Error::NotFound`] if no enrolled member's key matches `pubkey`; |
| 223 | /// otherwise propagates a ref-store or object read failure. |
| 224 | pub(crate) fn resolve_member_by_key<O: Find>( |
| 225 | state: &AppState<O>, |
| 226 | pubkey: &str, |
| 227 | ) -> Result<(MemberId, Member)> { |
| 228 | for entry in state.refs.iter_prefix("refs/meta/member/")? { |
| 229 | let (name, tip) = entry?; |
| 230 | let path = name.as_bstr().to_string(); |
| 231 | let Some(username) = path.strip_prefix("refs/meta/member/") else { |
| 232 | continue; |
| 233 | }; |
| 234 | let tree = super::commit_tree(&*state.objects(), tip)?; |
| 235 | if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) |
| 236 | && member.key == pubkey |
| 237 | { |
| 238 | return Ok((MemberId::new(username), member)); |
| 239 | } |
| 240 | } |
| 241 | Err(Error::NotFound { |
| 242 | what: "member for the current signing identity".to_owned(), |
| 243 | }) |
| 244 | } |