Skip to main content

dsc/commands/
user.rs

1use crate::api::{DiscourseClient, UserAction};
2use crate::cli::ListFormat;
3use crate::commands::common::{ensure_api_credentials, select_discourse};
4use crate::config::Config;
5use crate::utils::{normalize_baseurl, parse_since_cutoff};
6use anyhow::{Context, Result, anyhow};
7use std::io::{self, Read};
8
9pub fn user_list(
10    config: &Config,
11    discourse_name: &str,
12    listing: &str,
13    page: u32,
14    format: ListFormat,
15) -> Result<()> {
16    let discourse = select_discourse(config, Some(discourse_name))?;
17    ensure_api_credentials(discourse)?;
18    let client = DiscourseClient::new(discourse)?;
19    let users = client.admin_list_users(listing, page)?;
20
21    match format {
22        ListFormat::Text => {
23            if users.is_empty() {
24                println!("No users found in listing '{}'.", listing);
25                return Ok(());
26            }
27            let name_width = users
28                .iter()
29                .map(|u| u.username.len())
30                .max()
31                .unwrap_or(0)
32                .max(8);
33            for u in &users {
34                let flag = if u.admin.unwrap_or(false) {
35                    "admin"
36                } else if u.moderator.unwrap_or(false) {
37                    "mod"
38                } else if u.suspended.unwrap_or(false) {
39                    "suspended"
40                } else if u.silenced.unwrap_or(false) {
41                    "silenced"
42                } else {
43                    "-"
44                };
45                let tl = u
46                    .trust_level
47                    .map(|t| t.to_string())
48                    .unwrap_or_else(|| "?".to_string());
49                println!(
50                    "{:<width$}  id:{}  tl:{}  {}",
51                    u.username,
52                    u.id,
53                    tl,
54                    flag,
55                    width = name_width
56                );
57            }
58        }
59        ListFormat::Json => {
60            println!("{}", serde_json::to_string_pretty(&users)?);
61        }
62        ListFormat::Yaml => {
63            println!("{}", serde_yaml::to_string(&users)?);
64        }
65    }
66
67    Ok(())
68}
69
70pub fn user_info(
71    config: &Config,
72    discourse_name: &str,
73    username: &str,
74    format: ListFormat,
75) -> Result<()> {
76    let discourse = select_discourse(config, Some(discourse_name))?;
77    ensure_api_credentials(discourse)?;
78    let client = DiscourseClient::new(discourse)?;
79    let detail = client.fetch_user_detail(username)?;
80
81    match format {
82        ListFormat::Text => {
83            println!("id:          {}", detail.id);
84            println!("username:    {}", detail.username);
85            if let Some(name) = &detail.name {
86                println!("name:        {}", name);
87            }
88            if let Some(email) = &detail.email {
89                println!("email:       {}", email);
90            }
91            if let Some(tl) = detail.trust_level {
92                println!("trust_level: {}", tl);
93            }
94            if detail.admin.unwrap_or(false) {
95                println!("role:        admin");
96            } else if detail.moderator.unwrap_or(false) {
97                println!("role:        moderator");
98            }
99            if let Some(until) = &detail.suspended_till {
100                println!("suspended:   until {}", until);
101            }
102            if let Some(until) = &detail.silenced_till {
103                println!("silenced:    until {}", until);
104            }
105            if let Some(last) = &detail.last_seen_at {
106                println!("last_seen:   {}", last);
107            }
108            if let Some(created) = &detail.created_at {
109                println!("created:     {}", created);
110            }
111            if let Some(posts) = detail.post_count {
112                println!("posts:       {}", posts);
113            }
114            if !detail.groups.is_empty() {
115                println!("groups:      {}", detail.groups.len());
116            }
117        }
118        ListFormat::Json => {
119            println!("{}", serde_json::to_string_pretty(&detail)?);
120        }
121        ListFormat::Yaml => {
122            println!("{}", serde_yaml::to_string(&detail)?);
123        }
124    }
125    Ok(())
126}
127
128pub fn user_suspend(
129    config: &Config,
130    discourse_name: &str,
131    username: &str,
132    until: &str,
133    reason: &str,
134    dry_run: bool,
135) -> Result<()> {
136    let discourse = select_discourse(config, Some(discourse_name))?;
137    ensure_api_credentials(discourse)?;
138    let client = DiscourseClient::new(discourse)?;
139
140    if dry_run {
141        println!(
142            "[dry-run] {}: would suspend {} until {} (reason: {})",
143            discourse.name,
144            username,
145            until,
146            if reason.is_empty() { "<none>" } else { reason }
147        );
148        return Ok(());
149    }
150
151    let detail = client.fetch_user_detail(username)?;
152    client.suspend_user(detail.id, until, reason)?;
153    println!("Suspended {} (id:{}) until {}", detail.username, detail.id, until);
154    Ok(())
155}
156
157pub fn user_unsuspend(
158    config: &Config,
159    discourse_name: &str,
160    username: &str,
161    dry_run: bool,
162) -> Result<()> {
163    let discourse = select_discourse(config, Some(discourse_name))?;
164    ensure_api_credentials(discourse)?;
165    let client = DiscourseClient::new(discourse)?;
166
167    if dry_run {
168        println!("[dry-run] {}: would unsuspend {}", discourse.name, username);
169        return Ok(());
170    }
171
172    let detail = client.fetch_user_detail(username)?;
173    client.unsuspend_user(detail.id)?;
174    println!("Unsuspended {} (id:{})", detail.username, detail.id);
175    Ok(())
176}
177
178pub fn user_silence(
179    config: &Config,
180    discourse_name: &str,
181    username: &str,
182    until: &str,
183    reason: &str,
184    dry_run: bool,
185) -> Result<()> {
186    let discourse = select_discourse(config, Some(discourse_name))?;
187    ensure_api_credentials(discourse)?;
188    let client = DiscourseClient::new(discourse)?;
189
190    if dry_run {
191        println!(
192            "[dry-run] {}: would silence {}{}{}",
193            discourse.name,
194            username,
195            if until.is_empty() {
196                String::new()
197            } else {
198                format!(" until {}", until)
199            },
200            if reason.is_empty() {
201                String::new()
202            } else {
203                format!(" (reason: {})", reason)
204            },
205        );
206        return Ok(());
207    }
208
209    let detail = client.fetch_user_detail(username)?;
210    client.silence_user(detail.id, until, reason)?;
211    println!("Silenced {} (id:{})", detail.username, detail.id);
212    Ok(())
213}
214
215pub fn user_unsilence(
216    config: &Config,
217    discourse_name: &str,
218    username: &str,
219    dry_run: bool,
220) -> Result<()> {
221    let discourse = select_discourse(config, Some(discourse_name))?;
222    ensure_api_credentials(discourse)?;
223    let client = DiscourseClient::new(discourse)?;
224
225    if dry_run {
226        println!("[dry-run] {}: would unsilence {}", discourse.name, username);
227        return Ok(());
228    }
229
230    let detail = client.fetch_user_detail(username)?;
231    client.unsilence_user(detail.id)?;
232    println!("Unsilenced {} (id:{})", detail.username, detail.id);
233    Ok(())
234}
235
236#[derive(Clone, Copy)]
237pub enum Role {
238    Admin,
239    Moderator,
240}
241
242pub fn user_promote(
243    config: &Config,
244    discourse_name: &str,
245    username: &str,
246    role: Role,
247    dry_run: bool,
248) -> Result<()> {
249    let discourse = select_discourse(config, Some(discourse_name))?;
250    ensure_api_credentials(discourse)?;
251    let client = DiscourseClient::new(discourse)?;
252
253    let role_label = match role {
254        Role::Admin => "admin",
255        Role::Moderator => "moderator",
256    };
257
258    if dry_run {
259        println!(
260            "[dry-run] {}: would grant {} to {}",
261            discourse.name, role_label, username
262        );
263        return Ok(());
264    }
265
266    let detail = client.fetch_user_detail(username)?;
267    match role {
268        Role::Admin => client.grant_admin(detail.id)?,
269        Role::Moderator => client.grant_moderation(detail.id)?,
270    }
271    println!("Granted {} to {} (id:{})", role_label, detail.username, detail.id);
272    Ok(())
273}
274
275pub fn user_demote(
276    config: &Config,
277    discourse_name: &str,
278    username: &str,
279    role: Role,
280    dry_run: bool,
281) -> Result<()> {
282    let discourse = select_discourse(config, Some(discourse_name))?;
283    ensure_api_credentials(discourse)?;
284    let client = DiscourseClient::new(discourse)?;
285
286    let role_label = match role {
287        Role::Admin => "admin",
288        Role::Moderator => "moderator",
289    };
290
291    if dry_run {
292        println!(
293            "[dry-run] {}: would revoke {} from {}",
294            discourse.name, role_label, username
295        );
296        return Ok(());
297    }
298
299    let detail = client.fetch_user_detail(username)?;
300    match role {
301        Role::Admin => client.revoke_admin(detail.id)?,
302        Role::Moderator => client.revoke_moderation(detail.id)?,
303    }
304    println!(
305        "Revoked {} from {} (id:{})",
306        role_label, detail.username, detail.id
307    );
308    Ok(())
309}
310
311pub fn user_groups_list(
312    config: &Config,
313    discourse_name: &str,
314    username: &str,
315    format: ListFormat,
316) -> Result<()> {
317    let discourse = select_discourse(config, Some(discourse_name))?;
318    ensure_api_credentials(discourse)?;
319    let client = DiscourseClient::new(discourse)?;
320
321    let mut groups = client.fetch_user_groups(username)?;
322    groups.sort_by(|a, b| a.name.cmp(&b.name));
323
324    match format {
325        ListFormat::Text => {
326            if groups.is_empty() {
327                println!("{} is not in any groups.", username);
328                return Ok(());
329            }
330            let name_width = groups
331                .iter()
332                .map(|g| g.name.len())
333                .max()
334                .unwrap_or(0)
335                .max(4);
336            for g in &groups {
337                println!("{:<width$}  id:{}", g.name, g.id, width = name_width);
338            }
339        }
340        ListFormat::Json => {
341            println!("{}", serde_json::to_string_pretty(&groups)?);
342        }
343        ListFormat::Yaml => {
344            println!("{}", serde_yaml::to_string(&groups)?);
345        }
346    }
347
348    Ok(())
349}
350
351pub fn user_groups_add(
352    config: &Config,
353    discourse_name: &str,
354    username: &str,
355    group_id: u64,
356    notify: bool,
357    dry_run: bool,
358) -> Result<()> {
359    let discourse = select_discourse(config, Some(discourse_name))?;
360    ensure_api_credentials(discourse)?;
361    let client = DiscourseClient::new(discourse)?;
362
363    if dry_run {
364        println!(
365            "[dry-run] {}: would add {} to group {} (notify={})",
366            discourse.name, username, group_id, notify
367        );
368        return Ok(());
369    }
370
371    let usernames = vec![username.to_string()];
372    let outcome = client.add_group_members_by_username(group_id, &usernames, notify)?;
373    if outcome.added_usernames.is_empty() {
374        println!(
375            "{} was already a member of group {} (or Discourse reported no change)",
376            username, group_id
377        );
378    } else {
379        println!("Added {} to group {}", username, group_id);
380    }
381    if !outcome.errors.is_empty() {
382        eprintln!("Server notes:");
383        for msg in &outcome.errors {
384            eprintln!("  - {}", msg);
385        }
386    }
387    Ok(())
388}
389
390pub fn user_groups_remove(
391    config: &Config,
392    discourse_name: &str,
393    username: &str,
394    group_id: u64,
395    dry_run: bool,
396) -> Result<()> {
397    let discourse = select_discourse(config, Some(discourse_name))?;
398    ensure_api_credentials(discourse)?;
399    let client = DiscourseClient::new(discourse)?;
400
401    if dry_run {
402        println!(
403            "[dry-run] {}: would remove {} from group {}",
404            discourse.name, username, group_id
405        );
406        return Ok(());
407    }
408
409    let usernames = vec![username.to_string()];
410    client.remove_group_members_by_username(group_id, &usernames)?;
411    println!("Removed {} from group {}", username, group_id);
412    Ok(())
413}
414
415/// Output format variants for `dsc user activity`. Superset of ListFormat —
416/// adds `markdown` and `csv`.
417#[derive(Clone, Copy)]
418pub enum ActivityFormat {
419    Text,
420    Json,
421    Yaml,
422    Markdown,
423    Csv,
424}
425
426/// Fetch a user's recent activity and render it.
427pub fn user_activity(
428    config: &Config,
429    discourse_name: &str,
430    username: &str,
431    type_names: &[String],
432    since: Option<&str>,
433    limit: Option<u32>,
434    format: ActivityFormat,
435) -> Result<()> {
436    let discourse = select_discourse(config, Some(discourse_name))?;
437    // Activity is read-only, and Discourse's user_actions.json is public for
438    // forums that allow anonymous read. Skip the apikey/api_username guard so
439    // this command works on forums where the caller only has a config entry
440    // with baseurl (no admin access). If the forum is login-walled, the API
441    // call will surface a 403/401 with the normal credentials hint.
442    let client = DiscourseClient::new(discourse)?;
443
444    let filter_types = resolve_activity_types(type_names)?;
445    let cutoff = match since {
446        Some(raw) => Some(parse_since_cutoff(raw)?),
447        None => None,
448    };
449
450    let mut collected: Vec<UserAction> = Vec::new();
451    let mut offset: u32 = 0;
452    let page_hint: u32 = 30; // Discourse returns ~10-30 depending on version
453    let max = limit.unwrap_or(u32::MAX);
454    loop {
455        let page = client.fetch_user_actions(username, &filter_types, offset)?;
456        if page.is_empty() {
457            break;
458        }
459        let page_len = page.len() as u32;
460
461        let mut past_cutoff = false;
462        for action in page {
463            if let Some(cutoff) = cutoff {
464                if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&action.created_at) {
465                    if created.with_timezone(&chrono::Utc) < cutoff {
466                        past_cutoff = true;
467                        continue;
468                    }
469                }
470            }
471            collected.push(action);
472            if collected.len() as u32 >= max {
473                break;
474            }
475        }
476
477        if past_cutoff || collected.len() as u32 >= max {
478            break;
479        }
480        offset = offset.saturating_add(page_len.max(page_hint));
481    }
482
483    render_activity(&collected, &normalize_baseurl(&discourse.baseurl), format)
484}
485
486fn render_activity(
487    actions: &[UserAction],
488    baseurl: &str,
489    format: ActivityFormat,
490) -> Result<()> {
491    match format {
492        ActivityFormat::Text => {
493            if actions.is_empty() {
494                println!("No activity in that window.");
495                return Ok(());
496            }
497            for a in actions {
498                let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
499                let title = a.title.as_deref().unwrap_or("(untitled)");
500                let kind = action_type_label(a.action_type);
501                println!(
502                    "{}  [{:<6}]  {}  {}",
503                    date,
504                    kind,
505                    title,
506                    activity_url(baseurl, a)
507                );
508            }
509        }
510        ActivityFormat::Markdown => {
511            for a in actions {
512                let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
513                let title = a.title.as_deref().unwrap_or("(untitled)");
514                println!(
515                    "- [{}]({}) — {}",
516                    title,
517                    activity_url(baseurl, a),
518                    date
519                );
520            }
521        }
522        ActivityFormat::Csv => {
523            println!("date,type,title,url");
524            for a in actions {
525                let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
526                let title = a.title.as_deref().unwrap_or("").replace('"', "\"\"");
527                println!(
528                    "{},{},\"{}\",{}",
529                    date,
530                    action_type_label(a.action_type),
531                    title,
532                    activity_url(baseurl, a)
533                );
534            }
535        }
536        ActivityFormat::Json => println!("{}", serde_json::to_string_pretty(&actions)?),
537        ActivityFormat::Yaml => println!("{}", serde_yaml::to_string(&actions)?),
538    }
539    Ok(())
540}
541
542/// Construct the public URL for a user-action row.
543pub(crate) fn activity_url(baseurl: &str, a: &UserAction) -> String {
544    let slug = a.slug.as_deref().unwrap_or("-");
545    match a.post_number {
546        Some(n) if n > 1 => format!("{}/t/{}/{}/{}", baseurl, slug, a.topic_id, n),
547        _ => format!("{}/t/{}/{}", baseurl, slug, a.topic_id),
548    }
549}
550
551/// Discourse's UserAction::Types numeric constants.
552const ACTION_LIKE: u32 = 1;
553const ACTION_NEW_TOPIC: u32 = 4;
554const ACTION_REPLY: u32 = 5;
555const ACTION_RESPONSE: u32 = 6;
556const ACTION_MENTION: u32 = 7;
557const ACTION_QUOTE: u32 = 9;
558const ACTION_EDIT: u32 = 11;
559
560fn action_type_label(n: u32) -> &'static str {
561    match n {
562        ACTION_LIKE => "like",
563        ACTION_NEW_TOPIC => "topic",
564        ACTION_REPLY => "reply",
565        ACTION_RESPONSE => "resp",
566        ACTION_MENTION => "@",
567        ACTION_QUOTE => "quote",
568        ACTION_EDIT => "edit",
569        _ => "?",
570    }
571}
572
573/// Map friendly type names on the command line to Discourse's numeric filters.
574pub(crate) fn resolve_activity_types(names: &[String]) -> Result<Vec<u32>> {
575    if names.is_empty() {
576        return Ok(vec![ACTION_NEW_TOPIC, ACTION_REPLY]);
577    }
578    let mut out = Vec::new();
579    for raw in names {
580        for piece in raw.split(',') {
581            let piece = piece.trim().to_ascii_lowercase();
582            if piece.is_empty() {
583                continue;
584            }
585            let n = match piece.as_str() {
586                "topic" | "topics" | "new_topic" => ACTION_NEW_TOPIC,
587                "reply" | "replies" => ACTION_REPLY,
588                "response" | "responses" => ACTION_RESPONSE,
589                "mention" | "mentions" => ACTION_MENTION,
590                "quote" | "quotes" => ACTION_QUOTE,
591                "like" | "likes" => ACTION_LIKE,
592                "edit" | "edits" => ACTION_EDIT,
593                other => {
594                    return Err(anyhow!(
595                        "unknown activity type: {:?} (known: topics, replies, mentions, quotes, likes, edits, responses)",
596                        other
597                    ));
598                }
599            };
600            if !out.contains(&n) {
601                out.push(n);
602            }
603        }
604    }
605    Ok(out)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::api::UserAction;
612
613    #[test]
614    fn default_activity_types_are_topics_and_replies() {
615        assert_eq!(
616            resolve_activity_types(&[]).unwrap(),
617            vec![ACTION_NEW_TOPIC, ACTION_REPLY]
618        );
619    }
620
621    #[test]
622    fn activity_types_accept_plural_and_csv() {
623        let got = resolve_activity_types(&["topics,mentions".to_string()]).unwrap();
624        assert_eq!(got, vec![ACTION_NEW_TOPIC, ACTION_MENTION]);
625    }
626
627    #[test]
628    fn activity_types_dedupe() {
629        let got = resolve_activity_types(&["reply,reply,replies".to_string()]).unwrap();
630        assert_eq!(got, vec![ACTION_REPLY]);
631    }
632
633    #[test]
634    fn activity_types_reject_unknown() {
635        assert!(resolve_activity_types(&["nonsense".to_string()]).is_err());
636    }
637
638    #[test]
639    fn activity_url_for_reply_includes_post_number() {
640        let a = UserAction {
641            action_type: ACTION_REPLY,
642            created_at: "2026-04-15T12:00:00Z".to_string(),
643            title: Some("Hi".to_string()),
644            slug: Some("hi-there".to_string()),
645            topic_id: 42,
646            post_id: Some(999),
647            post_number: Some(3),
648            username: Some("alice".to_string()),
649            excerpt: None,
650        };
651        assert_eq!(
652            activity_url("https://f.example", &a),
653            "https://f.example/t/hi-there/42/3"
654        );
655    }
656
657    #[test]
658    fn activity_url_for_op_omits_post_number() {
659        let a = UserAction {
660            action_type: ACTION_NEW_TOPIC,
661            created_at: "2026-04-15T12:00:00Z".to_string(),
662            title: Some("Hi".to_string()),
663            slug: Some("hi-there".to_string()),
664            topic_id: 42,
665            post_id: Some(999),
666            post_number: Some(1),
667            username: Some("alice".to_string()),
668            excerpt: None,
669        };
670        assert_eq!(
671            activity_url("https://f.example", &a),
672            "https://f.example/t/hi-there/42"
673        );
674    }
675}
676
677pub fn user_create(
678    config: &Config,
679    discourse_name: &str,
680    email: &str,
681    username: &str,
682    name: Option<&str>,
683    password_from_stdin: bool,
684    approve: bool,
685    dry_run: bool,
686) -> Result<()> {
687    let discourse = select_discourse(config, Some(discourse_name))?;
688    ensure_api_credentials(discourse)?;
689    let client = DiscourseClient::new(discourse)?;
690
691    if !email.contains('@') {
692        return Err(anyhow!("invalid email: {:?}", email));
693    }
694    if username.trim().is_empty() {
695        return Err(anyhow!("username cannot be empty"));
696    }
697
698    let password = if password_from_stdin {
699        let mut buf = String::new();
700        io::stdin()
701            .read_to_string(&mut buf)
702            .context("reading password from stdin")?;
703        let trimmed = buf.trim_end_matches(['\r', '\n']).to_string();
704        if trimmed.is_empty() {
705            return Err(anyhow!("--password-stdin set but stdin was empty"));
706        }
707        Some(trimmed)
708    } else {
709        None
710    };
711
712    if dry_run {
713        println!(
714            "[dry-run] {}: would create user {} ({}){}{}{}",
715            discourse.name,
716            username,
717            email,
718            name.filter(|n| !n.is_empty())
719                .map(|n| format!(", name=\"{}\"", n))
720                .unwrap_or_default(),
721            if password.is_some() {
722                ", with password from stdin"
723            } else {
724                ", no password (triggers email reset flow)"
725            },
726            if approve { ", approved=true" } else { "" }
727        );
728        return Ok(());
729    }
730
731    let id = client.create_user(email, username, password.as_deref(), name, approve)?;
732    println!("Created user {} (id:{})", username, id);
733    if password.is_none() {
734        println!(
735            "  no password set — send them a reset email with:\n    dsc user password-reset {} {}",
736            discourse.name, username
737        );
738    }
739    Ok(())
740}
741
742pub fn user_password_reset(
743    config: &Config,
744    discourse_name: &str,
745    username: &str,
746    dry_run: bool,
747) -> Result<()> {
748    let discourse = select_discourse(config, Some(discourse_name))?;
749    ensure_api_credentials(discourse)?;
750    let client = DiscourseClient::new(discourse)?;
751
752    if dry_run {
753        println!(
754            "[dry-run] {}: would trigger password-reset email for {}",
755            discourse.name, username
756        );
757        return Ok(());
758    }
759
760    client.trigger_password_reset(username)?;
761    // Discourse deliberately returns a generic success even for unknown
762    // users to prevent enumeration — surface that so the caller doesn't
763    // over-trust the result.
764    println!("Password reset request sent for {} (if that user exists).", username);
765    Ok(())
766}
767
768pub fn user_email_set(
769    config: &Config,
770    discourse_name: &str,
771    username: &str,
772    email: &str,
773    dry_run: bool,
774) -> Result<()> {
775    let discourse = select_discourse(config, Some(discourse_name))?;
776    ensure_api_credentials(discourse)?;
777    let client = DiscourseClient::new(discourse)?;
778
779    if !email.contains('@') {
780        return Err(anyhow!("invalid email: {:?}", email));
781    }
782
783    if dry_run {
784        println!(
785            "[dry-run] {}: would set {}'s email to {}",
786            discourse.name, username, email
787        );
788        return Ok(());
789    }
790
791    client.set_user_email(username, email)?;
792    println!("Set {}'s email to {}", username, email);
793    Ok(())
794}