diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index a602770..e7cc6cd 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -1816,6 +1816,104 @@ pub struct VetterStandingRow { pub profile: String, } +/// One of the holder's pool attributes, as a row to tick. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PoolRow { + pub attribute_id: String, + pub claim_type: String, + /// What to call it on screen — the holder's label, else the claim type. + pub label: String, +} + +/// Which half of the new-face form has the keyboard. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NewFaceFocus { + #[default] + Name, + Attributes, +} + +/// Making a face without leaving the vetting flow. +/// +/// The general face editor lives on the Identity page and is the right place to +/// build a face for its own sake. This is the narrow case: a community has +/// already said which claim types its card must carry, so the form knows what +/// the face is *for* and can open with those attributes already ticked. +/// +/// Composed from attributes the pool already holds — it never creates one. +/// Supplying a value for, say, a legal name is a different act from arranging +/// which attributes a face shows, and it belongs with the editor that knows +/// about value types, sensitivity and masking. What this form owes the holder +/// when the pool is short is to say exactly which claim type is missing, rather +/// than to offer a face that cannot make the card. +#[derive(Clone, Debug, Default)] +pub struct NewFaceForm { + pub application_id: String, + /// The claim types this community's card must carry, so the list can mark + /// them and the form can warn when the pool cannot cover them. + pub required: Vec, + /// Every attribute the holder has, metadata only. + pub pool: Vec, + pub name: String, + /// Attribute ids ticked, in the order they were ticked — which is the + /// order the face will present them in. + pub ticked: Vec, + pub cursor: usize, + pub focus: NewFaceFocus, + pub error: Option, +} + +impl NewFaceForm { + /// Required claim types no attribute in the pool can supply. + /// + /// Not a refusal: a holder may be building a face now and adding the + /// attribute afterwards. It is named so the reason the card will be + /// refused is on screen *before* a vetter is waiting on it. + #[must_use] + pub fn uncoverable(&self) -> Vec<&str> { + self.required + .iter() + .filter(|r| !self.pool.iter().any(|a| &&a.claim_type == r)) + .map(String::as_str) + .collect() + } + + /// The claim types the ticked attributes would disclose. + #[must_use] + pub fn covered(&self) -> Vec<&str> { + self.pool + .iter() + .filter(|a| self.ticked.contains(&a.attribute_id)) + .map(|a| a.claim_type.as_str()) + .collect() + } + + /// Required claim types the current selection still does not carry. + #[must_use] + pub fn still_missing(&self) -> Vec<&str> { + let covered = self.covered(); + self.required + .iter() + .filter(|r| !covered.contains(&r.as_str())) + .map(String::as_str) + .collect() + } + + /// Tick or untick the attribute under the cursor. + pub fn toggle(&mut self) { + let Some(row) = self.pool.get(self.cursor) else { + return; + }; + match self.ticked.iter().position(|id| id == &row.attribute_id) { + Some(i) => { + self.ticked.remove(i); + } + None => self.ticked.push(row.attribute_id.clone()), + } + self.error = None; + } +} + /// What the Vetting page is doing. #[derive(Clone, Debug, Default)] pub enum VettingMode { @@ -1841,6 +1939,8 @@ pub enum VettingMode { /// The DID to grant it to, when this install has one. credential_did: Option, }, + /// Make a face for this community, without leaving the flow. + NewFace(Box), /// Choose the face an application shows vetters. ChooseFace { application_id: String, @@ -1937,6 +2037,9 @@ impl VettingMode { Some(event) => event.text().map(String::as_str), None => form.text().map(String::as_str), }, + // Only while the name has focus: on the tick list, space has to + // reach `Toggle` rather than being typed into a field. + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Name => Some(&form.name), _ => None, } } @@ -1955,6 +2058,7 @@ impl VettingMode { VettingMode::RequestVetter { code, field: 1, .. } => Some(code), VettingMode::Directory(view) => view.text_mut(), VettingMode::Profile(form) => form.focused_text_mut(), + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Name => Some(&mut form.name), _ => None, } } diff --git a/openvtc/src/state_handler/vetting_actions.rs b/openvtc/src/state_handler/vetting_actions.rs index 0a0c03e..8e91aa9 100644 --- a/openvtc/src/state_handler/vetting_actions.rs +++ b/openvtc/src/state_handler/vetting_actions.rs @@ -57,10 +57,11 @@ use crate::state_handler::join_flow; use crate::state_handler::main_page::content::{ ApplicationRow, AttestForm, CardPreview, DIRECTORY_FIELDS, DIRECTORY_LABELS, DIRECTORY_METHODS, DeskRow, DeskStage, DeskView, DirectoryCommunity, DirectoryView, EVENT_FIELDS, EVENT_LABELS, - EventForm, FaceChoice, IssuedRow, LineTone, ListedVetterRow, PROFILE_FIELDS, PROFILE_LABELS, - RequestRow, TicketRow, VETTING_METHODS, VETTING_RELATIONSHIPS, VETTING_TICKET_USES, - VETTING_WITHDRAWAL_REASONS, VetterProfileForm, VetterStandingRow, VettingMembership, - VettingMode, VettingPersona, VettingState, VettingTab, method_label, row_of, + EventForm, FaceChoice, IssuedRow, LineTone, ListedVetterRow, NewFaceFocus, NewFaceForm, + PROFILE_FIELDS, PROFILE_LABELS, PoolRow, RequestRow, TicketRow, VETTING_METHODS, + VETTING_RELATIONSHIPS, VETTING_TICKET_USES, VETTING_WITHDRAWAL_REASONS, VetterProfileForm, + VetterStandingRow, VettingMembership, VettingMode, VettingPersona, VettingState, VettingTab, + method_label, row_of, }; use crate::state_handler::main_page::menu::MainMenu; use crate::state_handler::main_page::{sanitize_display, shorten_did}; @@ -665,6 +666,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { refresh_application_contexts(ctx); } VettingAction::Toggle => match &mut page(ctx).mode { + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Attributes => form.toggle(), VettingMode::Attest { form, .. } => match form.field { 3 => form.liveness_confirmed = !form.liveness_confirmed, 4 => form.attested = !form.attested, @@ -903,7 +905,23 @@ fn move_field(v: &mut VettingState, forward: bool) { step(&mut form.field, rows); } }, - VettingMode::ChooseFace { faces, index, .. } => step(index, faces.len()), + // One past the faces is "make a new one" — a row, not a key, so that + // every way out of this screen is in the list the eye is already on. + VettingMode::ChooseFace { faces, index, .. } => step(index, faces.len() + 1), + VettingMode::NewFace(form) => match form.focus { + NewFaceFocus::Name => form.focus = NewFaceFocus::Attributes, + NewFaceFocus::Attributes => { + let rows = form.pool.len(); + if rows == 0 { + form.focus = NewFaceFocus::Name; + } else if form.cursor + 1 < rows { + form.cursor += 1; + } else { + form.focus = NewFaceFocus::Name; + form.cursor = 0; + } + } + }, VettingMode::Attest { form, .. } => step(&mut form.field, AttestForm::FIELDS), _ => {} } @@ -966,7 +984,7 @@ fn cycle(v: &mut VettingState, forward: bool) { VettingMode::Withdraw { reason_index, .. } => { turn(reason_index, VETTING_WITHDRAWAL_REASONS.len()); } - VettingMode::ChooseFace { faces, index, .. } => turn(index, faces.len()), + VettingMode::ChooseFace { faces, index, .. } => turn(index, faces.len() + 1), _ => {} } } @@ -998,8 +1016,15 @@ async fn submit(ctx: &mut ActionCtx<'_>) { application_id, faces, index, - .. - } => wear_face(ctx, &application_id, faces.get(index).cloned()), + required, + } => { + if index == faces.len() { + open_new_face(ctx, &application_id, required); + } else { + wear_face(ctx, &application_id, faces.get(index).cloned()); + } + } + VettingMode::NewFace(form) => create_face(ctx, *form), VettingMode::RequestVetter { application_id, vetter, @@ -1856,6 +1881,75 @@ fn preview_refusal(error: &str, application_id: &str, config: &Config) -> String ) } +/// Open the make-a-face form, reading the pool to fill its tick list. +/// +/// The read is what makes this worth doing inline: the community has already +/// said which claim types it needs, so the form opens with the matching +/// attributes already ticked and the holder's decision is usually just a name. +fn open_new_face(ctx: &mut ActionCtx<'_>, application_id: &str, required: Vec) { + let Some(client) = admin_client(ctx) else { + return; + }; + if !begin(ctx) { + return; + } + status(ctx, "Reading your attributes…"); + let job = FaceJob::Pool { + client, + application_id: application_id.to_string(), + required, + }; + spawn_job(ctx, job.run()); +} + +/// Create the face the form describes, and wear it. +/// +/// One step, not two. A face made here exists only to be worn by this +/// application — leaving it created but unworn would put the holder back on the +/// picker to do the thing they had just asked for. +fn create_face(ctx: &mut ActionCtx<'_>, mut form: NewFaceForm) { + let name = form.name.trim().to_string(); + if name.is_empty() { + form.error = Some("Give the face a name — it is how you will recognise it later.".into()); + form.focus = NewFaceFocus::Name; + page(ctx).mode = VettingMode::NewFace(Box::new(form)); + return; + } + if form.ticked.is_empty() { + form.error = Some( + "Tick at least one attribute with space — a face that shows nothing cannot make a \ + card." + .into(), + ); + form.focus = NewFaceFocus::Attributes; + page(ctx).mode = VettingMode::NewFace(Box::new(form)); + return; + } + let Some(client) = admin_client(ctx) else { + return; + }; + let application_id = form.application_id.clone(); + let (context_id, persona_did) = match application_context(ctx.config, &application_id) { + Ok(found) => found, + Err(e) => return status(ctx, format!("Cannot make a face: {e}")), + }; + if !begin(ctx) { + return; + } + page(ctx).mode = VettingMode::List; + status(ctx, format!("Making {name}…")); + let job = FaceJob::Create { + client, + top_context_id: ctx.config.account.top_context_id.clone(), + context_id, + persona_did, + application_id, + name, + live_refs: form.ticked.clone(), + }; + spawn_job(ctx, job.run()); +} + /// The claim types a card for this community must carry. /// /// From the manifest when it has been read, and from the fallback set when it @@ -2517,6 +2611,22 @@ pub(crate) enum FaceJob { persona_did: String, application_id: String, }, + /// Read the pool so the make-a-face form has something to tick. + Pool { + client: VtaClient, + application_id: String, + required: Vec, + }, + /// Create a face and wear it, in one step. + Create { + client: VtaClient, + top_context_id: String, + context_id: String, + persona_did: String, + application_id: String, + name: String, + live_refs: Vec, + }, Wear { client: VtaClient, top_context_id: String, @@ -2603,6 +2713,85 @@ impl FaceJob { result, } } + FaceJob::Pool { + client, + application_id, + required, + } => { + // Metadata only. The form arranges which attributes a face + // shows; it never reads or writes their values, so there is + // nothing here to decrypt. + let result = pool::list(&client, false, false) + .await + .map(|attributes| { + attributes + .into_iter() + .map(|a| PoolRow { + label: sanitize_display(a.display_name(), 128), + claim_type: a.claim_type, + attribute_id: a.attribute_id, + }) + .collect::>() + }) + .map_err(|e| e.to_string()); + VettingOutcome::Pool { + application_id, + required, + result, + } + } + FaceJob::Create { + client, + top_context_id, + context_id, + persona_did, + application_id, + name, + live_refs, + } => { + // `other_entries` is empty because this creates: there is no + // profile whose pinned or inline entries could be dropped. The + // general editor has to carry them; here there is nothing yet + // to carry. + let created = profile::put(&client, None, &name, &live_refs, &[], None).await; + let (profile_id, error) = match created { + Ok(id) => (id, None), + Err(e) => (String::new(), Some(e.to_string())), + }; + // Wearing it is the point, so a face created but left unworn + // would put the holder back on the picker to do what they had + // just asked for. A failure to wear is still reported against + // the face that now exists. + let error = match error { + Some(e) => Some(e), + None => { + let slug = parse_sub_context_id(&context_id) + .map_or(context_id.as_str(), |(_, slug)| slug); + match community_context::ensure_context( + &client, + &top_context_id, + &context_id, + slug, + ) + .await + { + Err(e) => Some(e.to_string()), + Ok(_) => { + binding::set(&client, &context_id, &persona_did, Some(&profile_id)) + .await + .err() + .map(|e| e.to_string()) + } + } + } + }; + VettingOutcome::FaceWorn { + error, + application_id, + profile_id, + name, + } + } FaceJob::Wear { client, top_context_id, @@ -2811,6 +3000,12 @@ pub(crate) enum VettingOutcome { application_id: String, result: Result, String>, }, + /// The pool, for the make-a-face form. + Pool { + application_id: String, + required: Vec, + result: Result, String>, + }, /// A face is worn, or is not. FaceWorn { application_id: String, @@ -2895,6 +3090,58 @@ impl VettingOutcome { // The application may have just been given its context. (message.to_string(), true) } + VettingOutcome::Pool { + application_id, + required, + result: Ok(pool), + } => { + // Open with the required claim types already ticked. The + // community has said what the card needs, so leaving the + // holder to work that out from a list of thirty attributes + // would be withholding the one thing that makes this inline. + let ticked: Vec = required + .iter() + .filter_map(|want| { + pool.iter() + .find(|a| &a.claim_type == want) + .map(|a| a.attribute_id.clone()) + }) + .collect(); + let form = NewFaceForm { + application_id, + required, + pool, + name: String::new(), + ticked, + cursor: 0, + focus: NewFaceFocus::Name, + error: None, + }; + let short = form.uncoverable(); + let message = if short.is_empty() { + "Name the face. What this community needs is already ticked.".to_string() + } else { + format!( + "You have no {} attribute — add one under My Identity, or make the face \ + now and add it after.", + short.join(" or ") + ) + }; + v.mode = VettingMode::NewFace(Box::new(form)); + (message, true) + } + VettingOutcome::Pool { result: Err(e), .. } => { + // The same refusal the faces read has, for the same reason: + // the pool is what a face is built over. + if crate::holder_grant::needs_holder_grant(&e) { + v.mode = VettingMode::HolderGrant { + credential_did: agent_credential_did(config).map(str::to_string), + }; + ("Could not read your attributes.".to_string(), true) + } else { + (format!("Could not read your attributes: {e}"), true) + } + } VettingOutcome::Faces { result: Err(e), .. } => { // Faces are built over the holder's attribute pool, which sits // above every context — so the commonest way this fails is the @@ -3808,6 +4055,95 @@ mod tests { assert_eq!(v.worn_faces.get("a").map(String::as_str), Some("WORK")); } + fn pool_row(id: &str, claim_type: &str) -> PoolRow { + PoolRow { + attribute_id: id.into(), + claim_type: claim_type.into(), + label: claim_type.to_uppercase(), + } + } + + /// The whole point of making the face *here*: the community has already + /// said what its card needs, so the holder should not have to work that out + /// again from a list of attributes. + #[test] + fn the_new_face_form_opens_with_the_required_claims_ticked() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: vec!["name.legal".into()], + result: Ok(vec![ + pool_row("attr-email", "email.work"), + pool_row("attr-name", "name.legal"), + ]), + } + .apply(&mut state, &mut config, &mut save); + + let VettingMode::NewFace(form) = &state.main_page.content_panel.vetting.mode else { + panic!("the form did not open"); + }; + assert_eq!(form.ticked, vec!["attr-name".to_string()]); + assert!( + form.still_missing().is_empty(), + "the required claim is covered by the opening selection" + ); + // Unticking it is said against the selection, not the pool, so the + // warning appears at the moment the choice is made. + let mut form = (**form).clone(); + form.cursor = 1; + form.toggle(); + assert_eq!(form.still_missing(), vec!["name.legal"]); + } + + /// A claim type no attribute can supply is a different problem from one + /// that is merely unticked, and the form must not conflate them: ticking + /// harder will not fix it. + #[test] + fn a_claim_the_pool_cannot_cover_is_named_as_such() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: vec!["name.legal".into()], + result: Ok(vec![pool_row("attr-email", "email.work")]), + } + .apply(&mut state, &mut config, &mut save); + + let VettingMode::NewFace(form) = &state.main_page.content_panel.vetting.mode else { + panic!("the form did not open"); + }; + assert!(form.ticked.is_empty(), "nothing in the pool matches"); + assert_eq!(form.uncoverable(), vec!["name.legal"]); + assert_eq!(form.still_missing(), vec!["name.legal"]); + } + + /// The pool is what a face is built over, so it meets the same refusal the + /// faces read does — and must answer it the same way rather than passing + /// the agent's paragraph through. + #[test] + fn the_pool_read_routes_the_holder_refusal_to_the_same_view() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: Vec::new(), + result: Err("forbidden: requires an unscoped holder credential".into()), + } + .apply(&mut state, &mut config, &mut save); + + assert!(matches!( + &state.main_page.content_panel.vetting.mode, + VettingMode::HolderGrant { .. } + )); + } + /// The card preview's one explicable refusal. The agent's sentence names /// neither the claims, nor the face, nor the key that changes it — and it /// arrives three steps after the choice that caused it. diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 44b2d7a..1ed3f0e 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -23,7 +23,7 @@ use crate::state_handler::{ main_page::content::{ AttestForm, CardPreview, ContentPanelState, DIRECTORY_FIELDS, DIRECTORY_LABELS, DIRECTORY_METHODS, DeskStage, DeskView, DirectoryView, EVENT_LABELS, EventForm, LineTone, - PROFILE_FIELDS, PROFILE_LABELS, VETTING_METHODS, VETTING_RELATIONSHIPS, + NewFaceFocus, PROFILE_FIELDS, PROFILE_LABELS, VETTING_METHODS, VETTING_RELATIONSHIPS, VETTING_TICKET_USES, VETTING_WITHDRAWAL_REASONS, VetterProfileForm, VettingMode, VettingState, VettingTab, method_label, reason_label, relationship_label, }, @@ -67,6 +67,7 @@ pub fn mode_id(state: &VettingState) -> &'static str { (VettingMode::NewApplication { .. }, _) => "new-application", (VettingMode::HolderGrant { .. }, _) => "holder-grant", (VettingMode::ChooseFace { .. }, _) => "face", + (VettingMode::NewFace(_), _) => "new-face", (VettingMode::RequestVetter { .. }, _) => "request", (VettingMode::Directory(_), _) => "directory", (VettingMode::Profile(form), _) if form.event.is_some() => "profile-event", @@ -246,6 +247,101 @@ pub fn render(v: &VettingState) -> Vec> { lines.push(Line::from("")); lines.push(hint("Esc: back")); } + VettingMode::NewFace(form) => { + lines.push(heading("Make a face for this community")); + lines.push(Line::from("")); + lines.push(hint( + "A face is a selection of your attributes. Making one here does not copy them —", + )); + lines.push(hint( + "the same attribute can appear in as many faces as you like.", + )); + lines.push(Line::from("")); + lines.push(field( + "Name", + if form.name.is_empty() { + "—".to_string() + } else { + form.name.clone() + }, + form.focus == NewFaceFocus::Name, + false, + )); + lines.push(Line::from("")); + if !form.required.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" This community's card needs ", label()), + Span::styled(form.required.join(", "), value()), + ])); + lines.push(Line::from("")); + } + if form.pool.is_empty() { + lines.push(Line::from(Span::styled( + " You have no attributes yet. Add them under My Identity, then press f again.", + dim(), + ))); + } + for (i, attribute) in form.pool.iter().enumerate() { + let on_row = form.focus == NewFaceFocus::Attributes && i == form.cursor; + let ticked = form.ticked.contains(&attribute.attribute_id); + // A required claim type is marked wherever it appears, ticked + // or not: the holder is choosing against a list the community + // set, and that list should be visible on the rows it governs. + let wanted = form.required.contains(&attribute.claim_type); + let style = if on_row { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }; + lines.push(Line::from(vec![ + Span::styled(if on_row { "▸ " } else { " " }, style), + Span::styled(if ticked { "[x] " } else { "[ ] " }, style), + Span::styled(attribute.label.clone(), style), + Span::styled(format!(" {}", attribute.claim_type), dim()), + Span::styled( + if wanted { " needed here" } else { "" }, + Style::new().fg(COLOR_SUCCESS), + ), + ])); + } + // Said against the current selection, not against the pool, so + // unticking something required says so at once rather than at the + // card preview. + let short = form.still_missing(); + if !short.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" missing ", dim()), + Span::styled( + short.join(", "), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ), + Span::styled(" — this face could not make the card", dim()), + ])); + let uncoverable = form.uncoverable(); + if !uncoverable.is_empty() { + lines.push(Line::from(Span::styled( + format!( + " no attribute of yours is {} — add one under My Identity", + uncoverable.join(" or ") + ), + dim(), + ))); + } + } + if let Some(error) = &form.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {error}"), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::from("")); + lines.push(hint( + "Tab: name / attributes ↑/↓: move Space: tick Enter: make it and wear it \ + Esc: cancel", + )); + } VettingMode::ChooseFace { faces, index, @@ -335,10 +431,28 @@ pub fn render(v: &VettingState) -> Vec> { ])); } } - lines.push(Line::from("")); - lines.push(hint( - "Add a missing claim to a face under My Identity, then press f again.", - )); + // A row rather than a key, so every way out of this screen is in + // the list the eye is already on. + let on_new = *index == faces.len(); + lines.push(Line::from(vec![ + Span::styled( + if on_new { "▸ " } else { " " }, + if on_new { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }, + ), + Span::styled( + "Make a face for this community", + if on_new { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }, + ), + Span::styled(" from attributes you already have", dim()), + ])); lines.push(Line::from("")); lines.push(hint("↑/↓: choose Enter: wear it Esc: cancel")); }