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