[WIP] User Invites

This commit is contained in:
Alex Emmet 2026-09-10 23:14:25 +02:00
commit 7164f4671d
No known key found for this signature in database
24 changed files with 2829 additions and 170 deletions

View file

@ -484,6 +484,38 @@ impl IpcClient {
.join("\n")
}
}
ResponsePayload::InvitationCreated(invitation) => format!(
"Created {:?} invitation {}\nToken: {}\nExpires: {}{}",
invitation.authority,
invitation.invitation_id,
invitation.raw_token.0,
invitation.expires_at,
invitation
.short_url
.as_ref()
.map(|url| format!("\nLink: {url}"))
.unwrap_or_default(),
),
ResponsePayload::Invitations(invitations) => {
if invitations.is_empty() {
"No invitations.".into()
} else {
invitations
.iter()
.map(|invitation| {
format!(
"{} ({:?}, {:?})",
invitation.invitation_id, invitation.authority, invitation.state
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
ResponsePayload::InvitationUpdated(invitation) => format!(
"Invitation {} is {:?}",
invitation.invitation_id, invitation.state
),
ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id)
}
@ -588,6 +620,7 @@ impl IpcClient {
iota_ipc::IpcErrorCode::OmikronUnavailable => {
"Omikron is unavailable; try reconnecting."
}
iota_ipc::IpcErrorCode::Unsupported => "The daemon does not implement this operation.",
iota_ipc::IpcErrorCode::UnsupportedVersion => {
"CLI and daemon versions are incompatible."
}

View file

@ -25,6 +25,7 @@ pub enum AddUserPhase {
pub struct AddUserFlow {
pub phase: AddUserPhase,
pub methods: MenuState<AddUserMethod>,
pub invitation_authorities: MenuState<iota_ipc::InvitationAuthority>,
pub username: TextInput,
pub import_path: TextInput,
pub credential: Option<iota_ipc::SecretString>,
@ -39,7 +40,7 @@ impl AddUserFlow {
methods: MenuState::new(vec![
MenuItem {
label: "Share an invitation".into(),
description: Some("Not supported by the connected daemon".into()),
description: Some("Create a single-use onboarding credential".into()),
value: AddUserMethod::Invitation,
enabled: invitation_supported,
disabled_reason: Some("Not supported by the connected daemon".into()),
@ -60,6 +61,22 @@ impl AddUserFlow {
.then(|| "TU inspection is not supported by the connected daemon".into()),
},
]),
invitation_authorities: MenuState::new(vec![
MenuItem {
label: "Omega".into(),
description: Some("Central invitation authority".into()),
value: iota_ipc::InvitationAuthority::Omega,
enabled: true,
disabled_reason: None,
},
MenuItem {
label: "This Iota".into(),
description: Some("Local invitation authority".into()),
value: iota_ipc::InvitationAuthority::Iota,
enabled: false,
disabled_reason: Some("Local invitations are not implemented yet".into()),
},
]),
username: TextInput::new("Username"),
import_path: TextInput::new("TU path"),
credential: None,

View file

@ -399,6 +399,42 @@ impl UsersScreen {
}),
}
}
fn start_invitation(&mut self, authority: iota_ipc::InvitationAuthority) -> InteractionResult {
self.pending = true;
self.message = Some("Creating invitation…".into());
self.overlay = None;
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::CreateInvitation {
authority,
lifetime_seconds: 7 * 24 * 60 * 60,
password: None,
label: None,
})
.await
{
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::InvitationCreated(invitation),
)) => Ok(format!(
"Invitation {} created. Token: {} Link: {}",
invitation.invitation_id,
invitation.raw_token.0,
invitation.short_url.as_deref().unwrap_or("unavailable")
)),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Create invitation failed: {error}"))
}
Ok(_) => Err(
"Create invitation failed: daemon returned an unexpected response.".into(),
),
Err(error) => Err(format!("Create invitation failed: {error}")),
};
UiEvent::App(AppEvent::UserOperationFinished(result))
}),
}
}
fn refresh(&self) -> InteractionResult {
let ipc = self.ipc.clone();
InteractionResult::AppTask {
@ -649,6 +685,33 @@ impl UsersScreen {
inner,
);
}
AddUserPhase::ConfigureInvitation => {
let lines = flow
.invitation_authorities
.items()
.iter()
.enumerate()
.map(|(index, item)| {
Line::from(format!(
"{} {}{}",
if flow.invitation_authorities.selected_index() == Some(index) {
">"
} else {
" "
},
item.label,
item.disabled_reason
.as_ref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default()
))
})
.chain(std::iter::once(Line::from(
"\nEnter creates a single-use invitation valid for one week.",
)))
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), inner);
}
AddUserPhase::InspectingTu => {
frame.render_widget(Paragraph::new("Inspecting credential…"), inner);
}
@ -991,6 +1054,29 @@ impl Screen for UsersScreen {
InteractionResult::Handled
}
},
AddUserPhase::ConfigureInvitation => {
if flow.invitation_authorities.handle_key(key.code) {
return InteractionResult::Handled;
}
match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ChooseMethod;
InteractionResult::Handled
}
KeyCode::Enter => {
let authority = flow
.invitation_authorities
.selected_item()
.map(|item| item.value);
if let Some(authority) = authority {
self.start_invitation(authority)
} else {
InteractionResult::Handled
}
}
_ => InteractionResult::Handled,
}
}
AddUserPhase::ReviewImport => match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ConfigureImport;