Skip to content

Commit 6fae0aa

Browse files
committed
feat: implement all command modules
Phase 3: Replace todo!() stubs with full implementations for all command modules - claims (get/merge/remove/clear/find), users (get/create/disable/enable/remove/list/list-inactive/count), links (password-reset/email-verify/sign-in), emulator (clear-users/config), info (connection display/verify), and config (init/add/remove/default/ list/show/which/path). Add time crate dependency and update error-stack to v0.6.
1 parent b2ef8e0 commit 6fae0aa

8 files changed

Lines changed: 1107 additions & 90 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ confy = "0.6"
1313
console = "0.15"
1414
csv = "1"
1515
dialoguer = "0.11"
16-
error-stack = "0.5"
16+
error-stack = "0.6"
1717
google-cloud-auth = "1"
1818
indicatif = "0.17"
1919
rand = "0.9"
@@ -22,6 +22,7 @@ rs-firebase-admin-sdk = { version = "4", default-features = false, features = ["
2222
serde = { version = "1", features = ["derive"] }
2323
serde_json = "1"
2424
shellexpand = "3"
25+
time = { version = "0.3.47", features = ["formatting", "parsing", "macros"] }
2526
tokio = { version = "1", features = ["full"] }
2627
toml = "0.8"
2728

src/commands/claims.rs

Lines changed: 277 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
use anyhow::Result;
1+
use std::collections::BTreeMap;
22

3+
use anyhow::{Result, bail};
4+
use indicatif::ProgressBar;
5+
use rs_firebase_admin_sdk::auth::{Claims, FirebaseAuthService, UserIdentifiers, UserUpdate};
6+
use serde_json::Value;
7+
8+
use crate::config::resolve_connection;
9+
use crate::firebase::{AuthBackend, init_firebase};
10+
use crate::output::{render_json_value, render_message, render_table};
11+
use crate::prompt::{confirm, resolve_email, resolve_string};
312
use crate::{Cli, ClaimsCommand};
413

514
pub async fn run(cli: &Cli, command: &ClaimsCommand) -> Result<()> {
@@ -18,32 +27,281 @@ pub async fn run(cli: &Cli, command: &ClaimsCommand) -> Result<()> {
1827
}
1928
}
2029

21-
async fn get(_cli: &Cli, _email: Option<String>) -> Result<()> {
22-
todo!()
30+
fn parse_claim_value(raw: &str) -> Value {
31+
if raw.starts_with('{') || raw.starts_with('[') {
32+
if let Ok(v) = serde_json::from_str(raw) {
33+
return v;
34+
}
35+
}
36+
if raw == "true" {
37+
return Value::Bool(true);
38+
}
39+
if raw == "false" {
40+
return Value::Bool(false);
41+
}
42+
if let Ok(i) = raw.parse::<i64>() {
43+
return Value::Number(i.into());
44+
}
45+
if let Ok(f) = raw.parse::<f64>() {
46+
if let Some(n) = serde_json::Number::from_f64(f) {
47+
return Value::Number(n);
48+
}
49+
}
50+
Value::String(raw.to_string())
51+
}
52+
53+
fn claims_to_map(claims: &Option<Claims>) -> BTreeMap<String, Value> {
54+
match claims {
55+
Some(c) => c.get().clone(),
56+
None => BTreeMap::new(),
57+
}
58+
}
59+
60+
fn map_to_json(map: &BTreeMap<String, Value>) -> Value {
61+
Value::Object(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
62+
}
63+
64+
fn map_to_claims(map: BTreeMap<String, Value>) -> Claims {
65+
Claims::from(map)
66+
}
67+
68+
macro_rules! fb_anyhow {
69+
($expr:expr) => {
70+
$expr.map_err(|e| anyhow::anyhow!("{e}"))
71+
};
72+
}
73+
74+
async fn get(cli: &Cli, email: Option<String>) -> Result<()> {
75+
let email = resolve_email(email)?;
76+
let conn = resolve_connection(
77+
&cli.profile,
78+
&cli.project,
79+
&cli.credentials,
80+
&cli.emulator_host,
81+
)?;
82+
let auth = init_firebase(AuthBackend::from_resolved(&conn)).await?;
83+
84+
let identifiers = UserIdentifiers::builder().with_email(email.clone()).build();
85+
let user = fb_anyhow!(auth.get_user(identifiers).await)?
86+
.ok_or_else(|| anyhow::anyhow!("User not found: {email}"))?;
87+
88+
let claims_map = claims_to_map(&user.custom_claims);
89+
if claims_map.is_empty() {
90+
render_message("No custom claims set");
91+
} else {
92+
render_json_value(&cli.format, &map_to_json(&claims_map));
93+
}
94+
95+
Ok(())
2396
}
2497

2598
async fn merge(
26-
_cli: &Cli,
27-
_key: Option<String>,
28-
_value: Option<String>,
29-
_email: Option<String>,
99+
cli: &Cli,
100+
key: Option<String>,
101+
value: Option<String>,
102+
email: Option<String>,
30103
) -> Result<()> {
31-
todo!()
104+
let email = resolve_email(email)?;
105+
let key = resolve_string(key, "Claim key")?;
106+
let raw_value = resolve_string(value, "Claim value")?;
107+
let parsed_value = parse_claim_value(&raw_value);
108+
109+
let conn = resolve_connection(
110+
&cli.profile,
111+
&cli.project,
112+
&cli.credentials,
113+
&cli.emulator_host,
114+
)?;
115+
let auth = init_firebase(AuthBackend::from_resolved(&conn)).await?;
116+
117+
let identifiers = UserIdentifiers::builder().with_email(email.clone()).build();
118+
let user = fb_anyhow!(auth.get_user(identifiers).await)?
119+
.ok_or_else(|| anyhow::anyhow!("User not found: {email}"))?;
120+
121+
let mut claims_map = claims_to_map(&user.custom_claims);
122+
claims_map.insert(key.clone(), parsed_value);
123+
124+
if cli.dry_run {
125+
render_message("Dry run — would set claims to:");
126+
render_json_value(&cli.format, &map_to_json(&claims_map));
127+
return Ok(());
128+
}
129+
130+
let update = UserUpdate::builder(user.uid)
131+
.custom_claims(map_to_claims(claims_map))
132+
.build();
133+
let updated_user = fb_anyhow!(auth.update_user(update).await)?;
134+
135+
let updated_map = claims_to_map(&updated_user.custom_claims);
136+
render_json_value(&cli.format, &map_to_json(&updated_map));
137+
138+
Ok(())
32139
}
33140

34-
async fn remove(_cli: &Cli, _key: Option<String>, _email: Option<String>) -> Result<()> {
35-
todo!()
141+
async fn remove(cli: &Cli, key: Option<String>, email: Option<String>) -> Result<()> {
142+
let email = resolve_email(email)?;
143+
let key = resolve_string(key, "Claim key")?;
144+
145+
let conn = resolve_connection(
146+
&cli.profile,
147+
&cli.project,
148+
&cli.credentials,
149+
&cli.emulator_host,
150+
)?;
151+
let auth = init_firebase(AuthBackend::from_resolved(&conn)).await?;
152+
153+
let identifiers = UserIdentifiers::builder().with_email(email.clone()).build();
154+
let user = fb_anyhow!(auth.get_user(identifiers).await)?
155+
.ok_or_else(|| anyhow::anyhow!("User not found: {email}"))?;
156+
157+
let mut claims_map = claims_to_map(&user.custom_claims);
158+
159+
if claims_map.remove(&key).is_none() {
160+
eprintln!("Claim key '{key}' not found");
161+
return Ok(());
162+
}
163+
164+
if cli.dry_run {
165+
render_message("Dry run — would set claims to:");
166+
render_json_value(&cli.format, &map_to_json(&claims_map));
167+
return Ok(());
168+
}
169+
170+
let update = UserUpdate::builder(user.uid)
171+
.custom_claims(map_to_claims(claims_map))
172+
.build();
173+
let updated_user = fb_anyhow!(auth.update_user(update).await)?;
174+
175+
let updated_map = claims_to_map(&updated_user.custom_claims);
176+
render_json_value(&cli.format, &map_to_json(&updated_map));
177+
178+
Ok(())
36179
}
37180

38-
async fn clear(_cli: &Cli, _email: Option<String>) -> Result<()> {
39-
todo!()
181+
async fn clear(cli: &Cli, email: Option<String>) -> Result<()> {
182+
let email = resolve_email(email)?;
183+
184+
if !confirm(
185+
&format!("Clear ALL custom claims for {email}?"),
186+
cli.yes,
187+
)? {
188+
bail!("Aborted");
189+
}
190+
191+
if cli.dry_run {
192+
render_message(&format!(
193+
"Dry run — would clear all custom claims for {email}"
194+
));
195+
return Ok(());
196+
}
197+
198+
let conn = resolve_connection(
199+
&cli.profile,
200+
&cli.project,
201+
&cli.credentials,
202+
&cli.emulator_host,
203+
)?;
204+
let auth = init_firebase(AuthBackend::from_resolved(&conn)).await?;
205+
206+
let identifiers = UserIdentifiers::builder().with_email(email.clone()).build();
207+
let user = fb_anyhow!(auth.get_user(identifiers).await)?
208+
.ok_or_else(|| anyhow::anyhow!("User not found: {email}"))?;
209+
210+
let update = UserUpdate::builder(user.uid)
211+
.custom_claims(map_to_claims(BTreeMap::new()))
212+
.build();
213+
fb_anyhow!(auth.update_user(update).await)?;
214+
215+
render_message(&format!("Cleared all custom claims for {email}"));
216+
217+
Ok(())
40218
}
41219

42-
async fn find(
43-
_cli: &Cli,
44-
_key: String,
45-
_value: Option<String>,
46-
_exclusive: bool,
47-
) -> Result<()> {
48-
todo!()
220+
async fn find(cli: &Cli, key: String, value: Option<String>, exclusive: bool) -> Result<()> {
221+
let conn = resolve_connection(
222+
&cli.profile,
223+
&cli.project,
224+
&cli.credentials,
225+
&cli.emulator_host,
226+
)?;
227+
let auth = init_firebase(AuthBackend::from_resolved(&conn)).await?;
228+
229+
let spinner = ProgressBar::new_spinner();
230+
spinner.set_message("Scanning users…");
231+
232+
let mut matching_rows: Vec<Vec<String>> = Vec::new();
233+
let mut prev_page: Option<_> = None;
234+
let target_value = value.as_deref().map(parse_claim_value);
235+
236+
loop {
237+
spinner.tick();
238+
239+
let page = fb_anyhow!(auth.list_users(1000, prev_page).await)?;
240+
241+
let user_list = match page {
242+
Some(list) => list,
243+
None => break,
244+
};
245+
246+
for user in &user_list.users {
247+
let claims = match &user.custom_claims {
248+
Some(c) => c,
249+
None => continue,
250+
};
251+
252+
let claims_map = claims.get();
253+
let claim_value = match claims_map.get(&key) {
254+
Some(v) => v,
255+
None => continue,
256+
};
257+
258+
if let Some(ref target) = target_value {
259+
if exclusive {
260+
if let Value::Array(arr) = claim_value {
261+
if arr.len() != 1 || arr[0] != *target {
262+
continue;
263+
}
264+
} else {
265+
continue;
266+
}
267+
} else if claim_value != target {
268+
if let Value::Array(arr) = claim_value {
269+
if !arr.contains(target) {
270+
continue;
271+
}
272+
} else {
273+
continue;
274+
}
275+
}
276+
}
277+
278+
matching_rows.push(vec![
279+
user.uid.clone(),
280+
user.email.clone().unwrap_or_default(),
281+
serde_json::to_string(claim_value).unwrap_or_default(),
282+
]);
283+
284+
spinner.set_message(format!("Scanning users… {} matches", matching_rows.len()));
285+
}
286+
287+
match user_list.next_page_token {
288+
Some(ref token) if !token.is_empty() => prev_page = Some(user_list),
289+
_ => break,
290+
}
291+
}
292+
293+
spinner.finish_and_clear();
294+
295+
if matching_rows.is_empty() {
296+
render_message("No matching users found");
297+
} else {
298+
render_message(&format!("Found {} matching user(s)", matching_rows.len()));
299+
render_table(
300+
&cli.format,
301+
&["UID", "Email", "Claims Value"],
302+
&matching_rows,
303+
);
304+
}
305+
306+
Ok(())
49307
}

0 commit comments

Comments
 (0)