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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes

### Changed

- On a terminal, `voltage login` offers to save a profile pinned to an organization and environment, and the picker takes the only organization or environment without asking. A wallet is still always asked for. Nothing changes off a terminal, with `--json`, or with `--no-input`.
- `voltage login` under a saved name replaces that login when it is the same user at the same auth URL, and revokes the replaced session. A different user is still refused. With `--account NAME`, a name saved for an API key or for another auth URL is refused before the browser step. An expired or revoked login's hint is now only `voltage login --account NAME`. A saved login whose secret cannot be read is refused, also before the browser step, with a hint that names `voltage logout --local --account NAME`, and `logout --local` removes an account whose secret is already gone or cannot be removed, with a warning for the latter. Without `--credential-store`, a replaced login keeps its credential store, and a new login uses the keychain. `voltage login --json` reports `replaced: true` when the login replaced a saved one. When a replacement cannot be saved, the replaced login stays saved, and the error says so if it could not be restored.
- Human tables fit the terminal width, and all API text is scrubbed of terminal control characters.
- Each item of a string list gets its own line in human output.
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ voltage logout --local # removes local credentials only

A login grants the permissions your account already has. Profiles do not restrict them.

On a terminal, a login then offers to save a [profile](#profiles) pinned to an organization and environment, so later commands need only `--profile NAME`. It takes the organization and the environment when there is only one of each, and otherwise asks, as the [picker](#pick-the-scope-interactively) does. Then it asks for the profile's name (`default` when you press Enter). Esc skips the profile. When a profile already uses this login, or when stdout is not a terminal, as with `--json`, or with `--no-input`, nothing is offered. A failure while offering, such as a name that is already used, leaves the login saved and says how to create the profile later with `voltage profiles create`. Ctrl-C exits with code 130 and says that the login is saved. The profile is saved under the same lock as `voltage profiles create`, after the configuration is read again, so a change another command made while the prompt was open is kept. With table output, the result shows `profile` when one was saved.

Signing in again under a saved name replaces that login when it is the same user at the same auth URL, and revokes the replaced session. A different user, or a name used by an imported API key, is refused. With `--account NAME`, an API key or a login at another auth URL is refused before the browser step. Without `--credential-store`, the new login stays in the replaced login's credential store; a new name uses the keychain. With `--json`, the output has `replaced: true` when the login replaced a saved one.

## API keys
Expand Down Expand Up @@ -47,7 +49,7 @@ Only endpoints that filter by environment receive `--env`. Organization-wide com

## Pick the scope interactively

On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.
On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. When there is only one organization or environment, the CLI takes it without asking and says so. A wallet is always asked for, also when there is only one, because a payment spends from it. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.

```sh
voltage wallets list # asks for the organization
Expand Down
60 changes: 59 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,9 @@ pub struct SubmissionState {
/// Cleared only once the local side is done, never on drop: Ctrl-C drops the command
/// before the interruption is reported.
session: Mutex<Option<SessionChange>>,
/// The saved login whose profile offer is under way. Kept apart from `session`: the
/// offer's own refresh or token exchange sets and settles a session change of its own.
profile_offer: Mutex<Option<String>>,
}

/// A login change that the server and the saved credentials can disagree about.
Expand Down Expand Up @@ -1180,6 +1183,14 @@ impl SubmissionState {
*self.session.lock().unwrap_or_else(PoisonError::into_inner) = None;
}

/// Marks the profile offer after a login, which is saved by then, as under way or done.
pub(crate) fn offering_profile(&self, account: Option<&str>) {
*self
.profile_offer
.lock()
.unwrap_or_else(PoisonError::into_inner) = account.map(str::to_owned);
}

/// The error for Ctrl-C. It claims a write may continue only when one may have been sent,
/// and never claims that the write was cancelled. A sent write outranks a session change,
/// which always comes first.
Expand Down Expand Up @@ -1207,7 +1218,16 @@ impl SubmissionState {
"Interrupted while revoking the replaced session for {account}; the new login is saved"
))
.with_hint("Account global signout ends the replaced session if it is still active."),
None => Error::interrupted("Interrupted before any resource change was submitted"),
None => match &*self
.profile_offer
.lock()
.unwrap_or_else(PoisonError::into_inner)
{
Some(account) => profile_offer_interrupted(account),
None => {
Error::interrupted("Interrupted before any resource change was submitted")
}
},
};
};
let message = match write.resource_id {
Expand All @@ -1224,6 +1244,17 @@ impl SubmissionState {
}
}

/// Ctrl-C during the profile offer: the login was saved before the offer began.
pub(crate) fn profile_offer_interrupted(account: &str) -> Error {
let account = shell_word(account);
Error::interrupted(format!(
"Interrupted while saving a profile; the login for {account} is saved"
))
.with_hint(format!(
"Create one later with `voltage profiles create NAME --account {account}`."
))
}

/// A read can establish acceptance without sending the mutation again. A missing
/// projection remains uncertain and keeps the original recovery record.
async fn reconcile_payment(api: &Api, request: &Request, scope: &Scope) -> Option<Response> {
Expand Down Expand Up @@ -1981,6 +2012,33 @@ mod tests {
assert_eq!(detail["outcome"], "unknown");
}

#[test]
fn interruption_during_a_profile_offer_says_the_login_is_saved_until_it_ends() {
let submission = SubmissionState::default();
submission.offering_profile(Some("my team"));
// The offer's own token refresh settles without ending the offer.
submission.session_changing(SessionChange::Refresh {
account: "my team".into(),
});
submission.session_settled();
let during = submission.interrupted();
assert_eq!(
during.message,
"Interrupted while saving a profile; the login for 'my team' is saved"
);
assert_eq!(
during.hint.as_deref(),
Some("Create one later with `voltage profiles create NAME --account 'my team'`.")
);
assert!(during.detail.is_none());

submission.offering_profile(None);
assert_eq!(
submission.interrupted().message,
"Interrupted before any resource change was submitted"
);
}

#[test]
fn interruption_during_a_session_change_says_how_to_recover_until_it_settles() {
let submission = SubmissionState::default();
Expand Down
4 changes: 4 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ pub struct LoginOutcome {
pub authenticated: bool,
/// The login replaced a saved login of the same user under this name.
pub replaced: bool,
/// The profile saved for this login on a terminal, when one was.
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -777,6 +780,7 @@ async fn save_login(
email,
authenticated: true,
replaced: replaced.is_some(),
profile: None,
})
}

Expand Down
19 changes: 19 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,25 @@ impl Settings {
Ok(())
}

/// Save a new profile under the credential lock, after re-reading the configuration, so a
/// change another command made meanwhile is kept and a name it took is refused. `build`
/// makes the profile from the re-read settings.
pub async fn create_profile(
&mut self,
name: &str,
terminal: Terminal,
build: impl FnOnce(&Self) -> Result<Profile>,
) -> Result<()> {
let _lock = self.lock(terminal).await?;
self.reload()?;
if self.config.profiles.contains_key(name) {
return Err(Error::usage(format!("Profile {name} already exists")));
}
let profile = build(self)?;
self.config.profiles.insert(name.to_owned(), profile);
self.save()
}

pub fn save(&self) -> Result<()> {
private_dir(&self.dir)?;
let text = toml::to_string_pretty(&self.config)
Expand Down
121 changes: 114 additions & 7 deletions src/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
//! a person at a terminal picks one instead of copying a UUID. Scripts never see a prompt.

use crate::{
Result, api,
Error, Result, api,
api::{SubmissionState, profile_offer_interrupted},
auth::{ApiCredential, Discovery, Resolver},
cli::{BodySource, Command, GlobalFlags, LocalCommand, ProfileCommand},
config::{Credential, Scope},
config::{Credential, Profile, Scope, Settings, shell_word},
error::ErrorKind,
output::OutputFormat,
registry::{OperationId, ParameterLocation, ScopeParameter},
terminal::{Choice, Terminal},
Expand Down Expand Up @@ -114,6 +116,101 @@ pub fn can_ask(global: &GlobalFlags) -> bool {
global.output_format() == OutputFormat::Table && global.terminal().can_pick()
}

/// After `voltage login` on a terminal, offer to save a profile that pins the login to an
/// organization and environment, so later commands need only `--profile NAME`. Nothing is
/// offered off a terminal, or when a profile already uses this login. The login is already
/// saved, so a failure here is a notice and never fails the login. Ctrl-C ends the command,
/// and its error says that the login is saved.
pub async fn offer_profile(
settings: &mut Settings,
account: &str,
global: &GlobalFlags,
submission: &SubmissionState,
) -> Result<Option<String>> {
let used = settings
.config
.profiles
.values()
.any(|profile| profile.account == account);
if used || !can_ask(global) {
return Ok(None);
}
let terminal = global.terminal();
terminal.notice(
"Save a profile for this login, so later commands need only --profile NAME. Press Esc to skip.",
);
let later = format!(
"Create one later with `voltage profiles create NAME --account {}`.",
shell_word(account)
);
let skipped = |why: String| terminal.notice(format!("{why}{later}"));
// Ctrl-C from here on, at a prompt or during a request, reports the saved login.
submission.offering_profile(Some(account));
let pinned = pin(settings, account, global, submission).await;
submission.offering_profile(None);
match pinned {
Ok(Some(name)) => {
terminal.notice(format!(
"Saved profile {name}. Use it with --profile {name}."
));
Ok(Some(name))
}
Ok(None) => {
skipped("No profile was saved. ".into());
Ok(None)
}
Err(error) if error.kind == ErrorKind::Interrupted => {
Err(profile_offer_interrupted(account))
}
Err(error) => {
skipped(format!("No profile was saved: {}. ", error.message));
Ok(None)
}
}
}

/// Pick the organization and environment, taking each when it is the only one, then ask for
/// the profile's name and save it. `None` means Esc.
async fn pin(
settings: &mut Settings,
account: &str,
global: &GlobalFlags,
submission: &SubmissionState,
) -> Result<Option<String>> {
let mut scope = Scope {
account: Some(account.to_owned()),
..Scope::default()
};
let resolver = Resolver {
settings,
flags: global,
submission,
credential_sources: None,
};
ask(Needs::new(true, true, false), global, resolver, &mut scope).await?;
let (Some(organization_id), [environment_id]) = (scope.org, scope.envs.as_slice()) else {
return Ok(None);
};
let Some(name) = global.terminal().text("Profile name:", "default").await? else {
return Ok(None);
};
let name = name.trim().to_owned();
if name.is_empty() {
return Err(Error::usage("A profile name cannot be empty"));
}
let profile = Profile {
organization_id,
environment_id: *environment_id,
account: account.to_owned(),
};
// The prompts can stay open for a long time, so the configuration is re-read under the
// lock before the profile is added.
settings
.create_profile(&name, global.terminal(), |_| Ok(profile))
.await?;
Ok(Some(name))
}

/// The pickers themselves, with one credential resolution and at most one organization token
/// exchange. An API key cannot list organizations or environments, so only its wallets are
/// offered. Returns the flags for what was picked, which Esc or an empty list can cut short.
Expand All @@ -136,7 +233,8 @@ async fn ask(
let listed = resolver
.list(scope, Discovery::Organizations, &login.access_token)
.await?;
let Some(org) = choose(terminal, "Organization:", "organizations", &listed).await? else {
let Some(org) = choose(terminal, "Organization:", "organizations", &listed, true).await?
else {
return Ok(picked);
};
scope.org = Some(org);
Expand All @@ -152,15 +250,16 @@ async fn ask(
return Ok(picked);
};
let listed = resolver.list(scope, Discovery::Environments, token).await?;
let Some(env) = choose(terminal, "Environment:", "environments", &listed).await? else {
let Some(env) = choose(terminal, "Environment:", "environments", &listed, true).await?
else {
return Ok(picked);
};
scope.envs = vec![env];
picked.push(format!("--env {env}"));
}
if missing.wallet {
let listed = api::wallets(global, credential, resolver.credential_sources, scope).await?;
let Some(wallet) = choose(terminal, "Wallet:", "wallets", &listed).await? else {
let Some(wallet) = choose(terminal, "Wallet:", "wallets", &listed, false).await? else {
return Ok(picked);
};
scope.wallet = Some(wallet);
Expand All @@ -169,18 +268,26 @@ async fn ask(
Ok(picked)
}

/// Pick from a listing, or say why there is nothing to pick.
/// Pick from a listing, or say why there is nothing to pick. With `take_only`, the only
/// choice is taken without asking. A wallet is always asked for, because a payment spends
/// from it.
async fn choose(
terminal: Terminal,
question: &'static str,
plural: &str,
listed: &Value,
take_only: bool,
) -> Result<Option<Uuid>> {
let choices = choices(listed);
let mut choices = choices(listed);
if choices.is_empty() {
terminal.notice(format!("No {plural} to choose from."));
return Ok(None);
}
if take_only && choices.len() == 1 {
let only = choices.remove(0);
terminal.notice(format!("{question} {only} (the only one)"));
return Ok(Some(only.id()));
}
terminal.pick(question, choices).await
}

Expand Down
Loading
Loading