1use crate::cli::{
7 config::{CliConfiguration, Profile, ProfileAuth, write_config},
8 secrets,
9 state::{CONFIG_LOCATION, CliState, SECRETS_LOCATION},
10};
11use clap::{Subcommand, ValueEnum};
12use dialoguer::{Confirm, Select};
13use dialoguer::{Input, Password, theme::ColorfulTheme};
14use haste_fhir_model::r4::generated::terminology::IssueType;
15use haste_fhir_operation_error::OperationOutcomeError;
16use std::sync::Arc;
17use tokio::sync::Mutex;
18
19#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
21pub(crate) enum AuthModeChoice {
22 ClientCredentials,
24 AuthorizationCode,
27 BasicAuth,
29}
30
31#[derive(Subcommand, Debug)]
33pub(crate) enum ConfigCommands {
34 ShowProfile,
36 CreateProfile {
39 #[arg(short, long)]
41 name: Option<String>,
42 #[arg(short, long)]
44 r4_url: Option<String>,
45 #[arg(short, long)]
47 discovery_uri: Option<String>,
48 #[arg(long, value_enum)]
50 auth_mode: Option<AuthModeChoice>,
51 #[arg(short, long)]
53 id: Option<String>,
54 #[arg(short, long)]
57 secret: Option<String>,
58 #[arg(long)]
60 redirect_uri: Option<String>,
61 #[arg(long)]
63 scope: Option<String>,
64 },
65 DeleteProfile {
67 #[arg(short, long)]
69 name: Option<String>,
70 #[arg(short, long)]
72 confirm: Option<bool>,
73 },
74 SetActiveProfile {
76 #[arg(short, long)]
78 name: Option<String>,
79 },
80}
81
82fn persist(config: &CliConfiguration) -> Result<(), OperationOutcomeError> {
83 write_config(&CONFIG_LOCATION, config)
84}
85
86fn select_profile_name(state: &CliState, prompt: &str) -> Result<String, OperationOutcomeError> {
87 let profile_names = state
88 .config
89 .profiles
90 .iter()
91 .map(|profile| profile.name.as_str())
92 .collect::<Vec<_>>();
93
94 if profile_names.is_empty() {
95 return Err(OperationOutcomeError::error(
96 IssueType::exception(),
97 "No profiles available.".to_string(),
98 ));
99 }
100
101 let active_profile_index = state
102 .config
103 .active_profile
104 .as_ref()
105 .and_then(|active_name| profile_names.iter().position(|&name| name == active_name))
106 .unwrap_or(0);
107
108 let selection = Select::with_theme(&ColorfulTheme::default())
109 .with_prompt(prompt)
110 .items(&profile_names)
111 .default(active_profile_index)
112 .interact()
113 .unwrap();
114
115 Ok(profile_names[selection].to_string())
116}
117
118pub(crate) async fn run(
120 state: Arc<Mutex<CliState>>,
121 command: &ConfigCommands,
122) -> Result<(), OperationOutcomeError> {
123 match command {
124 ConfigCommands::ShowProfile => {
125 let state = state.lock().await;
126 if let Some(active_profile) = state.config.current_profile() {
127 println!("{:#?}", active_profile);
128 } else {
129 println!("No active profile set.");
130 }
131
132 Ok(())
133 }
134 ConfigCommands::CreateProfile {
135 name,
136 r4_url,
137 discovery_uri,
138 auth_mode,
139 id,
140 secret,
141 redirect_uri,
142 scope,
143 } => {
144 let name: String = if let Some(name) = name {
145 name.clone()
146 } else {
147 Input::with_theme(&ColorfulTheme::default())
148 .with_prompt("Profile Name")
149 .interact_text()
150 .unwrap()
151 };
152
153 let r4_url: String = if let Some(r4_url) = r4_url {
154 r4_url.clone()
155 } else {
156 Input::with_theme(&ColorfulTheme::default())
157 .with_prompt("FHIR R4 Server URL")
158 .interact_text()
159 .unwrap()
160 };
161
162 let oidc_discovery_uri: String = if let Some(discovery_uri) = discovery_uri {
163 discovery_uri.clone()
164 } else {
165 Input::with_theme(&ColorfulTheme::default())
166 .with_prompt("OIDC Discovery URI")
167 .interact_text()
168 .unwrap()
169 };
170
171 let client_id: String = if let Some(id) = id {
172 id.clone()
173 } else {
174 Input::with_theme(&ColorfulTheme::default())
175 .with_prompt("OIDC Client ID")
176 .interact_text()
177 .unwrap()
178 };
179
180 let auth_mode: AuthModeChoice = match auth_mode {
181 Some(mode) => mode.clone(),
182 None => {
183 let options = ["Authorization Code (browser login)", "Client Credentials"];
184 let selection = Select::with_theme(&ColorfulTheme::default())
185 .with_prompt("Auth Mode")
186 .items(&options)
187 .default(0)
188 .interact()
189 .unwrap();
190
191 match selection {
192 1 => AuthModeChoice::ClientCredentials,
193 _ => AuthModeChoice::AuthorizationCode,
194 }
195 }
196 };
197
198 let (auth, client_secret) = match auth_mode {
199 AuthModeChoice::BasicAuth => {
200 let password: String = if let Some(secret) = secret {
201 secret.clone()
202 } else {
203 Password::with_theme(&ColorfulTheme::default())
204 .with_prompt("Password")
205 .interact()
206 .unwrap()
207 };
208
209 (
210 ProfileAuth::Basic {
211 username: client_id.clone(),
212 },
213 Some(password),
214 )
215 }
216 AuthModeChoice::ClientCredentials => {
217 let client_secret: String = if let Some(secret) = secret {
218 secret.clone()
219 } else {
220 Password::with_theme(&ColorfulTheme::default())
221 .with_prompt("OIDC Client Secret")
222 .interact()
223 .unwrap()
224 };
225
226 (
227 ProfileAuth::ClientCredentails {
228 client_id: client_id.clone(),
229 },
230 Some(client_secret),
231 )
232 }
233 AuthModeChoice::AuthorizationCode => {
234 let redirect_uri: String = if let Some(redirect_uri) = redirect_uri {
235 redirect_uri.clone()
236 } else {
237 Input::with_theme(&ColorfulTheme::default())
238 .with_prompt("Loopback Redirect URI")
239 .default("http://127.0.0.1:8976/callback".to_string())
240 .interact_text()
241 .unwrap()
242 };
243
244 let scope: String = if let Some(scope) = scope {
245 scope.clone()
246 } else {
247 Input::with_theme(&ColorfulTheme::default())
248 .with_prompt("OAuth Scope")
249 .default("openid profile fhirUser offline_access user/*.*".to_string())
250 .interact_text()
251 .unwrap()
252 };
253
254 (
255 ProfileAuth::AuthorizationCode {
256 client_id: client_id.clone(),
257 redirect_uri,
258 scope,
259 },
260 None,
261 )
262 }
263 };
264
265 let mut state = state.lock().await;
266 if state
267 .config
268 .profiles
269 .iter()
270 .any(|profile| profile.name == *name)
271 {
272 return Err(OperationOutcomeError::error(
273 IssueType::exception(),
274 format!("Profile with name '{}' already exists", name),
275 ));
276 }
277
278 let profile = Profile {
279 name: name.clone(),
280 r4_url: r4_url.clone(),
281 oidc_discovery_uri: oidc_discovery_uri.clone(),
282 auth,
283 };
284
285 state.config.profiles.push(profile);
286 state.config.active_profile = Some(name.clone());
287
288 if let Some(client_secret) = client_secret {
289 state.secrets.profile_mut(&name).client_secret = Some(client_secret);
290 secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
291 }
292
293 persist(&state.config)
294 }
295 ConfigCommands::DeleteProfile { name, confirm } => {
296 let name: String = if let Some(name) = name {
297 name.clone()
298 } else {
299 let state = state.lock().await;
300 select_profile_name(&state, "Choose a profile to delete")?
301 };
302
303 let confirmed = if let Some(confirm) = confirm {
304 *confirm
305 } else {
306 Confirm::with_theme(&ColorfulTheme::default())
307 .with_prompt(format!(
308 "Are you sure you want to delete the profile '{}'? ",
309 name
310 ))
311 .interact()
312 .unwrap_or(false)
313 };
314
315 if !confirmed {
316 println!("Profile deletion cancelled.");
317 return Ok(());
318 }
319
320 let mut state = state.lock().await;
321 state
322 .config
323 .profiles
324 .retain(|profile| profile.name != *name);
325 state.secrets.remove_profile(&name);
326
327 secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
328 persist(&state.config)
329 }
330 ConfigCommands::SetActiveProfile { name } => {
331 let mut state = state.lock().await;
332 let name: String = if let Some(name) = name {
333 name.clone()
334 } else {
335 select_profile_name(&state, "Choose a profile to set as active")?
336 };
337
338 if !state
339 .config
340 .profiles
341 .iter()
342 .any(|profile| profile.name == name)
343 {
344 return Err(OperationOutcomeError::error(
345 IssueType::exception(),
346 format!("Profile with name '{}' does not exist", name),
347 ));
348 }
349
350 state.config.active_profile = Some(name.to_string());
351
352 persist(&state.config)
353 }
354 }
355}