Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed

- **Holding no invitation is two different situations, and the join now tells
them apart.** An invitation credential must name a `credentialSubject.id` —
`validate_invitation_credential` refuses one without it, so there is no such
thing as a bearer invitation — and that subject is a persona DID. With no
personas, no valid invitation can be *for* you, so that row is genuinely shut
and says why. With a persona, one may exist that this vault has not seen, so
the row is live and starts by asking you to paste it.

Before, both read "none held for this community" while suggesting you paste
one on a later step — a greyed-out row telling you there was a way to act on
it, which is the shape of confusion the routes list exists to remove.

`RouteState::FirstStep` now carries *which* step, because they are not the
same: creating a persona is something the join has to do before the route can
proceed, while being asked to paste an invitation is a page the route already
leads to. Deciding that from the route's identity would have put the same
reasoning in two places and got it wrong in one.

### Changed

- **A way in whose prerequisite the join can supply is one you can take.**
Applying for vetting without a persona was greyed out with "create one under
My Identity" — so on a community that vets, the one route the community was
Expand Down
32 changes: 30 additions & 2 deletions openvtc/src/state_handler/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,32 @@ pub enum RouteState {
/// Take it and it starts with this, then proceeds. Said in the second
/// person and in order — it is a description of what happens next, not a
/// refusal dressed up.
FirstStep(String),
FirstStep {
/// What the step is, for the row to show.
note: String,
/// Which step, for the flow to take. Not every first step is the same
/// one: creating a persona is something the join has to *do* before the
/// route can proceed, while being asked to paste an invitation is
/// simply what the next page of the route already does. Deciding that
/// from the route's identity would put the same reasoning in two
/// places, and get it wrong in one of them.
kind: FirstStepKind,
},
/// It cannot be taken, and why. Reserved for what the join cannot supply:
/// an invitation you were never given, or a community that admits nobody
/// that way.
Blocked(String),
}

/// What taking a route has to do before it can proceed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FirstStepKind {
/// Open the create-persona overlay, then take the route.
CreatePersona,
/// Nothing extra — the step is a page the route already leads to.
OnTheWay,
}

/// One way in, as the routes list shows it.
#[derive(Clone, Debug)]
pub struct RouteOption {
Expand All @@ -140,7 +159,16 @@ impl RouteOption {
#[must_use]
pub fn first_step(&self) -> Option<&str> {
match &self.state {
RouteState::FirstStep(step) => Some(step),
RouteState::FirstStep { note, .. } => Some(note),
_ => None,
}
}

/// Which step that is.
#[must_use]
pub fn first_step_kind(&self) -> Option<FirstStepKind> {
match &self.state {
RouteState::FirstStep { kind, .. } => Some(*kind),
_ => None,
}
}
Expand Down
123 changes: 92 additions & 31 deletions openvtc/src/state_handler/join_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ use crate::{
StateHandler,
actions::Action,
join::{
ApplyAs, AvailableVic, IdentityPick, JoinApplication, JoinPage, JoinRoute, JoinState,
JoinVettingView, KnownVetting, PersonaOption, PresentedInvitation, RouteOption,
RouteState, VettingPhase,
ApplyAs, AvailableVic, FirstStepKind, IdentityPick, JoinApplication, JoinPage,
JoinRoute, JoinState, JoinVettingView, KnownVetting, PersonaOption,
PresentedInvitation, RouteOption, RouteState, VettingPhase,
},
main_page::content::{VicLifecycle, VicSummary},
main_page::{sanitize_display, shorten_did},
Expand Down Expand Up @@ -209,20 +209,30 @@ fn build_routes(
} else {
invitations_held(invitations)
},
// An invitation is the one prerequisite the join cannot supply:
// it is a credential somebody else has to have issued to you. So
// holding none blocks the route rather than starting it a step
// earlier — there is no step that would produce one.
// Holding none is two different situations, and the difference is
// provable rather than a guess. A VIC must name a
// `credentialSubject.id` — `validate_invitation_credential` refuses
// one without it, so there is no such thing as a bearer invitation
// — and that subject is a persona DID. With no personas, no valid
// invitation can be *for* you, and the row is genuinely shut. With
// personas, one may well exist that this vault has not seen, and
// pasting it is a step the join can walk you through.
state: if refuses {
RouteState::Blocked(format!("{community} does not admit anyone by invitation"))
} else if held == 0 {
} else if held > 0 {
RouteState::Ready
} else if personas.is_empty() {
RouteState::Blocked(
"none held for this community — paste one on the invitation step if you \
have one the vault has not seen"
"an invitation is issued to one of your personas, and you have none yet"
.to_string(),
)
} else {
RouteState::Ready
RouteState::FirstStep {
note: "none is in your vault, so this starts by asking you to paste one — \
if you have none, the step offers to join without it"
.to_string(),
kind: FirstStepKind::OnTheWay,
}
},
});
}
Expand All @@ -248,11 +258,12 @@ fn build_routes(
// actually telling you about read as the one you could not use.
None if personas.is_empty() => (
"no application yet".to_string(),
RouteState::FirstStep(
"you have no persona yet, so this starts by creating one — every card is \
signed by the DID you join with"
RouteState::FirstStep {
note: "you have no persona yet, so this starts by creating one — every card \
is signed by the DID you join with"
.to_string(),
),
kind: FirstStepKind::CreatePersona,
},
),
None => ("no application yet".to_string(), RouteState::Ready),
};
Expand Down Expand Up @@ -1096,11 +1107,18 @@ impl StateHandler {
let _ = self.state_tx.send(state.clone());
continue;
}
// A route that starts with a step: do the step,
// and remember what it was on the way to. That
// is the difference between being guided and
// being told to go and configure something.
Some(option) if option.first_step().is_some() => {
// A route that starts with the join doing
// something: do it, and remember what it was on
// the way to. That is the difference between
// being guided and being told to go and
// configure something elsewhere.
//
// A step that is merely the next page needs none
// of this — taking the route reaches it.
Some(option)
if option.first_step_kind()
== Some(FirstStepKind::CreatePersona) =>
{
let route = option.route;
state.join.messages.clear();
state.join.resume_route = Some(route);
Expand Down Expand Up @@ -3718,14 +3736,33 @@ mod vetting_tests {
assert!(invitation.detail.contains("2026-12-01"));
}

/// Holding none is an answer, not a reason to drop the row — "why can I
/// not use an invitation?" is what the page exists to settle.
/// Holding none is two situations, and which one it is follows from the
/// credential rather than from a guess: a VIC must name a
/// `credentialSubject.id`, so with no persona no valid invitation can be
/// *for* you. That row is shut. With a persona, one may exist that this
/// vault has not seen, and pasting it is a step the join can walk through.
#[test]
fn holding_no_invitation_keeps_the_row_and_gives_the_reason() {
let routes = build_routes("Kernel", &requirements(None), None, &[a_persona()], &[]);
let invitation = option_for(&routes, JoinRoute::Invitation).unwrap();
assert!(!invitation.available());
assert!(invitation.blocked().unwrap().contains("none held"));
fn holding_no_invitation_is_shut_without_a_persona_and_a_step_with_one() {
let no_persona = build_routes("Kernel", &requirements(None), None, &[], &[]);
let invitation = option_for(&no_persona, JoinRoute::Invitation).unwrap();
assert!(
!invitation.available(),
"nothing could have been issued yet"
);
assert!(
invitation.blocked().unwrap().contains("you have none yet"),
"{:?}",
invitation.blocked()
);

let with_persona = build_routes("Kernel", &requirements(None), None, &[a_persona()], &[]);
let invitation = option_for(&with_persona, JoinRoute::Invitation).unwrap();
assert!(invitation.available(), "one could exist outside the vault");
assert!(
invitation.first_step().unwrap().contains("paste one"),
"{:?}",
invitation.first_step()
);
}

/// An invitation the community *requires* is asked for on top of the
Expand Down Expand Up @@ -3804,9 +3841,33 @@ mod vetting_tests {
assert!(vetting.blocked().is_none());
}

/// Not every first step is the same step. Creating a persona is something
/// the join has to do *before* the route can proceed; being asked to paste
/// an invitation is a page the route already leads to. Taking the second
/// must not open the persona overlay.
#[test]
fn the_two_first_steps_are_different_steps() {
let routes = build_routes("Kernel", &requirements(None), None, &[a_persona()], &[]);
assert_eq!(
option_for(&routes, JoinRoute::Invitation)
.unwrap()
.first_step_kind(),
Some(FirstStepKind::OnTheWay),
);

let routes = build_routes("Kernel", &requirements(None), None, &[], &[]);
assert_eq!(
option_for(&routes, JoinRoute::Vetting)
.unwrap()
.first_step_kind(),
Some(FirstStepKind::CreatePersona),
);
}

/// The line between the two is who has to supply the missing thing. The
/// join can make a persona; it cannot make an invitation somebody else
/// has to have issued — so that one stays shut.
/// join can make a persona; it cannot make an invitation somebody else has
/// to have issued — and with no persona there is not even a DID one could
/// have been issued to.
#[test]
fn only_what_the_join_cannot_supply_is_blocked() {
let routes = build_routes("Kernel", &requirements(None), None, &[], &[]);
Expand All @@ -3815,14 +3876,14 @@ mod vetting_tests {
.unwrap()
.blocked()
.is_some(),
"an invitation is not something the join can produce"
"no persona means no subject an invitation could name"
);
assert!(
option_for(&routes, JoinRoute::Vetting)
.unwrap()
.first_step()
.is_some(),
"a persona is"
"a persona is something the join can make"
);
}

Expand Down
7 changes: 5 additions & 2 deletions openvtc/src/ui/pages/join_flow/vetting_requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ pub(crate) fn body_lines(state: &JoinState, view: &JoinVettingView) -> Vec<Line<
mod tests {
use super::*;
use crate::state_handler::join::{
AvailableVic, JoinApplication, JoinPage, JoinRoute, RouteOption, RouteState,
AvailableVic, FirstStepKind, JoinApplication, JoinPage, JoinRoute, RouteOption, RouteState,
};
use crate::state_handler::state::State;
use crate::ui::component::Component;
Expand Down Expand Up @@ -583,7 +583,10 @@ mod tests {
routes: vec![route(
JoinRoute::Vetting,
"Apply for vetting",
RouteState::FirstStep("you have no persona yet".into()),
RouteState::FirstStep {
note: "you have no persona yet".into(),
kind: FirstStepKind::CreatePersona,
},
)],
..KnownVetting::default()
}));
Expand Down
Loading