diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f48389..5e90613cae 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -133,6 +133,7 @@ export default defineConfig({ "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", "**/huddle-transcription.spec.ts", + "**/clickup.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/clickup.rs b/desktop/src-tauri/src/commands/clickup.rs new file mode 100644 index 0000000000..b352e90608 --- /dev/null +++ b/desktop/src-tauri/src/commands/clickup.rs @@ -0,0 +1,925 @@ +use std::{sync::OnceLock, time::Duration}; + +use bytes::BytesMut; +use futures_util::StreamExt; +use reqwest::{header::HeaderMap, Method, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; +use tauri::State; +use url::Url; +use zeroize::{Zeroize, Zeroizing}; + +use crate::{ + app_state::{keyring_service, AppState}, + secret_store::SecretStore, +}; + +const CLICKUP_API_BASE: &str = "https://api.clickup.com/api/v2/"; +const CLICKUP_RESPONSE_LIMIT_BYTES: usize = 10 * 1024 * 1024; +const MAX_TASK_PAGES: u32 = 20; +const MAX_TASKS: usize = 2_000; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpUser { + id: u64, + username: String, + #[serde(default)] + email: Option, + #[serde(default)] + color: Option, + #[serde(default)] + profile_picture: Option, + #[serde(default)] + initials: Option, +} + +#[derive(Debug, Deserialize)] +struct ClickUpUserResponse { + user: ClickUpUser, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ClickUpAuthStatus { + connected: bool, + account: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpWorkspace { + id: String, + name: String, + #[serde(default)] + color: Option, + #[serde(default)] + avatar: Option, +} + +#[derive(Debug, Deserialize)] +struct ClickUpWorkspacesResponse { + #[serde(default)] + teams: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct ClickUpTaskStatus { + #[serde(default)] + status: String, + #[serde(default)] + color: Option, + #[serde(default, rename = "type")] + kind: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpTaskPriority { + #[serde(default)] + priority: String, + #[serde(default)] + color: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpNamedLocation { + #[serde(default)] + id: String, + #[serde(default)] + name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpTaskTag { + #[serde(default)] + name: String, + #[serde(default)] + tag_fg: Option, + #[serde(default)] + tag_bg: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpCustomField { + #[serde(default)] + id: String, + #[serde(default)] + name: String, + #[serde(default, rename = "type")] + field_type: String, + #[serde(default)] + value: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpDependency { + #[serde(default)] + task_id: Option, + #[serde(default)] + depends_on: Option, + #[serde(default)] + dependency_of: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpTask { + id: String, + name: String, + #[serde(default)] + text_content: String, + #[serde(default)] + description: String, + #[serde(default)] + status: ClickUpTaskStatus, + #[serde(default)] + priority: Option, + #[serde(default)] + due_date: Option, + #[serde(default)] + start_date: Option, + #[serde(default)] + date_created: Option, + #[serde(default)] + date_updated: Option, + #[serde(default)] + archived: bool, + #[serde(default)] + parent: Option, + #[serde(default)] + url: String, + #[serde(default)] + team_id: String, + #[serde(default)] + list: Option, + #[serde(default)] + folder: Option, + #[serde(default)] + space: Option, + #[serde(default)] + assignees: Vec, + #[serde(default)] + tags: Vec, + #[serde(default)] + subtasks: Vec, + #[serde(default)] + custom_fields: Vec, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Deserialize)] +struct ClickUpTasksResponse { + #[serde(default)] + tasks: Vec, + #[serde(default)] + last_page: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ClickUpTaskPage { + tasks: Vec, + fetched_at_ms: u64, + truncated: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpCommentPart { + #[serde(default)] + text: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpComment { + id: Value, + #[serde(default)] + comment_text: Option, + #[serde(default)] + comment: Vec, + #[serde(default)] + date: Option, + #[serde(default)] + user: Option, + #[serde(default)] + resolved: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ClickUpCommentsResponse { + #[serde(default)] + comments: Vec, +} + +struct ClickUpApi { + base_url: Url, + client: reqwest::Client, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn clickup_error(code: &str, message: &str, retry_at_ms: Option) -> String { + format!( + "clickup:{code}:{}:{message}", + retry_at_ms + .map(|value| value.to_string()) + .unwrap_or_default() + ) +} + +fn retry_at_ms(headers: &HeaderMap) -> Option { + if let Some(value) = headers + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + return Some(if value < 1_000_000_000_000 { + value.saturating_mul(1_000) + } else { + value + }); + } + headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(|seconds| now_ms().saturating_add(seconds.saturating_mul(1_000))) +} + +impl ClickUpApi { + fn new(base_url: &str) -> Result { + let base_url = Url::parse(base_url) + .map_err(|_| clickup_error("invalid_request", "ClickUp API URL is invalid.", None))?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| { + clickup_error( + "network", + "Buzz could not initialize the ClickUp client.", + None, + ) + })?; + Ok(Self { base_url, client }) + } + + fn endpoint(&self, segments: &[&str]) -> Result { + let mut endpoint = self.base_url.clone(); + { + let mut path = endpoint.path_segments_mut().map_err(|_| { + clickup_error("invalid_request", "ClickUp API URL is invalid.", None) + })?; + path.pop_if_empty(); + path.extend(segments.iter().copied()); + } + Ok(endpoint) + } + + async fn request_json( + &self, + token: &str, + method: Method, + segments: &[&str], + query: &[(String, String)], + ) -> Result { + let endpoint = self.endpoint(segments)?; + let response = self + .client + .request(method, endpoint) + .header(reqwest::header::AUTHORIZATION, token) + .query(query) + .send() + .await + .map_err(|_| clickup_error("network", "Buzz could not reach ClickUp.", None))?; + let status = response.status(); + let headers = response.headers().clone(); + + if status.is_redirection() { + return Err(clickup_error( + "redirect_rejected", + "ClickUp returned an unexpected redirect.", + None, + )); + } + if status == StatusCode::UNAUTHORIZED { + return Err(clickup_error( + "unauthorized", + "ClickUp rejected the personal token.", + None, + )); + } + if status == StatusCode::FORBIDDEN { + return Err(clickup_error( + "forbidden", + "The connected ClickUp account cannot access this resource.", + None, + )); + } + if status == StatusCode::TOO_MANY_REQUESTS { + return Err(clickup_error( + "rate_limited", + "ClickUp is temporarily limiting requests.", + retry_at_ms(&headers), + )); + } + if status.is_server_error() { + return Err(clickup_error( + "server", + "ClickUp is temporarily unavailable.", + None, + )); + } + if !status.is_success() { + return Err(clickup_error( + "invalid_request", + "ClickUp could not complete this read request.", + None, + )); + } + + if headers + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|size| size > CLICKUP_RESPONSE_LIMIT_BYTES) + { + return Err(clickup_error( + "response_too_large", + "ClickUp returned more data than Buzz can safely display.", + None, + )); + } + + let mut bytes = BytesMut::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + clickup_error("network", "Buzz could not read the ClickUp response.", None) + })?; + if bytes.len().saturating_add(chunk.len()) > CLICKUP_RESPONSE_LIMIT_BYTES { + return Err(clickup_error( + "response_too_large", + "ClickUp returned more data than Buzz can safely display.", + None, + )); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes) + .map_err(|_| clickup_error("server", "ClickUp returned an unreadable response.", None)) + } + + async fn current_user(&self, token: &str) -> Result { + let response: ClickUpUserResponse = self + .request_json(token, Method::GET, &["user"], &[]) + .await?; + Ok(response.user) + } + + async fn workspaces(&self, token: &str) -> Result, String> { + let response: ClickUpWorkspacesResponse = self + .request_json(token, Method::GET, &["team"], &[]) + .await?; + Ok(response.teams) + } + + async fn tasks( + &self, + token: &str, + workspace_id: &str, + assignee_id: u64, + ) -> Result { + validate_resource_id(workspace_id, "Workspace")?; + let mut tasks = Vec::new(); + let mut truncated = false; + for page in 0..MAX_TASK_PAGES { + let query = vec![ + ("assignees[]".to_string(), assignee_id.to_string()), + ("include_closed".to_string(), "false".to_string()), + ("subtasks".to_string(), "true".to_string()), + ("order_by".to_string(), "due_date".to_string()), + ("page".to_string(), page.to_string()), + ]; + let response: ClickUpTasksResponse = self + .request_json(token, Method::GET, &["team", workspace_id, "task"], &query) + .await?; + let is_empty = response.tasks.is_empty(); + let remaining = MAX_TASKS.saturating_sub(tasks.len()); + if response.tasks.len() > remaining { + tasks.extend(response.tasks.into_iter().take(remaining)); + return Ok(ClickUpTaskPage { + tasks, + fetched_at_ms: now_ms(), + truncated: true, + }); + } + tasks.extend(response.tasks); + if response.last_page || is_empty { + return Ok(ClickUpTaskPage { + tasks, + fetched_at_ms: now_ms(), + truncated, + }); + } + if page + 1 == MAX_TASK_PAGES { + truncated = true; + } + } + Ok(ClickUpTaskPage { + tasks, + fetched_at_ms: now_ms(), + truncated, + }) + } + + async fn task(&self, token: &str, task_id: &str) -> Result { + validate_resource_id(task_id, "Task")?; + let query = vec![("include_subtasks".to_string(), "true".to_string())]; + self.request_json(token, Method::GET, &["task", task_id], &query) + .await + } + + async fn comments( + &self, + token: &str, + task_id: &str, + ) -> Result { + validate_resource_id(task_id, "Task")?; + self.request_json(token, Method::GET, &["task", task_id, "comment"], &[]) + .await + } +} + +fn production_api() -> Result<&'static ClickUpApi, String> { + static API: OnceLock> = OnceLock::new(); + match API.get_or_init(|| ClickUpApi::new(CLICKUP_API_BASE)) { + Ok(api) => Ok(api), + Err(error) => Err(error.clone()), + } +} + +fn validate_resource_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + { + return Err(clickup_error( + "invalid_request", + &format!("{label} identifier is invalid."), + None, + )); + } + Ok(()) +} + +fn normalize_personal_token(value: &str) -> Result { + let token = value.trim(); + if token.len() < 12 + || token.len() > 512 + || !token.starts_with("pk_") + || token.chars().any(char::is_whitespace) + { + return Err(clickup_error( + "invalid_token", + "Enter a valid ClickUp personal token beginning with pk_.", + None, + )); + } + Ok(token.to_owned()) +} + +fn credential_key(state: &AppState) -> Result { + let keys = state.signing_keys().map_err(|_| { + clickup_error( + "keyring_unavailable", + "Buzz cannot securely scope the ClickUp credential right now.", + None, + ) + })?; + Ok(format!( + "clickup:{}:personal-token", + keys.public_key().to_hex() + )) +} + +fn credential_store() -> &'static SecretStore { + SecretStore::shared(keyring_service()) +} + +trait CredentialBackend { + fn load_value(&self, key: &str) -> Result, ()>; + fn store_value(&self, key: &str, value: &str) -> Result<(), ()>; + fn verify_value(&self, key: &str, expected: &str) -> Result; + fn delete_value(&self, key: &str) -> Result<(), ()>; +} + +impl CredentialBackend for SecretStore { + fn load_value(&self, key: &str) -> Result, ()> { + self.load(key).map_err(|_| ()) + } + + fn store_value(&self, key: &str, value: &str) -> Result<(), ()> { + self.store(key, value).map_err(|_| ()) + } + + fn verify_value(&self, key: &str, expected: &str) -> Result { + self.verify_stored_raw(key, expected).map_err(|_| ()) + } + + fn delete_value(&self, key: &str) -> Result<(), ()> { + self.delete(key).map_err(|_| ()) + } +} + +fn restore_previous_token( + store: &impl CredentialBackend, + key: &str, + previous: Option<&str>, +) -> Result<(), ()> { + match previous { + Some(value) => { + store.store_value(key, value)?; + if store.verify_value(key, value)? { + Ok(()) + } else { + Err(()) + } + } + None => { + store.delete_value(key)?; + if store.load_value(key)?.is_none() { + Ok(()) + } else { + Err(()) + } + } + } +} + +fn persist_token_transactionally( + store: &impl CredentialBackend, + key: &str, + token: &str, +) -> Result<(), String> { + let previous = store + .load_value(key) + .map_err(|_| { + clickup_error( + "keyring_unavailable", + "Buzz could not access secure keyring storage.", + None, + ) + })? + .map(Zeroizing::new); + + let stored = store.store_value(key, token); + let verified = stored + .as_ref() + .map(|()| store.verify_value(key, token)) + .unwrap_or(Err(())); + if stored.is_ok() && matches!(verified, Ok(true)) { + return Ok(()); + } + + if restore_previous_token(store, key, previous.as_ref().map(|value| value.as_str())).is_err() { + return Err(clickup_error( + "keyring_unavailable", + "Buzz could not verify or restore the ClickUp token in secure storage.", + None, + )); + } + Err(clickup_error( + "keyring_unavailable", + "Buzz could not verify the ClickUp token in secure storage. The previous credential was restored.", + None, + )) +} + +fn enforce_task_scope( + task: &ClickUpTask, + workspace_id: &str, + assignee_id: u64, +) -> Result<(), String> { + validate_resource_id(workspace_id, "Workspace")?; + let assigned = task.assignees.iter().any(|user| user.id == assignee_id); + let closed = task + .status + .kind + .as_deref() + .is_some_and(|kind| matches!(kind, "closed" | "done")); + if task.team_id != workspace_id || !assigned || task.archived || closed { + return Err(clickup_error( + "forbidden", + "This task is outside the selected read-only My Work scope.", + None, + )); + } + Ok(()) +} + +fn ensure_credential_key_unchanged(expected: &str, current: &str) -> Result<(), String> { + if expected == current { + return Ok(()); + } + Err(clickup_error( + "identity_changed", + "The active Buzz identity changed while ClickUp was connecting. Enter the token again for the current identity.", + None, + )) +} + +fn load_token(state: &AppState) -> Result>, String> { + let key = credential_key(state)?; + credential_store() + .load(&key) + .map(|token| token.map(Zeroizing::new)) + .map_err(|_| { + clickup_error( + "keyring_unavailable", + "Buzz could not access secure keyring storage.", + None, + ) + }) +} + +fn require_token(state: &AppState) -> Result, String> { + load_token(state)?.ok_or_else(|| { + clickup_error( + "not_connected", + "Connect ClickUp before loading tasks.", + None, + ) + }) +} + +#[tauri::command] +pub(crate) async fn clickup_auth_status( + state: State<'_, AppState>, +) -> Result { + let Some(token) = load_token(&state)? else { + return Ok(ClickUpAuthStatus { + connected: false, + account: None, + }); + }; + let account = production_api()?.current_user(&token).await?; + Ok(ClickUpAuthStatus { + connected: true, + account: Some(account), + }) +} + +#[tauri::command] +pub(crate) async fn clickup_connect( + personal_token: String, + state: State<'_, AppState>, +) -> Result { + let key = credential_key(&state)?; + let mut incoming = Zeroizing::new(personal_token); + let normalized = normalize_personal_token(&incoming)?; + incoming.zeroize(); + let token = Zeroizing::new(normalized); + let account = production_api()?.current_user(&token).await?; + ensure_credential_key_unchanged(&key, &credential_key(&state)?)?; + let store = credential_store(); + persist_token_transactionally(store, &key, &token)?; + Ok(ClickUpAuthStatus { + connected: true, + account: Some(account), + }) +} + +#[tauri::command] +pub(crate) fn clickup_disconnect(state: State<'_, AppState>) -> Result<(), String> { + let key = credential_key(&state)?; + let store = credential_store(); + store.delete(&key).map_err(|_| { + clickup_error( + "keyring_unavailable", + "Buzz could not remove the ClickUp token from secure storage.", + None, + ) + })?; + if store + .load(&key) + .map_err(|_| { + clickup_error( + "keyring_unavailable", + "Buzz could not verify ClickUp disconnection.", + None, + ) + })? + .is_some() + { + return Err(clickup_error( + "keyring_unavailable", + "Buzz could not verify ClickUp disconnection.", + None, + )); + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn clickup_list_workspaces( + state: State<'_, AppState>, +) -> Result, String> { + let token = require_token(&state)?; + production_api()?.workspaces(&token).await +} + +#[tauri::command] +pub(crate) async fn clickup_list_tasks( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + let token = require_token(&state)?; + let api = production_api()?; + let account = api.current_user(&token).await?; + api.tasks(&token, &workspace_id, account.id).await +} + +#[tauri::command] +pub(crate) async fn clickup_get_task( + workspace_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + let token = require_token(&state)?; + let api = production_api()?; + let account = api.current_user(&token).await?; + let task = api.task(&token, &task_id).await?; + enforce_task_scope(&task, &workspace_id, account.id)?; + Ok(task) +} + +#[tauri::command] +pub(crate) async fn clickup_get_task_comments( + workspace_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + let token = require_token(&state)?; + let api = production_api()?; + let account = api.current_user(&token).await?; + let task = api.task(&token, &task_id).await?; + enforce_task_scope(&task, &workspace_id, account.id)?; + api.comments(&token, &task_id).await +} + +#[cfg(test)] +#[path = "clickup_security_tests.rs"] +mod security_tests; + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, convert::Infallible}; + + use axum::{ + body::Body, + extract::Query, + http::HeaderMap as AxumHeaderMap, + response::{Redirect, Response}, + routing::get, + Json, Router, + }; + use futures_util::stream; + use serde_json::json; + + use super::*; + + #[test] + fn personal_tokens_are_trimmed_and_validated() { + assert_eq!( + normalize_personal_token(" pk_123456789012 ").unwrap(), + "pk_123456789012" + ); + assert!(normalize_personal_token("not-a-token").is_err()); + assert!(normalize_personal_token("pk_with whitespace").is_err()); + } + + #[test] + fn resource_ids_reject_path_and_query_injection() { + assert!(validate_resource_id("86abc-123", "Task").is_ok()); + assert!(validate_resource_id("../user", "Task").is_err()); + assert!(validate_resource_id("team?admin=true", "Workspace").is_err()); + } + + async fn spawn_test_api(router: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (format!("http://{address}/api/v2/"), handle) + } + + #[tokio::test] + async fn authenticated_task_reads_paginate_until_last_page() { + async fn user(headers: AxumHeaderMap) -> (StatusCode, Json) { + if headers + .get(reqwest::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + != Some("pk_test-token") + { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + ( + StatusCode::OK, + Json(json!({ "user": { "id": 42, "username": "Mikes" } })), + ) + } + + async fn tasks(Query(query): Query>) -> Json { + let page = query.get("page").map(String::as_str).unwrap_or("0"); + let id = if page == "0" { "first" } else { "second" }; + Json(json!({ + "tasks": [{ + "id": id, + "name": format!("Task {id}"), + "status": { "status": "open" }, + "team_id": "123" + }], + "last_page": page == "1" + })) + } + + let router = Router::new() + .route("/api/v2/user", get(user)) + .route("/api/v2/team/{workspace_id}/task", get(tasks)); + let (base_url, handle) = spawn_test_api(router).await; + let api = ClickUpApi::new(&base_url).unwrap(); + let user = api.current_user("pk_test-token").await.unwrap(); + let result = api.tasks("pk_test-token", "123", user.id).await.unwrap(); + handle.abort(); + + assert_eq!( + result + .tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["first", "second"] + ); + assert!(!result.truncated); + } + + #[tokio::test] + async fn redirects_are_rejected_without_following() { + let router = Router::new().route( + "/api/v2/user", + get(|| async { Redirect::temporary("https://example.com/steal") }), + ); + let (base_url, handle) = spawn_test_api(router).await; + let api = ClickUpApi::new(&base_url).unwrap(); + let error = api.current_user("pk_test-token").await.unwrap_err(); + handle.abort(); + + assert!(error.starts_with("clickup:redirect_rejected:")); + } + + #[tokio::test] + async fn chunked_responses_are_stopped_at_the_byte_limit() { + let router = Router::new().route( + "/api/v2/user", + get(|| async { + let chunks = stream::iter([ + Ok::<_, Infallible>(bytes::Bytes::from(vec![b'a'; 6 * 1024 * 1024])), + Ok::<_, Infallible>(bytes::Bytes::from(vec![b'b'; 6 * 1024 * 1024])), + ]); + Response::new(Body::from_stream(chunks)) + }), + ); + let (base_url, handle) = spawn_test_api(router).await; + let api = ClickUpApi::new(&base_url).unwrap(); + let error = api.current_user("pk_test-token").await.unwrap_err(); + handle.abort(); + + assert!(error.starts_with("clickup:response_too_large:")); + } + + #[tokio::test] + async fn rate_limit_reset_is_preserved_for_the_ui() { + let router = Router::new().route( + "/api/v2/user", + get(|| async { + ( + StatusCode::TOO_MANY_REQUESTS, + [("x-ratelimit-reset", "2000000000")], + Json(json!({})), + ) + }), + ); + let (base_url, handle) = spawn_test_api(router).await; + let api = ClickUpApi::new(&base_url).unwrap(); + let error = api.current_user("pk_test-token").await.unwrap_err(); + handle.abort(); + + assert!(error.starts_with("clickup:rate_limited:2000000000000:")); + } +} diff --git a/desktop/src-tauri/src/commands/clickup_security_tests.rs b/desktop/src-tauri/src/commands/clickup_security_tests.rs new file mode 100644 index 0000000000..50103288de --- /dev/null +++ b/desktop/src-tauri/src/commands/clickup_security_tests.rs @@ -0,0 +1,133 @@ +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }, +}; + +use serde_json::json; + +use super::{ + enforce_task_scope, ensure_credential_key_unchanged, persist_token_transactionally, + ClickUpTask, CredentialBackend, +}; + +#[derive(Default)] +struct FakeCredentialBackend { + values: Mutex>, + store_failures: AtomicUsize, + verify_errors: AtomicUsize, + verify_mismatches: AtomicUsize, +} + +impl FakeCredentialBackend { + fn take(counter: &AtomicUsize) -> bool { + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| { + value.checked_sub(1) + }) + .is_ok() + } +} + +impl CredentialBackend for FakeCredentialBackend { + fn load_value(&self, key: &str) -> Result, ()> { + Ok(self.values.lock().unwrap().get(key).cloned()) + } + + fn store_value(&self, key: &str, value: &str) -> Result<(), ()> { + if Self::take(&self.store_failures) { + return Err(()); + } + self.values + .lock() + .unwrap() + .insert(key.to_owned(), value.to_owned()); + Ok(()) + } + + fn verify_value(&self, key: &str, expected: &str) -> Result { + if Self::take(&self.verify_errors) { + return Err(()); + } + if Self::take(&self.verify_mismatches) { + return Ok(false); + } + Ok(self.values.lock().unwrap().get(key).map(String::as_str) == Some(expected)) + } + + fn delete_value(&self, key: &str) -> Result<(), ()> { + self.values.lock().unwrap().remove(key); + Ok(()) + } +} + +#[test] +fn token_verification_mismatch_restores_previous_credential() { + let backend = FakeCredentialBackend::default(); + backend + .values + .lock() + .unwrap() + .insert("identity".to_owned(), "pk_previous-token".to_owned()); + backend.verify_mismatches.store(1, Ordering::SeqCst); + + assert!(persist_token_transactionally(&backend, "identity", "pk_new-token").is_err()); + assert_eq!( + backend.values.lock().unwrap().get("identity").cloned(), + Some("pk_previous-token".to_owned()) + ); +} + +#[test] +fn token_verification_error_removes_new_credential_when_none_existed() { + let backend = FakeCredentialBackend::default(); + backend.verify_errors.store(1, Ordering::SeqCst); + + assert!(persist_token_transactionally(&backend, "identity", "pk_new-token").is_err()); + assert!(!backend.values.lock().unwrap().contains_key("identity")); +} + +#[test] +fn token_store_failure_preserves_previous_credential() { + let backend = FakeCredentialBackend::default(); + backend + .values + .lock() + .unwrap() + .insert("identity".to_owned(), "pk_previous-token".to_owned()); + backend.store_failures.store(1, Ordering::SeqCst); + + assert!(persist_token_transactionally(&backend, "identity", "pk_new-token").is_err()); + assert_eq!( + backend.values.lock().unwrap().get("identity").cloned(), + Some("pk_previous-token".to_owned()) + ); +} + +#[test] +fn task_scope_requires_workspace_assignment_and_open_state() { + let mut task: ClickUpTask = serde_json::from_value(json!({ + "id": "task-1", + "name": "Scoped task", + "team_id": "workspace-1", + "status": { "status": "open", "type": "custom" }, + "assignees": [{ "id": 42, "username": "Mikes" }] + })) + .unwrap(); + + assert!(enforce_task_scope(&task, "workspace-1", 42).is_ok()); + assert!(enforce_task_scope(&task, "workspace-2", 42).is_err()); + assert!(enforce_task_scope(&task, "workspace-1", 7).is_err()); + task.status.kind = Some("closed".to_owned()); + assert!(enforce_task_scope(&task, "workspace-1", 42).is_err()); +} + +#[test] +fn identity_change_is_rejected_before_token_persistence() { + assert!(ensure_credential_key_unchanged("clickup:identity-a", "clickup:identity-a").is_ok()); + let error = + ensure_credential_key_unchanged("clickup:identity-a", "clickup:identity-b").unwrap_err(); + assert!(error.contains("identity_changed")); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..24d80211a3 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ mod canvas; mod channel_templates; mod channel_window; mod channels; +mod clickup; mod clipboard; mod dms; mod engrams; @@ -74,6 +75,7 @@ pub use canvas::*; pub use channel_templates::*; pub use channel_window::*; pub use channels::*; +pub(crate) use clickup::*; pub use clipboard::*; pub use dms::*; pub use engrams::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..dbb6f70732 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -633,31 +633,12 @@ pub fn run() { } }); - // Drain events the retention store flagged `pending_sync` (UI - // create/edit, delete tombstones, launch reconcile) to the relay. - // One loop is the sole publisher for persona, team, and managed- - // agent writers; a relay-unreachable tick leaves rows pending for - // the next sweep. // Skipped in recovery mode — flushing under an ephemeral key would // publish events attributed to an identity the user doesn't own. if !recovery_mode { - let flush_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - use std::time::Duration; - use tauri::Manager; - loop { - let state = flush_handle.state::(); - if let Err(e) = managed_agents::persona_events::flush_active_pending_events( - &flush_handle, - &state, - ) - .await - { - eprintln!("buzz-desktop: event-flush: {e}"); - } - tokio::time::sleep(Duration::from_secs(30)).await; - } - }); + managed_agents::persona_events::spawn_active_pending_event_flusher( + app.handle().clone(), + ); } Ok(()) @@ -669,6 +650,13 @@ pub fn run() { cancel_builderlab_login, get_builderlab_auth, clear_builderlab_auth, + clickup_auth_status, + clickup_connect, + clickup_disconnect, + clickup_list_workspaces, + clickup_list_tasks, + clickup_get_task, + clickup_get_task_comments, get_builderlab_nostr_identity, bind_builderlab_nostr_identity, delete_builderlab_nostr_identity, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6afc18a501..2e7262d6c8 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -3,11 +3,12 @@ //! Persona events are NIP-33 parameterized replaceable events keyed by //! `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug. -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::Duration}; use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; +use tauri::Manager; use super::{AgentDefinition, ManagedAgentRecord}; use crate::app_state::AppState; @@ -247,6 +248,21 @@ pub async fn flush_active_pending_events( flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await } +/// Periodically drain active-scope events that the retention store marked +/// `pending_sync`. One loop is the sole publisher for persona, team, and +/// managed-agent writers; relay failures leave rows pending for the next sweep. +pub fn spawn_active_pending_event_flusher(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + let state = app.state::(); + if let Err(error) = flush_active_pending_events(&app, &state).await { + eprintln!("buzz-desktop: event-flush: {error}"); + } + tokio::time::sleep(Duration::from_secs(30)).await; + } + }); +} + async fn flush_pending_events_at( db_path: &std::path::Path, state: &AppState, diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..97a33db997 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -7,6 +7,7 @@ export type AppView = | "channel" | "messages" | "agents" + | "clickup" | "workflows" | "pulse" | "projects"; @@ -132,6 +133,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/clickup") { + return { + selectedChannelId: null, + selectedView: "clickup", + }; + } + if (pathname === "/workflows" || pathname.startsWith("/workflows/")) { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4eb0a42bbe..ba63366198 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -103,7 +103,6 @@ const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; }); - export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); @@ -127,6 +126,7 @@ export function AppShell() { const { goAgents, goChannel, + goClickUp, goHome, goNewMessage, goProjects, @@ -254,7 +254,6 @@ export function AppShell() { return; } hasRestoredCommunityDestinationRef.current = true; - // Restoration belongs to an explicit community transition. Cold boot and // reconnect remounts must preserve the route the user explicitly opened. if (!consumePendingCommunityRestore(activeCommunityId)) { @@ -875,6 +874,7 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectClickUp={() => void goClickUp()} onSelectChannel={(channelId) => void goChannel(channelId) } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index d19ac03120..31f0e3d3a5 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -102,6 +102,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goClickUp = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/clickup", + }, + behavior, + ), + [commitNavigation], + ); + const goProject = React.useCallback( ( projectId: string, @@ -309,6 +320,7 @@ export function useAppNavigation() { closeWorkflowDetail, goAgents, goChannel, + goClickUp, goForumPost, goHome, goNewMessage, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..81b3735612 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as clickupRouteImport } from "./routes/clickup"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const clickupRoute = clickupRouteImport.update({ + id: "/clickup", + path: "/clickup", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/clickup": typeof clickupRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/clickup": typeof clickupRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/clickup": typeof clickupRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/clickup" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/clickup" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/clickup" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + clickupRoute: typeof clickupRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/clickup": { + id: "/clickup"; + path: "/clickup"; + fullPath: "/clickup"; + preLoaderRoute: typeof clickupRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + clickupRoute: clickupRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..8b4140670e 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,7 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/clickup", "clickup.tsx"), route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( diff --git a/desktop/src/app/routes/clickup.tsx b/desktop/src/app/routes/clickup.tsx new file mode 100644 index 0000000000..a7a5ce7205 --- /dev/null +++ b/desktop/src/app/routes/clickup.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const ClickUpScreen = React.lazy(async () => { + const module = await import("@/features/clickup/ui/ClickUpScreen"); + return { default: module.ClickUpScreen }; +}); + +export const Route = createFileRoute("/clickup")({ + component: ClickUpRouteComponent, +}); + +function ClickUpRouteComponent() { + usePreviewFeatureWarning("clickup"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/clickup/hooks.ts b/desktop/src/features/clickup/hooks.ts new file mode 100644 index 0000000000..6c46e9d5e8 --- /dev/null +++ b/desktop/src/features/clickup/hooks.ts @@ -0,0 +1,176 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ClickUpApiError } from "@/features/clickup/types"; +import { + connectClickUp, + disconnectClickUp, + getClickUpConnection, + getClickUpTask, + getClickUpTaskComments, + listClickUpTasks, + listClickUpWorkspaces, +} from "@/shared/api/tauriClickUp"; + +export const clickUpKeys = { + root: (pubkey: string) => ["clickup", pubkey] as const, + connection: (pubkey: string) => + [...clickUpKeys.root(pubkey), "connection"] as const, + workspaces: (pubkey: string) => + [...clickUpKeys.root(pubkey), "workspaces"] as const, + tasks: (pubkey: string, workspaceId: string) => + [...clickUpKeys.root(pubkey), "workspace", workspaceId, "tasks"] as const, + task: (pubkey: string, workspaceId: string, taskId: string) => + [ + ...clickUpKeys.root(pubkey), + "workspace", + workspaceId, + "task", + taskId, + ] as const, + comments: (pubkey: string, workspaceId: string, taskId: string) => + [ + ...clickUpKeys.root(pubkey), + "workspace", + workspaceId, + "task", + taskId, + "comments", + ] as const, +}; + +function retryClickUpQuery(failureCount: number, error: Error) { + if ( + error instanceof ClickUpApiError && + [ + "forbidden", + "invalid_token", + "keyring_unavailable", + "not_connected", + "rate_limited", + "unauthorized", + ].includes(error.code) + ) { + return false; + } + return failureCount < 1; +} + +export function useClickUpConnection(pubkey: string | undefined) { + return useQuery({ + queryKey: clickUpKeys.connection(pubkey ?? "pending"), + queryFn: getClickUpConnection, + enabled: Boolean(pubkey), + staleTime: 60_000, + retry: retryClickUpQuery, + }); +} + +export function useConnectClickUp(pubkey: string | undefined) { + const queryClient = useQueryClient(); + const [isPending, setIsPending] = React.useState(false); + const [error, setError] = React.useState(null); + + const connect = React.useCallback( + async (personalToken: string) => { + setIsPending(true); + setError(null); + try { + const connection = await connectClickUp(personalToken); + if (pubkey) { + queryClient.setQueryData(clickUpKeys.connection(pubkey), connection); + void queryClient.invalidateQueries({ + queryKey: clickUpKeys.workspaces(pubkey), + }); + } + } catch (caught) { + const nextError = + caught instanceof Error ? caught : new Error(String(caught)); + setError(nextError); + throw nextError; + } finally { + setIsPending(false); + } + }, + [pubkey, queryClient], + ); + + return { connect, error, isPending }; +} + +export function useDisconnectClickUp(pubkey: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: disconnectClickUp, + onSuccess: () => { + if (!pubkey) return; + queryClient.removeQueries({ queryKey: clickUpKeys.root(pubkey) }); + queryClient.setQueryData(clickUpKeys.connection(pubkey), { + connected: false, + account: null, + }); + }, + }); +} + +export function useClickUpWorkspaces( + pubkey: string | undefined, + connected: boolean, +) { + return useQuery({ + queryKey: clickUpKeys.workspaces(pubkey ?? "pending"), + queryFn: listClickUpWorkspaces, + enabled: Boolean(pubkey && connected), + staleTime: 5 * 60_000, + retry: retryClickUpQuery, + }); +} + +export function useClickUpTasks( + pubkey: string | undefined, + workspaceId: string | undefined, +) { + return useQuery({ + queryKey: clickUpKeys.tasks(pubkey ?? "pending", workspaceId ?? "pending"), + queryFn: () => listClickUpTasks(workspaceId ?? ""), + enabled: Boolean(pubkey && workspaceId), + staleTime: 60_000, + retry: retryClickUpQuery, + }); +} + +export function useClickUpTask( + pubkey: string | undefined, + workspaceId: string | undefined, + taskId: string | null, +) { + return useQuery({ + queryKey: clickUpKeys.task( + pubkey ?? "pending", + workspaceId ?? "pending", + taskId ?? "pending", + ), + queryFn: () => getClickUpTask(workspaceId ?? "", taskId ?? ""), + enabled: Boolean(pubkey && workspaceId && taskId), + staleTime: 60_000, + retry: retryClickUpQuery, + }); +} + +export function useClickUpTaskComments( + pubkey: string | undefined, + workspaceId: string | undefined, + taskId: string | null, +) { + return useQuery({ + queryKey: clickUpKeys.comments( + pubkey ?? "pending", + workspaceId ?? "pending", + taskId ?? "pending", + ), + queryFn: () => getClickUpTaskComments(workspaceId ?? "", taskId ?? ""), + enabled: Boolean(pubkey && workspaceId && taskId), + staleTime: 60_000, + retry: retryClickUpQuery, + }); +} diff --git a/desktop/src/features/clickup/lib/tasks.test.mjs b/desktop/src/features/clickup/lib/tasks.test.mjs new file mode 100644 index 0000000000..4dca1bf455 --- /dev/null +++ b/desktop/src/features/clickup/lib/tasks.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + filterClickUpTasks, + groupClickUpTasks, + taskUrgencyGroup, +} from "./tasks.ts"; + +const NOW = new Date(2026, 6, 31, 10, 0, 0); + +function task(id, name, dueDateMs, overrides = {}) { + return { + id, + name, + textContent: "", + description: "", + status: { status: "open", color: null, kind: "open" }, + priority: null, + dueDateMs, + startDateMs: null, + dateCreatedMs: null, + dateUpdatedMs: null, + archived: false, + parentId: null, + url: `https://app.clickup.com/t/${id}`, + workspaceId: "workspace-1", + list: { id: "list-1", name: "Inbox" }, + folder: null, + space: null, + assignees: [], + tags: [], + subtasks: [], + customFields: [], + dependencies: [], + ...overrides, + }; +} + +test("taskUrgencyGroup uses local calendar boundaries", () => { + assert.equal( + taskUrgencyGroup( + task("a", "A", String(new Date(2026, 6, 30, 23).getTime())), + NOW, + ), + "overdue", + ); + assert.equal( + taskUrgencyGroup( + task("b", "B", String(new Date(2026, 6, 31, 18).getTime())), + NOW, + ), + "today", + ); + assert.equal( + taskUrgencyGroup( + task("c", "C", String(new Date(2026, 7, 7, 18).getTime())), + NOW, + ), + "next-seven-days", + ); + assert.equal( + taskUrgencyGroup( + task("d", "D", String(new Date(2026, 7, 8, 0).getTime())), + NOW, + ), + "later", + ); + assert.equal(taskUrgencyGroup(task("e", "E", null), NOW), "no-due-date"); +}); + +test("groupClickUpTasks keeps urgency order data and sorts by due date", () => { + const earlier = task( + "earlier", + "Earlier", + String(new Date(2026, 6, 31, 12).getTime()), + ); + const later = task( + "later", + "Later", + String(new Date(2026, 6, 31, 20).getTime()), + ); + const groups = groupClickUpTasks([later, earlier], NOW); + assert.deepEqual( + groups.today.map((item) => item.id), + ["earlier", "later"], + ); +}); + +test("filterClickUpTasks combines local name, status, priority, location, and due filters", () => { + const urgent = task( + "urgent", + "Prepare board pack", + String(new Date(2026, 6, 31, 15).getTime()), + { + status: { status: "in progress", color: null, kind: "custom" }, + priority: { priority: "high", color: null }, + list: { id: "ops", name: "Operations" }, + }, + ); + const other = task("other", "Write notes", null); + const filtered = filterClickUpTasks( + [urgent, other], + { + search: "board", + status: "in progress", + priority: "high", + location: "ops", + dueWindow: "today", + }, + NOW, + ); + assert.deepEqual( + filtered.map((item) => item.id), + ["urgent"], + ); +}); diff --git a/desktop/src/features/clickup/lib/tasks.ts b/desktop/src/features/clickup/lib/tasks.ts new file mode 100644 index 0000000000..1295cf2856 --- /dev/null +++ b/desktop/src/features/clickup/lib/tasks.ts @@ -0,0 +1,129 @@ +import type { ClickUpTask } from "@/features/clickup/types"; + +export type ClickUpUrgencyGroup = + | "overdue" + | "today" + | "next-seven-days" + | "later" + | "no-due-date"; + +export const CLICKUP_URGENCY_GROUPS: Array<{ + key: ClickUpUrgencyGroup; + label: string; +}> = [ + { key: "overdue", label: "Overdue" }, + { key: "today", label: "Today" }, + { key: "next-seven-days", label: "Next 7 days" }, + { key: "later", label: "Later" }, + { key: "no-due-date", label: "No due date" }, +]; + +export type ClickUpTaskFilters = { + search: string; + status: string; + priority: string; + location: string; + dueWindow: "all" | ClickUpUrgencyGroup; +}; + +function startOfLocalDay(value: Date): number { + return new Date( + value.getFullYear(), + value.getMonth(), + value.getDate(), + ).getTime(); +} + +export function taskUrgencyGroup( + task: ClickUpTask, + now = new Date(), +): ClickUpUrgencyGroup { + if (!task.dueDateMs) return "no-due-date"; + const dueAt = Number(task.dueDateMs); + if (!Number.isFinite(dueAt)) return "no-due-date"; + + const today = startOfLocalDay(now); + const tomorrow = today + 24 * 60 * 60 * 1_000; + const afterNextSevenDays = today + 8 * 24 * 60 * 60 * 1_000; + + if (dueAt < today) return "overdue"; + if (dueAt < tomorrow) return "today"; + if (dueAt < afterNextSevenDays) return "next-seven-days"; + return "later"; +} + +export function taskLocationLabel(task: ClickUpTask): string { + return [task.space?.name, task.folder?.name, task.list?.name] + .filter(Boolean) + .join(" › "); +} + +export function filterClickUpTasks( + tasks: ClickUpTask[], + filters: ClickUpTaskFilters, + now = new Date(), +): ClickUpTask[] { + const search = filters.search.trim().toLocaleLowerCase(); + return tasks.filter((task) => { + if (search && !task.name.toLocaleLowerCase().includes(search)) return false; + if (filters.status !== "all" && task.status.status !== filters.status) + return false; + if ( + filters.priority !== "all" && + (task.priority?.priority ?? "none") !== filters.priority + ) + return false; + if (filters.location !== "all" && task.list?.id !== filters.location) + return false; + if ( + filters.dueWindow !== "all" && + taskUrgencyGroup(task, now) !== filters.dueWindow + ) + return false; + return true; + }); +} + +export function groupClickUpTasks( + tasks: ClickUpTask[], + now = new Date(), +): Record { + const groups: Record = { + overdue: [], + today: [], + "next-seven-days": [], + later: [], + "no-due-date": [], + }; + + for (const task of tasks) groups[taskUrgencyGroup(task, now)].push(task); + for (const group of Object.values(groups)) { + group.sort((left, right) => { + const leftDue = Number(left.dueDateMs ?? Number.POSITIVE_INFINITY); + const rightDue = Number(right.dueDateMs ?? Number.POSITIVE_INFINITY); + if (leftDue !== rightDue) return leftDue - rightDue; + return left.name.localeCompare(right.name); + }); + } + return groups; +} + +export function clickUpTaskFilterOptions(tasks: ClickUpTask[]) { + const statuses = [...new Set(tasks.map((task) => task.status.status))] + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + const priorities = [ + ...new Set(tasks.map((task) => task.priority?.priority ?? "none")), + ].sort((left, right) => left.localeCompare(right)); + const locations = [ + ...new Map( + tasks + .filter((task) => task.list) + .map((task) => [ + task.list?.id ?? "", + { id: task.list?.id ?? "", label: taskLocationLabel(task) }, + ]), + ).values(), + ].sort((left, right) => left.label.localeCompare(right.label)); + return { statuses, priorities, locations }; +} diff --git a/desktop/src/features/clickup/types.ts b/desktop/src/features/clickup/types.ts new file mode 100644 index 0000000000..de3efdc349 --- /dev/null +++ b/desktop/src/features/clickup/types.ts @@ -0,0 +1,127 @@ +export type ClickUpUser = { + id: number; + username: string; + email: string | null; + color: string | null; + profilePicture: string | null; + initials: string | null; +}; + +export type ClickUpConnection = { + connected: boolean; + account: ClickUpUser | null; +}; + +export type ClickUpWorkspace = { + id: string; + name: string; + color: string | null; + avatar: string | null; +}; + +export type ClickUpTaskStatus = { + status: string; + color: string | null; + kind: string | null; +}; + +export type ClickUpTaskPriority = { + priority: string; + color: string | null; +}; + +export type ClickUpTaskLocation = { + id: string; + name: string; +}; + +export type ClickUpCustomField = { + id: string; + name: string; + type: string; + value: unknown; +}; + +export type ClickUpDependency = { + taskId: string | null; + dependsOn: string | null; + dependencyOf: string | null; +}; + +export type ClickUpTask = { + id: string; + name: string; + textContent: string; + description: string; + status: ClickUpTaskStatus; + priority: ClickUpTaskPriority | null; + dueDateMs: string | null; + startDateMs: string | null; + dateCreatedMs: string | null; + dateUpdatedMs: string | null; + archived: boolean; + parentId: string | null; + url: string; + workspaceId: string; + list: ClickUpTaskLocation | null; + folder: ClickUpTaskLocation | null; + space: ClickUpTaskLocation | null; + assignees: ClickUpUser[]; + tags: Array<{ + name: string; + foreground: string | null; + background: string | null; + }>; + subtasks: ClickUpTask[]; + customFields: ClickUpCustomField[]; + dependencies: ClickUpDependency[]; +}; + +export type ClickUpTaskPage = { + tasks: ClickUpTask[]; + fetchedAtMs: number; + truncated: boolean; +}; + +export type ClickUpComment = { + id: string; + text: string; + dateMs: string | null; + user: ClickUpUser | null; + resolved: boolean; +}; + +export type ClickUpComments = { + comments: ClickUpComment[]; +}; + +export type ClickUpErrorCode = + | "forbidden" + | "identity_changed" + | "invalid_request" + | "invalid_token" + | "keyring_unavailable" + | "network" + | "not_connected" + | "rate_limited" + | "redirect_rejected" + | "response_too_large" + | "server" + | "unauthorized" + | "unknown"; + +export class ClickUpApiError extends Error { + readonly code: ClickUpErrorCode; + readonly retryAtMs: number | null; + + constructor( + code: ClickUpErrorCode, + message: string, + retryAtMs: number | null = null, + ) { + super(message); + this.name = "ClickUpApiError"; + this.code = code; + this.retryAtMs = retryAtMs; + } +} diff --git a/desktop/src/features/clickup/ui/ClickUpConnectCard.tsx b/desktop/src/features/clickup/ui/ClickUpConnectCard.tsx new file mode 100644 index 0000000000..421eb3c717 --- /dev/null +++ b/desktop/src/features/clickup/ui/ClickUpConnectCard.tsx @@ -0,0 +1,114 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { KeyRound, ShieldCheck } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; +import { Input } from "@/shared/ui/input"; + +const CLICKUP_APPS_SETTINGS_URL = "https://app.clickup.com/settings/apps"; + +type ClickUpConnectCardProps = { + errorMessage?: string | null; + isPending: boolean; + onConnect: (token: string) => Promise; +}; + +export function ClickUpConnectCard({ + errorMessage, + isPending, + onConnect, +}: ClickUpConnectCardProps) { + const [token, setToken] = React.useState(""); + + const handleSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + const value = token.trim(); + if (!value) return; + setToken(""); + try { + await onConnect(value); + } catch { + // The parent renders the typed connection error. Sensitive values are + // cleared before native IPC and must be entered again after failure. + } + }, + [onConnect, token], + ); + + return ( +
+ + +
+ +
+
+ Connect ClickUp locally +

+ Connect a personal token to see tasks assigned to you. This + prototype only reads ClickUp data. +

+
+
+ +
+
+ + setToken(event.target.value)} + placeholder="pk_…" + type="password" + value={token} + /> +

+ The token is sent once to the native Buzz process, verified + against ClickUp, and stored in your operating system keyring. It + is never returned to this screen. +

+
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ + +
+
+
+ +

+ Buzz will not create, update, comment on, move, or delete ClickUp + tasks in this version. Disconnecting only removes the local + credential. +

+
+
+
+
+ ); +} diff --git a/desktop/src/features/clickup/ui/ClickUpScreen.tsx b/desktop/src/features/clickup/ui/ClickUpScreen.tsx new file mode 100644 index 0000000000..d66582d69d --- /dev/null +++ b/desktop/src/features/clickup/ui/ClickUpScreen.tsx @@ -0,0 +1,437 @@ +import * as React from "react"; +import { LogOut, ShieldCheck } from "lucide-react"; + +import { + useClickUpConnection, + useClickUpTasks, + useClickUpWorkspaces, + useConnectClickUp, + useDisconnectClickUp, +} from "@/features/clickup/hooks"; +import type { ClickUpTaskFilters } from "@/features/clickup/lib/tasks"; +import { ClickUpApiError } from "@/features/clickup/types"; +import { ClickUpConnectCard } from "@/features/clickup/ui/ClickUpConnectCard"; +import { ClickUpTaskDetail } from "@/features/clickup/ui/ClickUpTaskDetail"; +import { ClickUpTaskList } from "@/features/clickup/ui/ClickUpTaskList"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { topChromeInset } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/ui/alert-dialog"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { PageHeader } from "@/shared/ui/PageHeader"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/shared/ui/sheet"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const EMPTY_FILTERS: ClickUpTaskFilters = { + search: "", + status: "all", + priority: "all", + location: "all", + dueWindow: "all", +}; + +function workspaceStorageKey(pubkey: string) { + return `buzz.clickup.last-workspace.v1:${pubkey.toLowerCase()}`; +} + +function readStoredWorkspace(pubkey: string) { + try { + return window.localStorage.getItem(workspaceStorageKey(pubkey)); + } catch { + return null; + } +} + +function writeStoredWorkspace(pubkey: string, workspaceId: string) { + try { + window.localStorage.setItem(workspaceStorageKey(pubkey), workspaceId); + } catch { + // Workspace preference is optional; task data and credentials are not stored here. + } +} + +function connectionErrorCopy(error: Error | null) { + if (!(error instanceof ClickUpApiError)) return error?.message ?? null; + if (error.code === "unauthorized" || error.code === "invalid_token") + return "Buzz can no longer access this ClickUp account. Enter a valid personal token to reconnect."; + if (error.code === "keyring_unavailable") + return "Secure keyring storage is unavailable. Buzz will not store a ClickUp token without it."; + if (error.code === "network") + return "Buzz could not reach ClickUp. Check your connection and try again."; + return error.message; +} + +function retryAtFromError(error: unknown) { + return error instanceof ClickUpApiError ? error.retryAtMs : null; +} + +function requiresCredentialEntry(error: unknown) { + return ( + error instanceof ClickUpApiError && + [ + "identity_changed", + "invalid_token", + "not_connected", + "unauthorized", + ].includes(error.code) + ); +} + +function useNarrowLayout() { + const [isNarrow, setIsNarrow] = React.useState( + () => window.matchMedia("(max-width: 1023px)").matches, + ); + + React.useEffect(() => { + const query = window.matchMedia("(max-width: 1023px)"); + const update = () => setIsNarrow(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return isNarrow; +} + +export function ClickUpScreen() { + const identityQuery = useIdentityQuery(); + const pubkey = identityQuery.data?.pubkey; + const connectionQuery = useClickUpConnection(pubkey); + const connection = connectionQuery.data; + const connectMutation = useConnectClickUp(pubkey); + const disconnectMutation = useDisconnectClickUp(pubkey); + const workspacesQuery = useClickUpWorkspaces( + pubkey, + connection?.connected ?? false, + ); + const [selectedWorkspaceId, setSelectedWorkspaceId] = React.useState< + string | undefined + >(); + const [selectedTaskId, setSelectedTaskId] = React.useState( + null, + ); + const [filters, setFilters] = + React.useState(EMPTY_FILTERS); + const tasksQuery = useClickUpTasks(pubkey, selectedWorkspaceId); + const retryAtMs = retryAtFromError(tasksQuery.error); + const [retryClock, setRetryClock] = React.useState(() => Date.now()); + const isNarrow = useNarrowLayout(); + + React.useEffect(() => { + if (!retryAtMs || retryAtMs <= Date.now()) return; + const timer = window.setTimeout( + () => setRetryClock(Date.now()), + retryAtMs - Date.now() + 50, + ); + return () => window.clearTimeout(timer); + }, [retryAtMs]); + + React.useEffect(() => { + if (!pubkey || !workspacesQuery.data?.length) return; + const available = workspacesQuery.data; + const stored = readStoredWorkspace(pubkey); + const next = available.some( + (workspace) => workspace.id === selectedWorkspaceId, + ) + ? selectedWorkspaceId + : available.some((workspace) => workspace.id === stored) + ? (stored ?? available[0]?.id) + : available[0]?.id; + if (next && next !== selectedWorkspaceId) setSelectedWorkspaceId(next); + }, [pubkey, selectedWorkspaceId, workspacesQuery.data]); + + React.useEffect(() => { + if (!pubkey || !selectedWorkspaceId) return; + writeStoredWorkspace(pubkey, selectedWorkspaceId); + setSelectedTaskId(null); + setFilters(EMPTY_FILTERS); + }, [pubkey, selectedWorkspaceId]); + + const connect = React.useCallback( + async (token: string) => { + await connectMutation.connect(token); + }, + [connectMutation.connect], + ); + + if (identityQuery.isLoading || (connectionQuery.isLoading && !connection)) { + return ; + } + + const connectionError = connectionQuery.error ?? connectMutation.error; + const connectionFailure = connectionErrorCopy(connectionError); + + if ( + !connection?.connected && + (!connectionQuery.error || requiresCredentialEntry(connectionError)) + ) { + return ( +
+ +
+ ); + } + + if (!connection?.connected) { + return ( +
+ +
+

+ ClickUp is temporarily unavailable +

+

+ {connectionFailure ?? + "Buzz could not verify the local ClickUp connection."} +

+
+ +
+
+ ); + } + + const workspaces = workspacesQuery.data ?? []; + const selectedWorkspace = workspaces.find( + (workspace) => workspace.id === selectedWorkspaceId, + ); + const tasksError = + tasksQuery.error instanceof ClickUpApiError ? tasksQuery.error : null; + + return ( +
+
+
+
+ + + + Read-only + + + + + + + + Disconnect ClickUp? + + Buzz will remove the ClickUp token stored for this + Buzz identity. No ClickUp tasks or account settings + will be changed. + + + + Cancel + disconnectMutation.mutate()} + > + Disconnect ClickUp + + + + +
+ } + description="Your assigned ClickUp work, available as a reference surface inside Buzz." + title="ClickUp" + /> + + {connectionQuery.error ? ( +
+ {connectionFailure} + +
+ ) : null} + + {disconnectMutation.error ? ( +
+ + ClickUp is still connected because Buzz could not remove the + local credential. + + +
+ ) : null} + +
+
+

+ {connection.account?.username ?? "Connected account"} +

+

+ {connection.account?.email ?? "ClickUp personal token"} +

+
+ +
+ + {workspacesQuery.isError ? ( +
+ Could not load authorized ClickUp Workspaces. + +
+ ) : null} + + {workspacesQuery.isSuccess && workspaces.length === 0 ? ( +
+ This connected account has no available Workspaces. +
+ ) : null} + + {selectedWorkspace ? ( +
+ void tasksQuery.refetch()} + onSelectTask={setSelectedTaskId} + retryReady={!retryAtMs || retryClock >= retryAtMs} + selectedTaskId={selectedTaskId} + tasks={tasksQuery.data?.tasks ?? []} + truncated={tasksQuery.data?.truncated ?? false} + /> + {selectedTaskId && pubkey && !isNarrow ? ( + setSelectedTaskId(null)} + pubkey={pubkey} + taskId={selectedTaskId} + workspaceId={selectedWorkspace.id} + /> + ) : ( + + )} + {pubkey && isNarrow ? ( + { + if (!open) setSelectedTaskId(null); + }} + open={Boolean(selectedTaskId)} + > + + + ClickUp task details + + Read-only details for the selected ClickUp task. + + + {selectedTaskId ? ( + setSelectedTaskId(null)} + pubkey={pubkey} + taskId={selectedTaskId} + workspaceId={selectedWorkspace.id} + /> + ) : null} + + + ) : null} +
+ ) : null} +
+
+
+ + ); +} diff --git a/desktop/src/features/clickup/ui/ClickUpTaskDetail.tsx b/desktop/src/features/clickup/ui/ClickUpTaskDetail.tsx new file mode 100644 index 0000000000..b3c2063c81 --- /dev/null +++ b/desktop/src/features/clickup/ui/ClickUpTaskDetail.tsx @@ -0,0 +1,312 @@ +import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ArrowUpRight, MessageSquareText, X } from "lucide-react"; + +import { + useClickUpTask, + useClickUpTaskComments, +} from "@/features/clickup/hooks"; +import { taskLocationLabel } from "@/features/clickup/lib/tasks"; +import type { ClickUpCustomField } from "@/features/clickup/types"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type ClickUpTaskDetailProps = { + onClose: () => void; + pubkey: string; + taskId: string; + workspaceId: string; +}; + +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +function formatTimestamp(value: string | null) { + if (!value) return "Not set"; + const timestamp = Number(value); + return Number.isFinite(timestamp) + ? dateTimeFormatter.format(new Date(timestamp)) + : "Not set"; +} + +function formatCustomFieldValue(field: ClickUpCustomField) { + if (field.value === null || field.value === undefined || field.value === "") + return null; + if (["string", "number", "boolean"].includes(typeof field.value)) + return String(field.value); + if (Array.isArray(field.value)) + return field.value.map((item) => String(item)).join(", "); + return null; +} + +function isSafeClickUpUrl(value: string) { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + (url.hostname === "clickup.com" || url.hostname.endsWith(".clickup.com")) + ); + } catch { + return false; + } +} + +function DetailSkeleton() { + return ( +
+ + + + +
+ ); +} + +export function ClickUpTaskDetail({ + onClose, + pubkey, + taskId, + workspaceId, +}: ClickUpTaskDetailProps) { + const taskQuery = useClickUpTask(pubkey, workspaceId, taskId); + const commentsQuery = useClickUpTaskComments(pubkey, workspaceId, taskId); + const task = taskQuery.data; + const headingRef = React.useRef(null); + const headingId = `clickup-task-detail-heading-${taskId}`; + + React.useEffect(() => { + if (task) headingRef.current?.focus(); + }, [task]); + + const close = React.useCallback(() => { + const trigger = document.getElementById(`clickup-task-row-${taskId}`); + onClose(); + window.requestAnimationFrame(() => trigger?.focus()); + }, [onClose, taskId]); + const visibleFields = + task?.customFields + .map((field) => ({ field, value: formatCustomFieldValue(field) })) + .filter((entry): entry is { field: ClickUpCustomField; value: string } => + Boolean(entry.value), + ) ?? []; + + return ( + + ); +} diff --git a/desktop/src/features/clickup/ui/ClickUpTaskList.tsx b/desktop/src/features/clickup/ui/ClickUpTaskList.tsx new file mode 100644 index 0000000000..b9d9621fb9 --- /dev/null +++ b/desktop/src/features/clickup/ui/ClickUpTaskList.tsx @@ -0,0 +1,323 @@ +import { CalendarDays, ChevronRight, RefreshCw, Search } from "lucide-react"; + +import { + CLICKUP_URGENCY_GROUPS, + clickUpTaskFilterOptions, + filterClickUpTasks, + groupClickUpTasks, + taskLocationLabel, + type ClickUpTaskFilters, +} from "@/features/clickup/lib/tasks"; +import type { ClickUpApiError, ClickUpTask } from "@/features/clickup/types"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type ClickUpTaskListProps = { + error: ClickUpApiError | null; + fetchedAtMs: number | null; + filters: ClickUpTaskFilters; + isFetching: boolean; + isLoading: boolean; + onFiltersChange: (filters: ClickUpTaskFilters) => void; + onRefresh: () => void; + onSelectTask: (taskId: string) => void; + retryReady: boolean; + selectedTaskId: string | null; + tasks: ClickUpTask[]; + truncated: boolean; +}; + +const dueFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +const refreshedFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +function formatDueDate(value: string | null) { + if (!value) return "No due date"; + const timestamp = Number(value); + return Number.isFinite(timestamp) + ? dueFormatter.format(new Date(timestamp)) + : "No due date"; +} + +function errorCopy(error: ClickUpApiError) { + if (error.code === "rate_limited") + return "ClickUp is temporarily limiting requests. Your last loaded tasks are still shown."; + if (error.code === "forbidden") + return "This Workspace is not available to the connected account. Choose another Workspace."; + if (error.code === "network") + return "Buzz could not reach ClickUp. Your last loaded tasks are still shown when available."; + return error.message; +} + +function TaskListSkeleton() { + return ( +
+ {["first", "second", "third", "fourth"].map((key) => ( + +
+ + +
+ +
+ ))} +
+ ); +} + +export function ClickUpTaskList({ + error, + fetchedAtMs, + filters, + isFetching, + isLoading, + onFiltersChange, + onRefresh, + onSelectTask, + retryReady, + selectedTaskId, + tasks, + truncated, +}: ClickUpTaskListProps) { + const options = clickUpTaskFilterOptions(tasks); + const filteredTasks = filterClickUpTasks(tasks, filters); + const groupedTasks = groupClickUpTasks(filteredTasks); + const hasActiveFilters = + filters.search.trim().length > 0 || + filters.status !== "all" || + filters.priority !== "all" || + filters.location !== "all" || + filters.dueWindow !== "all"; + + const updateFilter = ( + key: Key, + value: ClickUpTaskFilters[Key], + ) => onFiltersChange({ ...filters, [key]: value }); + + return ( +
+ {truncated ? ( +
+ Results are incomplete because Buzz loaded the first 2,000 assigned + tasks. Open ClickUp to search the full Workspace. +
+ ) : null} +
+
+
+ + updateFilter("search", event.target.value)} + placeholder="Search loaded tasks" + value={filters.search} + /> +
+ +
+
+ + + + +
+
+ + {isLoading + ? "Loading your ClickUp tasks…" + : `${filteredTasks.length} open assigned ${filteredTasks.length === 1 ? "task" : "tasks"}`} + + {fetchedAtMs ? ( + Last refreshed {refreshedFormatter.format(fetchedAtMs)} + ) : null} +
+
+ + {error ? ( +
+ {errorCopy(error)} + +
+ ) : null} + +
+ {isLoading ? : null} +
+ + {!isLoading && + !(error && tasks.length === 0) && + filteredTasks.length === 0 ? ( + + +
+

+ {hasActiveFilters + ? "No tasks match these filters." + : "You have no open assigned tasks in this Workspace."} +

+ {hasActiveFilters ? ( + + ) : null} +
+
+ ) : null} + + {!isLoading + ? CLICKUP_URGENCY_GROUPS.map((group) => { + const groupTasks = groupedTasks[group.key]; + if (groupTasks.length === 0) return null; + return ( +
+
+

{group.label}

+ + {groupTasks.length} + +
+
+ {groupTasks.map((task) => ( + + ))} +
+
+ ); + }) + : null} +
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 55f467f215..482a41c4a6 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -103,6 +103,7 @@ type AppSidebarProps = { | "channel" | "messages" | "agents" + | "clickup" | "workflows" | "pulse" | "projects"; @@ -143,6 +144,7 @@ type AppSidebarProps = { onRemoveCommunity: (id: string) => void; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectClickUp: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -212,6 +214,7 @@ export function AppSidebar({ onRemoveCommunity, onCreateAgent, onSelectAgents, + onSelectClickUp, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -609,6 +612,7 @@ export function AppSidebar({ void; + onSelectClickUp: () => void; onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -83,6 +92,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, + onSelectClickUp, onSelectHome, onSelectProjects, onSelectPulse, @@ -143,6 +153,20 @@ export function AppSidebarPrimaryMenu({ + + + + + ClickUp + + + ; + subtasks?: RawClickUpTask[]; + custom_fields?: Array<{ + id?: string; + name?: string; + type?: string; + value?: unknown; + }>; + dependencies?: Array<{ + task_id?: string | null; + depends_on?: string | null; + dependency_of?: string | null; + }>; +}; + +type RawClickUpTaskPage = { + tasks: RawClickUpTask[]; + fetched_at_ms: number; + truncated: boolean; +}; + +type RawClickUpComment = { + id: string | number; + comment_text?: string | null; + comment?: Array<{ text?: string }>; + date?: string | null; + user?: RawClickUpUser | null; + resolved?: boolean; +}; + +type RawClickUpComments = { + comments: RawClickUpComment[]; +}; + +const KNOWN_ERROR_CODES = new Set([ + "forbidden", + "identity_changed", + "invalid_request", + "invalid_token", + "keyring_unavailable", + "network", + "not_connected", + "rate_limited", + "redirect_rejected", + "response_too_large", + "server", + "unauthorized", + "unknown", +]); + +function mapUser(user: RawClickUpUser): ClickUpUser { + return { + id: user.id, + username: user.username, + email: user.email ?? null, + color: user.color ?? null, + profilePicture: user.profile_picture ?? null, + initials: user.initials ?? null, + }; +} + +function mapLocation( + location: { id?: string; name?: string } | null | undefined, +) { + if (!location?.id || !location.name) return null; + return { id: location.id, name: location.name }; +} + +function mapDependency( + dependency: NonNullable[number], +): ClickUpDependency { + return { + taskId: dependency.task_id ?? null, + dependsOn: dependency.depends_on ?? null, + dependencyOf: dependency.dependency_of ?? null, + }; +} + +function mapTask(task: RawClickUpTask): ClickUpTask { + return { + id: task.id, + name: task.name, + textContent: task.text_content ?? "", + description: task.description ?? "", + status: { + status: task.status?.status ?? "Unknown", + color: task.status?.color ?? null, + kind: task.status?.type ?? null, + }, + priority: task.priority?.priority + ? { + priority: task.priority.priority, + color: task.priority.color ?? null, + } + : null, + dueDateMs: task.due_date ?? null, + startDateMs: task.start_date ?? null, + dateCreatedMs: task.date_created ?? null, + dateUpdatedMs: task.date_updated ?? null, + archived: task.archived ?? false, + parentId: task.parent ?? null, + url: task.url ?? "", + workspaceId: task.team_id ?? "", + list: mapLocation(task.list), + folder: mapLocation(task.folder), + space: mapLocation(task.space), + assignees: (task.assignees ?? []).map(mapUser), + tags: (task.tags ?? []) + .filter((tag) => Boolean(tag.name)) + .map((tag) => ({ + name: tag.name ?? "", + foreground: tag.tag_fg ?? null, + background: tag.tag_bg ?? null, + })), + subtasks: (task.subtasks ?? []).map(mapTask), + customFields: (task.custom_fields ?? []) + .filter((field) => Boolean(field.id && field.name && field.type)) + .map((field) => ({ + id: field.id ?? "", + name: field.name ?? "", + type: field.type ?? "", + value: field.value, + })), + dependencies: (task.dependencies ?? []).map(mapDependency), + }; +} + +function mapComment(comment: RawClickUpComment): ClickUpComment { + const parts = (comment.comment ?? []) + .map((part) => part.text?.trim() ?? "") + .filter(Boolean); + return { + id: String(comment.id), + text: comment.comment_text?.trim() || parts.join(" "), + dateMs: comment.date ?? null, + user: comment.user ? mapUser(comment.user) : null, + resolved: comment.resolved ?? false, + }; +} + +export function toClickUpApiError(error: unknown): ClickUpApiError { + if (error instanceof ClickUpApiError) return error; + const message = error instanceof Error ? error.message : String(error); + const match = /^clickup:([^:]+):([^:]*):(.*)$/s.exec(message); + if (!match) return new ClickUpApiError("unknown", message); + + const rawCode = match[1] ?? "unknown"; + const code = KNOWN_ERROR_CODES.has(rawCode as ClickUpErrorCode) + ? (rawCode as ClickUpErrorCode) + : "unknown"; + const parsedRetryAt = Number(match[2]); + return new ClickUpApiError( + code, + match[3] || "ClickUp request failed.", + Number.isFinite(parsedRetryAt) && parsedRetryAt > 0 ? parsedRetryAt : null, + ); +} + +async function clickUpInvoke( + command: string, + args?: Record, +): Promise { + try { + return await invokeTauri(command, args); + } catch (error) { + throw toClickUpApiError(error); + } +} + +export async function getClickUpConnection(): Promise { + const result = await clickUpInvoke( + "clickup_auth_status", + ); + return { + connected: result.connected, + account: result.account ? mapUser(result.account) : null, + }; +} + +export async function connectClickUp( + personalToken: string, +): Promise { + const result = await clickUpInvoke("clickup_connect", { + personalToken, + }); + return { + connected: result.connected, + account: result.account ? mapUser(result.account) : null, + }; +} + +export function disconnectClickUp(): Promise { + return clickUpInvoke("clickup_disconnect"); +} + +export async function listClickUpWorkspaces(): Promise { + const result = await clickUpInvoke( + "clickup_list_workspaces", + ); + return result.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + color: workspace.color ?? null, + avatar: workspace.avatar ?? null, + })); +} + +export async function listClickUpTasks( + workspaceId: string, +): Promise { + const result = await clickUpInvoke("clickup_list_tasks", { + workspaceId, + }); + return { + tasks: result.tasks.map(mapTask), + fetchedAtMs: result.fetched_at_ms, + truncated: result.truncated, + }; +} + +export async function getClickUpTask( + workspaceId: string, + taskId: string, +): Promise { + return mapTask( + await clickUpInvoke("clickup_get_task", { + workspaceId, + taskId, + }), + ); +} + +export async function getClickUpTaskComments( + workspaceId: string, + taskId: string, +): Promise { + const result = await clickUpInvoke( + "clickup_get_task_comments", + { workspaceId, taskId }, + ); + return { comments: result.comments.map(mapComment) }; +} diff --git a/desktop/src/shared/ui/ViewLoadingFallback.tsx b/desktop/src/shared/ui/ViewLoadingFallback.tsx index 5fe4359497..a6d7a3f6e3 100644 --- a/desktop/src/shared/ui/ViewLoadingFallback.tsx +++ b/desktop/src/shared/ui/ViewLoadingFallback.tsx @@ -7,6 +7,7 @@ import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; type ViewLoadingFallbackKind = | "agents" | "channel" + | "clickup" | "forum" | "projects" | "pulse" @@ -403,6 +404,7 @@ export function ViewLoadingFallback({ {kind === "agents" ? : null} {kind === "workflows" ? : null} {kind === "projects" ? : null} + {kind === "clickup" ? : null} {kind === "channel" ? ( ) : null} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc..73bf0d51ac 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -176,6 +176,22 @@ type E2eConfig = { name?: string; expiresAt: string; } | null; + /** Whether the local ClickUp personal-token prototype is connected. */ + clickupConnected?: boolean; + /** Typed native error returned while validating the local ClickUp connection. */ + clickupConnectionError?: string; + /** Typed native error returned while connecting a ClickUp token. */ + clickupConnectError?: string; + /** Typed native error returned while loading authorized Workspaces. */ + clickupWorkspacesError?: string; + /** Typed native error returned when the task list is requested. */ + clickupTasksError?: string; + /** Return no assigned ClickUp tasks. */ + clickupTasksEmpty?: boolean; + /** Typed native error returned when task comments are requested. */ + clickupCommentsError?: string; + /** Typed native error returned while disconnecting ClickUp. */ + clickupDisconnectError?: string; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { terms_markdown?: string; @@ -9942,11 +9958,15 @@ export function maybeInstallE2eTauriMocks() { return null; } })(); + const safePayload = + command === "clickup_connect" + ? { personalToken: "[REDACTED]" } + : loggedPayload; window.__BUZZ_E2E_COMMAND_PAYLOADS__?.push({ command, - payload: loggedPayload, + payload: safePayload, }); - window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); + window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload: safePayload }); switch (command) { case "get_huddle_state": { @@ -10206,6 +10226,164 @@ export function maybeInstallE2eTauriMocks() { registry: await handleMockCommand("list_voice_registry", null), }; } + case "clickup_auth_status": { + if (activeConfig?.mock?.clickupConnectionError) { + throw new Error(activeConfig.mock.clickupConnectionError); + } + const connected = activeConfig?.mock?.clickupConnected !== false; + return { + connected, + account: connected + ? { + id: 42, + username: "Mikes", + email: "mikes@example.com", + initials: "MS", + } + : null, + }; + } + case "clickup_connect": + if (activeConfig?.mock?.clickupConnectError) { + throw new Error(activeConfig.mock.clickupConnectError); + } + if (activeConfig?.mock) activeConfig.mock.clickupConnected = true; + return { + connected: true, + account: { + id: 42, + username: "Mikes", + email: "mikes@example.com", + initials: "MS", + }, + }; + case "clickup_disconnect": + if (activeConfig?.mock?.clickupDisconnectError) { + throw new Error(activeConfig.mock.clickupDisconnectError); + } + if (activeConfig?.mock) activeConfig.mock.clickupConnected = false; + return null; + case "clickup_list_workspaces": + if (activeConfig?.mock?.clickupWorkspacesError) { + throw new Error(activeConfig.mock.clickupWorkspacesError); + } + return [{ id: "workspace-1", name: "Operations", color: "#7b68ee" }]; + case "clickup_list_tasks": { + if (activeConfig?.mock?.clickupTasksError) { + throw new Error(activeConfig.mock.clickupTasksError); + } + const now = Date.now(); + const day = 24 * 60 * 60 * 1_000; + const task = ( + id: string, + name: string, + dueDate: number | null, + status: string, + priority: string, + ) => ({ + id, + name, + text_content: `${name} summary`, + description: `${name} details`, + status: { status, color: "#87909e", type: "custom" }, + priority: { priority, color: "#f9d900" }, + due_date: dueDate === null ? null : String(dueDate), + archived: false, + url: `https://app.clickup.com/t/${id}`, + team_id: "workspace-1", + list: { id: "list-1", name: "Launch" }, + folder: { id: "folder-1", name: "Product" }, + space: { id: "space-1", name: "Buzz" }, + assignees: [{ id: 42, username: "Mikes", initials: "MS" }], + tags: [], + subtasks: [], + custom_fields: [], + dependencies: [], + }); + return { + tasks: activeConfig?.mock?.clickupTasksEmpty + ? [] + : [ + task( + "task-overdue", + "Resolve launch blocker", + now - day, + "in progress", + "urgent", + ), + task( + "task-today", + "Review onboarding copy", + now, + "open", + "high", + ), + task( + "task-week", + "Prepare rollout checklist", + now + 3 * day, + "open", + "normal", + ), + task( + "task-later", + "Plan OAuth broker", + now + 21 * day, + "backlog", + "low", + ), + task( + "task-undated", + "Capture follow-up ideas", + null, + "open", + "normal", + ), + ], + fetched_at_ms: now, + truncated: false, + }; + } + case "clickup_get_task": { + const id = (payload as { taskId?: string })?.taskId ?? "task-overdue"; + return { + id, + name: + id === "task-overdue" ? "Resolve launch blocker" : "ClickUp task", + text_content: "Confirm the local read-only path before rollout.", + description: + "Verify the route, keyring boundary, pagination, and error states.", + status: { status: "in progress", color: "#87909e", type: "custom" }, + priority: { priority: "urgent", color: "#f50000" }, + due_date: String(Date.now() - 24 * 60 * 60 * 1_000), + archived: false, + url: `https://app.clickup.com/t/${id}`, + team_id: "workspace-1", + list: { id: "list-1", name: "Launch" }, + folder: { id: "folder-1", name: "Product" }, + space: { id: "space-1", name: "Buzz" }, + assignees: [{ id: 42, username: "Mikes", initials: "MS" }], + tags: [{ name: "prototype", tag_fg: "#ffffff", tag_bg: "#7b68ee" }], + subtasks: [], + custom_fields: [], + dependencies: [], + }; + } + case "clickup_get_task_comments": + if (activeConfig?.mock?.clickupCommentsError) { + throw new Error(activeConfig.mock.clickupCommentsError); + } + return { + comments: [ + { + id: "comment-1", + comment_text: "Ready for a focused read-only review.", + date: String(Date.now() - 60 * 60 * 1_000), + user: { id: 42, username: "Mikes", initials: "MS" }, + resolved: false, + }, + ], + }; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/clickup.spec.ts b/desktop/tests/e2e/clickup.spec.ts new file mode 100644 index 0000000000..9e9cfd6360 --- /dev/null +++ b/desktop/tests/e2e/clickup.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test("shows assigned ClickUp work grouped by urgency and opens details", async ({ + page, +}) => { + await installMockBridge(page, { clickupConnected: true }); + await page.goto("/"); + + await page.getByTestId("open-clickup-view").click(); + + await expect(page).toHaveURL(/#\/clickup$/); + await expect(page.getByRole("heading", { name: "ClickUp" })).toBeVisible(); + await expect(page.getByText("Read-only", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Select ClickUp Workspace")).toHaveValue( + "workspace-1", + ); + await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Today" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Next 7 days" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Later" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "No due date" }), + ).toBeVisible(); + + await page.getByTestId("clickup-task-task-overdue").click(); + + const detail = page.getByTestId("clickup-task-detail"); + await expect(detail).toContainText("Resolve launch blocker"); + await expect(detail).toContainText( + "Verify the route, keyring boundary, pagination, and error states.", + ); + await expect(detail).toContainText("Ready for a focused read-only review."); + await expect( + detail.getByRole("button", { name: "Open in ClickUp" }), + ).toBeEnabled(); +}); + +test("connects locally without recording the personal token in E2E logs", async ({ + page, +}) => { + await installMockBridge(page, { clickupConnected: false }); + await page.goto("/#/clickup"); + + const secret = "pk_test-secret-never-log"; + await page.getByTestId("clickup-token-input").fill(secret); + await page.getByTestId("connect-clickup").click(); + + await expect(page.getByText("Read-only", { exact: true })).toBeVisible(); + await expect(page.getByTestId("clickup-connect-card")).not.toBeVisible(); + + const logs = await page.evaluate(() => ({ + commands: window.__BUZZ_E2E_COMMAND_PAYLOADS__, + log: window.__BUZZ_E2E_COMMAND_LOG__, + })); + const serialized = JSON.stringify(logs); + expect(serialized).not.toContain(secret); + expect(serialized).toContain("[REDACTED]"); +}); + +test("keeps transient connection failures out of the credential form", async ({ + page, +}) => { + await installMockBridge(page, { + clickupConnected: true, + clickupConnectionError: "clickup:network::Buzz could not reach ClickUp.", + }); + await page.goto("/#/clickup"); + + await expect( + page.getByRole("heading", { name: "ClickUp is temporarily unavailable" }), + ).toBeVisible(); + await expect(page.getByTestId("clickup-connect-card")).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("clears a rejected personal token before native IPC settles", async ({ + page, +}) => { + await installMockBridge(page, { + clickupConnected: false, + clickupConnectError: + "clickup:unauthorized::ClickUp rejected the personal token.", + }); + await page.goto("/#/clickup"); + + const secret = "pk_rejected-secret-never-retain"; + const input = page.getByTestId("clickup-token-input"); + await input.fill(secret); + await page.getByTestId("connect-clickup").click(); + + await expect(input).toHaveValue(""); + await expect(page.getByRole("alert")).toContainText( + "Enter a valid personal token", + ); + const serialized = JSON.stringify( + await page.evaluate(() => window.__BUZZ_E2E_COMMAND_LOG__), + ); + expect(serialized).not.toContain(secret); +}); + +test("does not show an empty-success state when the first task read fails", async ({ + page, +}) => { + await installMockBridge(page, { + clickupConnected: true, + clickupTasksError: "clickup:server::ClickUp is temporarily unavailable.", + }); + await page.goto("/#/clickup"); + + await expect(page.getByTestId("clickup-task-error")).toBeVisible(); + await expect( + page.getByText("You have no open assigned tasks in this Workspace."), + ).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("opens narrow-window details in a focused dialog", async ({ page }) => { + await page.setViewportSize({ width: 800, height: 700 }); + await installMockBridge(page, { clickupConnected: true }); + await page.goto("/#/clickup"); + + const row = page.getByTestId("clickup-task-task-overdue"); + await row.click(); + + const dialog = page.getByRole("dialog", { name: "ClickUp task details" }); + await expect(dialog).toBeVisible(); + const heading = dialog.getByRole("heading", { + name: "Resolve launch blocker", + }); + await expect(heading).toBeFocused(); + await dialog.getByRole("button", { name: "Close task details" }).click(); + await expect(row).toBeFocused(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8a9ab2be11..26c37722c2 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -174,6 +174,22 @@ type MockBridgeOptions = { huddle?: MockHuddleSeed; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; + /** Whether the local ClickUp personal-token prototype is connected. */ + clickupConnected?: boolean; + /** Typed native error returned while validating the local ClickUp connection. */ + clickupConnectionError?: string; + /** Typed native error returned while connecting a ClickUp token. */ + clickupConnectError?: string; + /** Typed native error returned while loading authorized Workspaces. */ + clickupWorkspacesError?: string; + /** Typed native error returned when the task list is requested. */ + clickupTasksError?: string; + /** Return no assigned ClickUp tasks. */ + clickupTasksEmpty?: boolean; + /** Typed native error returned when task comments are requested. */ + clickupCommentsError?: string; + /** Typed native error returned while disconnecting ClickUp. */ + clickupDisconnectError?: string; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { terms_markdown?: string; diff --git a/preview-features.json b/preview-features.json index 388f1c39b0..cffb3a9fd8 100644 --- a/preview-features.json +++ b/preview-features.json @@ -13,6 +13,12 @@ "description": "Git repository browser and collaboration", "platforms": ["desktop"] }, + { + "id": "clickup", + "name": "ClickUp", + "description": "Read-only view of your assigned ClickUp work", + "platforms": ["desktop"] + }, { "id": "pulse", "name": "Pulse",