Skip to main content

clickup_cli/commands/
acl.rs

1use crate::client::ClickUpClient;
2use crate::commands::auth::resolve_token;
3use crate::commands::workspace::resolve_workspace;
4use crate::error::CliError;
5use crate::output::OutputConfig;
6use crate::Cli;
7use clap::Subcommand;
8
9#[derive(Subcommand)]
10pub enum AclCommands {
11    /// Update ACL for an object (Enterprise only, v3)
12    Update {
13        /// Object type (e.g. task, list, folder, space)
14        object_type: String,
15        /// Object ID
16        object_id: String,
17        /// Mark the object private (true) or remove the private flag (false). Omit to leave unchanged.
18        #[arg(long)]
19        private: Option<bool>,
20        /// Grant a user permission. Format: USER_ID[:LEVEL] where LEVEL is read|comment|edit|create (default: read). Repeat for multiple users.
21        #[arg(long = "grant-user")]
22        grant_user: Vec<String>,
23        /// Grant a group permission. Same format as --grant-user but the id refers to a user group.
24        #[arg(long = "grant-group")]
25        grant_group: Vec<String>,
26        /// Revoke a user (sends permission_level=0). Repeat for multiple users.
27        #[arg(long = "revoke-user")]
28        revoke_user: Vec<String>,
29        /// Revoke a group (sends permission_level=0). Repeat for multiple groups.
30        #[arg(long = "revoke-group")]
31        revoke_group: Vec<String>,
32        /// Raw JSON body (overrides all other flags). Use this for advanced shapes the flags don't cover.
33        #[arg(long)]
34        body: Option<String>,
35    },
36}
37
38fn permission_to_level(name: &str) -> Result<u8, CliError> {
39    // ClickUp's documented permission_level integers on ACL entries.
40    // The API enum is 1, 3, 4, 5 (0 used here for revocation).
41    match name.to_lowercase().as_str() {
42        "read" | "1" => Ok(1),
43        "comment" | "3" => Ok(3),
44        "edit" | "4" => Ok(4),
45        "create" | "5" => Ok(5),
46        other => Err(CliError::ClientError {
47            message: format!(
48                "Unknown permission '{}'. Valid: read (1), comment (3), edit (4), create (5).",
49                other
50            ),
51            status: 0,
52        }),
53    }
54}
55
56fn parse_grant(raw: &str, kind: &'static str) -> Result<serde_json::Value, CliError> {
57    // Format: ID[:LEVEL]. Default level is read (1).
58    let (id, level) = match raw.split_once(':') {
59        Some((id, level)) => (id.trim().to_string(), permission_to_level(level.trim())?),
60        None => (raw.trim().to_string(), 1u8),
61    };
62    Ok(serde_json::json!({
63        "kind": kind,
64        "id": id,
65        "permission_level": level,
66    }))
67}
68
69fn revoke_entry(id: &str, kind: &'static str) -> serde_json::Value {
70    // permission_level=0 to indicate removal. ClickUp's spec treats
71    // permission_level as optional and 0 is conventionally used to revoke.
72    // If a workspace requires a different shape, use --body for raw JSON.
73    serde_json::json!({
74        "kind": kind,
75        "id": id,
76        "permission_level": 0,
77    })
78}
79
80pub async fn execute(command: AclCommands, cli: &Cli) -> Result<(), CliError> {
81    let token = resolve_token(cli)?;
82    let client = ClickUpClient::new(&token, cli.timeout)?;
83    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
84
85    match command {
86        AclCommands::Update {
87            object_type,
88            object_id,
89            private,
90            grant_user,
91            grant_group,
92            revoke_user,
93            revoke_group,
94            body,
95        } => {
96            let team_id = resolve_workspace(cli)?;
97
98            // ClickUp's PATCH /v3/workspaces/{ws}/{type}/{id}/acls body shape
99            // per the v3 OpenAPI spec:
100            //   { "private"?: bool, "entries"?: [ { kind, id, permission_level? } ] }
101            // The previous implementation invented `{access_type, grant, revoke}`,
102            // which the endpoint does not recognise.
103            let request_body = if let Some(raw) = body {
104                serde_json::from_str(&raw).map_err(|e| CliError::ClientError {
105                    message: format!("Invalid JSON body: {}", e),
106                    status: 0,
107                })?
108            } else {
109                let mut b = serde_json::Map::new();
110                if let Some(p) = private {
111                    b.insert("private".into(), serde_json::Value::Bool(p));
112                }
113                let mut entries: Vec<serde_json::Value> = Vec::new();
114                for raw in grant_user {
115                    entries.push(parse_grant(&raw, "user")?);
116                }
117                for raw in grant_group {
118                    entries.push(parse_grant(&raw, "group")?);
119                }
120                for id in revoke_user {
121                    entries.push(revoke_entry(&id, "user"));
122                }
123                for id in revoke_group {
124                    entries.push(revoke_entry(&id, "group"));
125                }
126                if !entries.is_empty() {
127                    b.insert("entries".into(), serde_json::Value::Array(entries));
128                }
129                serde_json::Value::Object(b)
130            };
131
132            let resp = client
133                .patch(
134                    &format!(
135                        "/v3/workspaces/{}/{}/{}/acls",
136                        team_id, object_type, object_id
137                    ),
138                    &request_body,
139                )
140                .await?;
141
142            if cli.output == "json" {
143                println!("{}", serde_json::to_string_pretty(&resp).unwrap());
144            } else {
145                output.print_message(&format!("ACL updated for {} {}", object_type, object_id));
146            }
147            Ok(())
148        }
149    }
150}