git-ents.gitmain
⌘K
foforge
commit 11f2435
roots: state the signed-in member on the account page

Landing on /account read like a login-or-signup form. A local root has no login: the signing key resolved at serve startup is the identity, so the page now opens with the enrolled member’s own identity card (the same card the members page renders) or the unenrolled key itself, and reframes the Account record as the hosted login mapping it is, its edit form folded behind a disclosure.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/tests/router.rs @@ -586,6 +586,54 @@ assert!(body.contains("No commits yet.")); } +/// `GET /account` states who the session is (`roots.web-signing`): with +/// the serving identity's key enrolled, the page renders that member's +/// own identity card (never a login or signup form -- the signing key is +/// the identity); with no matching member, it shows the unenrolled key +/// itself. +#[tokio::test] +// @relation(roots.web-signing, scope=function, role=Verifies) +async fn account_page_names_the_signed_in_member() { + let key = Keypair::from_seed(1); + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + enroll_member( + &refs, + &objects, + "joey", + &key, + Provenance::AdminRegistered, + 100, + ); + let state = build_state_with( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + refs, + objects, + ); + let body = get_body(&ents_web::router(state), "/account").await; + assert!( + body.contains("Signed in as the member below"), + "the page states the session's identity" + ); + assert!( + body.contains(">joey</a>"), + "the enrolled member's card renders" + ); + + let stranger = build_state(FixtureIdentity { + name: "stranger", + key: Keypair::from_seed(2), + }); + let body = get_body(&ents_web::router(stranger), "/account").await; + assert!( + body.contains("not enrolled as a"), + "an unenrolled key is stated, not hidden behind a signup form" + ); +} + /// `roots.web-session`: a state-changing request with no CSRF token at /// all is a bad request (axum's own `Form` rejection); one with the wrong /// token is refused by this crate's own check; the session cookie a `GET`
crates/cli/ents-web/src/pages/account.rs @@ -1,8 +1,11 @@ -//! `GET /account`, `POST /account`: the generic *view* of -//! [`ents_model::Account`] (`crate::render::view`, reflection-driven, the -//! same mechanism [`super::members`] and [`super::redactions`] use), paired -//! with this crate's one demonstrated generic-edit write flow -//! (`roots.web-session`'s signed, CSRF-checked mutation path). +//! `GET /account`, `POST /account`: who the current session is (the +//! serving identity's enrolled member, `roots.web-signing` -- there is no +//! login flow, the signing key *is* the identity), followed by the +//! generic *view* of [`ents_model::Account`] (`crate::render::view`, +//! reflection-driven, the same mechanism [`super::members`] and +//! [`super::redactions`] use), paired with this crate's one demonstrated +//! generic-edit write flow (`roots.web-session`'s signed, CSRF-checked +//! mutation path). //! //! Account is the write-flow demo rather than every entity because it is //! the simplest possible case -- two string-shaped fields, one fixed ref, @@ -28,8 +31,14 @@ use crate::session::Session; use crate::state::AppState; -/// `GET /account`: the current account (if one exists), plus a form to -/// create or update it. +/// `GET /account`: who the current session is, first -- the enrolled +/// member whose key the serving identity signs with, as the same identity +/// card `crate::pages::members` renders, or the unenrolled key itself -- +/// then the recorded [`Account`] (the hosted login mapping) with its edit +/// form. There is no login flow to land on: a local root's identity *is* +/// the signing key `git ents serve` resolved at startup +/// (`roots.web-signing`), so this page states that rather than asking for +/// credentials. /// /// # Errors /// @@ -41,6 +50,8 @@ where O: Find + Write + Send + 'static, { + let pubkey = state.identity.public_openssh(); + let enrolled = resolve_member_by_key(&state, &pubkey).ok(); let current = read(&state)?; let (member_value, login_value) = match &current { Some(account) => (account.member.as_str().to_owned(), account.login.clone()), @@ -49,7 +60,7 @@ let view = current .as_ref() .map(crate::render::view) - .unwrap_or_else(|| html! { p { "no account created yet" } }); + .unwrap_or_else(|| html! { p.muted { "No login mapping recorded." } }); Ok(super::layout( &super::RepoHeader::from_state(&state), @@ -58,13 +69,44 @@ "Account", html! { div.readable { + @match &enrolled { + Some((username, member)) => { + p { + "Signed in as the member below. Every web edit is a " + "mutation commit signed with this key, exactly as " + code { "git ents" } + " itself would sign it -- a local root has no separate login." + } + (super::members::member_card(username.as_str(), member, true)) + } + None => { + div.card { + p { + "This signing key is not enrolled as a " + a href="/members" { "member" } + " of this repository. Edits still sign with it; enroll " + "the key to have them attributed to a username." + } + pre { (pubkey) } + } + } + } + h2 { "Hosted login" } + p.muted { + "A hosted deployment maps an external login to an enrolled " + "member so its pushes can be attributed. A local root never " + "needs one -- the key above is the identity." + } (view) - h2 { "Create or Update" } - form method="post" action="/account" { - (super::csrf_input(&session)) - label { "member" input type="text" name="member" value=(member_value); } - label { "login" input type="text" name="login" value=(login_value); } - button type="submit" { "Save" } + details { + summary { "Edit" } + form method="post" action="/account" { + (super::csrf_input(&session)) + label { "member" input type="text" name="member" value=(member_value) list="members"; } + label { "login" input type="text" name="login" value=(login_value); } + button type="submit" { "Save" } + } + (super::members_datalist(&state)) } } }, @@ -107,7 +149,7 @@ super::require_csrf(&session, &form.csrf)?; let member = if form.member.trim().is_empty() { - resolve_member_by_key(&state, &state.identity.public_openssh())? + resolve_member_by_key(&state, &state.identity.public_openssh())?.0 } else { MemberId::new(form.member.trim()) }; @@ -159,11 +201,12 @@ )?)) } -/// Resolve `pubkey` to the enrolled member whose stored key matches it, or -/// [`Error::NotFound`] when none does — shared with -/// `crate::pages::commits::review`, which needs the same "which member is -/// this session" lookup to key a review's composite -/// `refs/meta/reviews/<target>/<member>` ref (`model.review`). +/// Resolve `pubkey` to the enrolled member whose stored key matches it +/// (its id and full [`Member`] record), or [`Error::NotFound`] when none +/// does — shared with `crate::pages::commits::review`, which needs the +/// same "which member is this session" lookup to key a review's composite +/// `refs/meta/reviews/<target>/<member>` ref (`model.review`), and with +/// [`show`]'s own signed-in-as card. /// /// # Errors /// @@ -172,7 +215,7 @@ pub(crate) fn resolve_member_by_key<O: Find>( state: &AppState<O>, pubkey: &str, -) -> Result<MemberId> { +) -> Result<(MemberId, Member)> { for entry in state.refs.iter_prefix("refs/meta/member/")? { let (name, tip) = entry?; let path = name.as_bstr().to_string(); @@ -183,7 +226,7 @@ if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) && member.key == pubkey { - return Ok(MemberId::new(username)); + return Ok((MemberId::new(username), member)); } } Err(Error::NotFound {
crates/cli/ents-web/src/pages/commits.rs @@ -673,6 +673,7 @@ fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId { let pubkey = state.identity.public_openssh(); super::account::resolve_member_by_key(state, &pubkey) + .map(|(id, _member)| id) .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey))) }
crates/cli/ents-web/src/pages/members.rs @@ -101,8 +101,9 @@ /// through the middle ([`truncate_middle`]) with the full key line behind /// a `<details>` toggle -- no digest dependency, so no fingerprint; the /// truncated material plus the expandable full line is the identity a -/// reader compares. -fn member_card(username: &str, member: &Member, link: bool) -> Markup { +/// reader compares. Also `crate::pages::account`'s signed-in-as card, so +/// "you" and "a member" render identically. +pub(crate) fn member_card(username: &str, member: &Member, link: bool) -> Markup { let (key_type, material) = split_key(&member.key); html! { div.card.member-card {