Skip to content

Commit

Permalink
Merge pull request #667 from thoth-pub/feature/publihser_account_cli_…
Browse files Browse the repository at this point in the history
…helpers

Feature/publihser account cli helpers
  • Loading branch information
ja573 authored Jan 28, 2025
2 parents 5257fa6 + 1cbe812 commit 9f1c1e2
Show file tree
Hide file tree
Showing 12 changed files with 652 additions and 485 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/run_migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ on:
- '**up.sql'
- '**down.sql'
- '**db.rs'
- 'src/bin/**'
pull_request:
paths:
- '**up.sql'
- '**down.sql'
- '**db.rs'
- 'src/bin/**'
workflow_dispatch:

env:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- [667](https://github.com/thoth-pub/thoth/pull/667) - Refactor binary using new submodules `commands` and `arguments`
- [667](https://github.com/thoth-pub/thoth/pull/667) - Trigger `run\_migrations` github action when binary source changes

### Added
- [667](https://github.com/thoth-pub/thoth/pull/667) - CLI subcommand `thoth account publishers` to modify which publisher(s) an account has access to

## [[0.13.5]](https://github.com/thoth-pub/thoth/releases/tag/v0.13.5) - 2025-01-17
### Changed
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ thoth-export-server = { version = "=0.13.5", path = "thoth-export-server" }
clap = { version = "4.5.21", features = ["cargo", "env"] }
dialoguer = { version = "0.11.0", features = ["password"] }
dotenv = "0.15.0"
lazy_static = "1.5.0"
tokio = { version = "1.43.0", features = ["rt", "rt-multi-thread", "macros"] }
140 changes: 140 additions & 0 deletions src/bin/arguments/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use clap::{value_parser, Arg, ArgAction};

pub fn database() -> Arg {
Arg::new("db")
.short('D')
.long("database-url")
.value_name("DATABASE_URL")
.env("DATABASE_URL")
.help("Full postgres database url, e.g. postgres://thoth:thoth@localhost/thoth")
.num_args(1)
}

pub fn redis() -> Arg {
Arg::new("redis")
.short('R')
.long("redis-url")
.value_name("REDIS_URL")
.env("REDIS_URL")
.help("Full redis url, e.g. redis://localhost:6379")
.num_args(1)
}

pub fn host(env_value: &'static str) -> Arg {
Arg::new("host")
.short('H')
.long("host")
.value_name("HOST")
.env(env_value)
.default_value("0.0.0.0")
.help("host to bind")
.num_args(1)
}

pub fn port(default_value: &'static str, env_value: &'static str) -> Arg {
Arg::new("port")
.short('p')
.long("port")
.value_name("PORT")
.env(env_value)
.default_value(default_value)
.help("Port to bind")
.num_args(1)
}

pub fn domain() -> Arg {
Arg::new("domain")
.short('d')
.long("domain")
.value_name("THOTH_DOMAIN")
.env("THOTH_DOMAIN")
.default_value("localhost")
.help("Authentication cookie domain")
.num_args(1)
}

pub fn key() -> Arg {
Arg::new("key")
.short('k')
.long("secret-key")
.value_name("SECRET")
.env("SECRET_KEY")
.help("Authentication cookie secret key")
.num_args(1)
}

pub fn session() -> Arg {
Arg::new("duration")
.short('s')
.long("session-length")
.value_name("DURATION")
.env("SESSION_DURATION_SECONDS")
.default_value("3600")
.help("Authentication cookie session duration (seconds)")
.num_args(1)
.value_parser(value_parser!(i64))
}

pub fn gql_url() -> Arg {
Arg::new("gql-url")
.short('u')
.long("gql-url")
.value_name("THOTH_GRAPHQL_API")
.env("THOTH_GRAPHQL_API")
.default_value("http://localhost:8000")
.help("Thoth GraphQL's, public facing, root URL.")
.num_args(1)
}

pub fn gql_endpoint() -> Arg {
Arg::new("gql-endpoint")
.short('g')
.long("gql-endpoint")
.value_name("THOTH_GRAPHQL_ENDPOINT")
.env("THOTH_GRAPHQL_ENDPOINT")
.default_value("http://localhost:8000/graphql")
.help("Thoth GraphQL's endpoint")
.num_args(1)
}

pub fn export_url() -> Arg {
Arg::new("export-url")
.short('u')
.long("export-url")
.value_name("THOTH_EXPORT_API")
.env("THOTH_EXPORT_API")
.default_value("http://localhost:8181")
.help("Thoth Export API's, public facing, root URL.")
.num_args(1)
}

pub fn threads(env_value: &'static str) -> Arg {
Arg::new("threads")
.short('t')
.long("threads")
.value_name("THREADS")
.env(env_value)
.default_value("5")
.help("Number of HTTP workers to start")
.num_args(1)
.value_parser(value_parser!(usize))
}

pub fn keep_alive(env_value: &'static str) -> Arg {
Arg::new("keep-alive")
.short('K')
.long("keep-alive")
.value_name("THREADS")
.env(env_value)
.default_value("5")
.help("Number of seconds to wait for subsequent requests")
.num_args(1)
.value_parser(value_parser!(u64))
}

pub fn revert() -> Arg {
Arg::new("revert")
.long("revert")
.help("Revert all database migrations")
.action(ArgAction::SetTrue)
}
175 changes: 175 additions & 0 deletions src/bin/commands/account.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
use super::get_pg_pool;
use crate::arguments;
use clap::Command;
use dialoguer::{console::Term, theme::ColorfulTheme, Input, MultiSelect, Password, Select};
use lazy_static::lazy_static;
use std::collections::HashSet;
use thoth::{
api::{
account::{
model::{Account, LinkedPublisher},
service::{
all_emails, all_publishers, get_account, register as register_account,
update_password,
},
},
db::PgPool,
},
errors::{ThothError, ThothResult},
};

lazy_static! {
pub(crate) static ref COMMAND: Command = Command::new("account")
.about("Manage user accounts")
.arg(arguments::database())
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(Command::new("register").about("Create a new user account"))
.subcommand(
Command::new("publishers").about("Select which publisher(s) this account can manage"),
)
.subcommand(Command::new("password").about("Reset a password"));
}

pub fn register(arguments: &clap::ArgMatches) -> ThothResult<()> {
let pool = get_pg_pool(arguments);

let name = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter given name")
.interact_on(&Term::stdout())?;
let surname = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter family name")
.interact_on(&Term::stdout())?;
let email = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter email address")
.interact_on(&Term::stdout())?;
let password = password_input()?;
let is_superuser: bool = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Is this a superuser account")
.default(false)
.interact_on(&Term::stdout())?;
let is_bot: bool = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Is this a bot account")
.default(false)
.interact_on(&Term::stdout())?;

let account = register_account(&pool, name, surname, email, password, is_superuser, is_bot)?;
select_and_link_publishers(&pool, &account)
}

pub fn publishers(arguments: &clap::ArgMatches) -> ThothResult<()> {
let pool = get_pg_pool(arguments);
let account = email_selection(&pool).and_then(|email| get_account(&email, &pool))?;
select_and_link_publishers(&pool, &account)
}

pub fn password(arguments: &clap::ArgMatches) -> ThothResult<()> {
let pool = get_pg_pool(arguments);
let email = email_selection(&pool)?;
let password = password_input()?;

update_password(&email, &password, &pool).map(|_| ())
}

fn email_selection(pool: &PgPool) -> ThothResult<String> {
let all_emails = all_emails(pool).expect("No user accounts present in database.");
let email_labels: Vec<String> = all_emails
.iter()
.map(|(email, is_superuser, is_bot, is_active)| {
let mut label = email.clone();
if *is_superuser {
label.push_str(" 👑");
}
if *is_bot {
label.push_str(" 🤖");
}
if !is_active {
label.push_str(" ❌");
}
label
})
.collect();
let email_selection = Select::with_theme(&ColorfulTheme::default())
.items(&email_labels)
.default(0)
.with_prompt("Select a user account")
.interact_on(&Term::stdout())?;
all_emails
.get(email_selection)
.map(|(email, _, _, _)| email.clone())
.ok_or_else(|| ThothError::InternalError("Invalid user selection".into()))
}

fn password_input() -> ThothResult<String> {
Password::with_theme(&ColorfulTheme::default())
.with_prompt("Enter password")
.with_confirmation("Confirm password", "Passwords do not match")
.interact_on(&Term::stdout())
.map_err(Into::into)
}

fn is_admin_input(publisher_name: &str) -> ThothResult<bool> {
Input::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Make user an admin of '{}'?", publisher_name))
.default(false)
.interact_on(&Term::stdout())
.map_err(Into::into)
}

fn select_and_link_publishers(pool: &PgPool, account: &Account) -> ThothResult<()> {
let publishers = all_publishers(pool)?;
let publisher_accounts = account.get_publisher_accounts(pool)?;
let current_ids: HashSet<(_, _)> = publisher_accounts
.iter()
.map(|pa| (pa.publisher_id, pa.is_admin))
.collect();

let items_checked: Vec<(_, _)> = publishers
.iter()
.map(|p| {
let is_admin = current_ids
.iter()
.find(|(id, _)| *id == p.publisher_id)
.is_some_and(|(_, admin)| *admin);
let is_linked = current_ids.iter().any(|(id, _)| *id == p.publisher_id);
let mut publisher = p.clone();
if is_admin {
publisher.publisher_name = format!("{} 🔑", publisher.publisher_name);
}
(publisher, is_linked)
})
.collect();

let chosen: Vec<usize> = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select publishers to link this account to")
.items_checked(&items_checked)
.interact_on(&Term::stdout())?;
let chosen_ids: HashSet<_> = chosen
.iter()
.map(|&index| items_checked[index].0.publisher_id)
.collect();
let to_add: Vec<_> = publishers
.iter()
.filter(|p| {
chosen_ids.contains(&p.publisher_id)
&& !current_ids.iter().any(|(id, _)| id == &p.publisher_id)
})
.collect();
let to_remove: Vec<_> = publisher_accounts
.iter()
.filter(|pa| !chosen_ids.contains(&pa.publisher_id))
.collect();

for publisher in to_add {
let is_admin: bool = is_admin_input(&publisher.publisher_name)?;
let linked_publisher = LinkedPublisher {
publisher_id: publisher.publisher_id,
is_admin,
};
account.add_publisher_account(pool, linked_publisher)?;
}
for publisher_account in to_remove {
publisher_account.delete(pool)?;
}
Ok(())
}
Loading

0 comments on commit 9f1c1e2

Please sign in to comment.