From a6401e3b79f9f5cf403d0c9428f5b931c475bc3f Mon Sep 17 00:00:00 2001 From: kjuulh Date: Mon, 9 Mar 2026 22:34:04 +0100 Subject: [PATCH] feat: add integrations Signed-off-by: kjuulh --- .../console-2026-03-09T19-50-53-447Z.log | 1 + crates/forage-core/src/integrations/mod.rs | 186 ++++ crates/forage-core/src/integrations/nats.rs | 25 + crates/forage-core/src/integrations/router.rs | 847 +++++++++++++++--- crates/forage-db/src/integrations.rs | 216 ++++- ...0004_slack_user_links_and_message_refs.sql | 29 + ...9000005_slack_message_ref_destinations.sql | 3 + .../src/grpc/forest/v1/forest.v1.rs | 2 + crates/forage-server/src/auth.rs | 74 +- crates/forage-server/src/forest_client.rs | 39 + crates/forage-server/src/main.rs | 14 +- .../src/notification_consumer.rs | 9 +- .../forage-server/src/notification_worker.rs | 435 ++++++++- crates/forage-server/src/routes/auth.rs | 388 ++++++-- .../forage-server/src/routes/integrations.rs | 426 ++++++++- crates/forage-server/src/state.rs | 2 +- crates/forage-server/src/tests/mod.rs | 1 + crates/forage-server/src/tests/nats_tests.rs | 30 +- crates/forage-server/src/tests/slack_tests.rs | 624 +++++++++++++ .../src/tests/webhook_delivery_tests.rs | 28 +- .../features/006-notification-integrations.md | 2 +- static/css/style.css | 2 +- templates/pages/account.html.jinja | 41 + templates/pages/install_slack.html.jinja | 6 +- templates/pages/integration_detail.html.jinja | 8 + templates/pages/integrations.html.jinja | 13 +- 26 files changed, 3207 insertions(+), 244 deletions(-) create mode 100644 .playwright-mcp/console-2026-03-09T19-50-53-447Z.log create mode 100644 crates/forage-db/src/migrations/20260309000004_slack_user_links_and_message_refs.sql create mode 100644 crates/forage-db/src/migrations/20260309000005_slack_message_ref_destinations.sql create mode 100644 crates/forage-server/src/tests/slack_tests.rs diff --git a/.playwright-mcp/console-2026-03-09T19-50-53-447Z.log b/.playwright-mcp/console-2026-03-09T19-50-53-447Z.log new file mode 100644 index 0000000..efa63a9 --- /dev/null +++ b/.playwright-mcp/console-2026-03-09T19-50-53-447Z.log @@ -0,0 +1 @@ +[ 227ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://client.dev.forage.sh/favicon.ico:0 diff --git a/crates/forage-core/src/integrations/mod.rs b/crates/forage-core/src/integrations/mod.rs index 37ef400..fb649b7 100644 --- a/crates/forage-core/src/integrations/mod.rs +++ b/crates/forage-core/src/integrations/mod.rs @@ -97,6 +97,48 @@ pub const NOTIFICATION_TYPES: &[&str] = &[ "release_failed", ]; +// ── Slack user links ───────────────────────────────────────────────── + +/// Links a Forage user to their Slack identity in a workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlackUserLink { + pub id: String, + pub user_id: String, // Forage/Forest user ID + pub team_id: String, // Slack workspace ID + pub team_name: String, // Slack workspace name (display) + pub slack_user_id: String, // Slack user ID (U-xxx) + pub slack_username: String, // Slack display name + pub created_at: String, +} + +/// Per-destination deployment status within a release. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DestinationStatus { + pub environment: String, + pub status: String, // "started", "succeeded", "failed" + pub error: Option, +} + +/// Tracks a posted Slack message so we can update it in-place. +/// One ref per (integration, release_slug) — accumulates all destinations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlackMessageRef { + pub id: String, + pub integration_id: String, + pub release_id: String, // release slug (shared across destinations) + pub channel_id: String, // Slack channel where posted + pub message_ts: String, // Slack message timestamp (for chat.update) + pub last_event_type: String, // Last event that updated this message + /// Accumulated per-destination statuses. Key = destination name. + #[serde(default)] + pub destinations: HashMap, + /// Cached release title for message rebuilds. + #[serde(default)] + pub release_title: String, + pub created_at: String, + pub updated_at: String, +} + // ── Delivery log ───────────────────────────────────────────────────── /// Record of a notification delivery attempt. @@ -244,11 +286,60 @@ pub trait IntegrationStore: Send + Sync { limit: usize, ) -> Result, IntegrationError>; + /// Update the configuration (and optionally the name) of an existing integration. + async fn update_integration_config( + &self, + organisation: &str, + id: &str, + name: &str, + config: &IntegrationConfig, + ) -> Result<(), IntegrationError>; + /// Look up an integration by its API token hash. Used for API authentication. async fn get_integration_by_token_hash( &self, token_hash: &str, ) -> Result; + + // ── Slack user links ────────────────────────────────────────────── + + /// Get the Slack user link for a Forage user in a given workspace, if any. + async fn get_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result, IntegrationError>; + + /// Create or update the Slack user link for a given (user_id, team_id) pair. + async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError>; + + /// Remove the Slack user link for a given (user_id, team_id) pair. + async fn delete_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result<(), IntegrationError>; + + /// List all Slack user links for a Forage user (one per connected workspace). + async fn list_slack_user_links( + &self, + user_id: &str, + ) -> Result, IntegrationError>; + + // ── Slack message refs ──────────────────────────────────────────── + + /// Get the Slack message ref for a release in a specific integration, if any. + async fn get_slack_message_ref( + &self, + integration_id: &str, + release_id: &str, + ) -> Result, IntegrationError>; + + /// Create or update the Slack message ref for a given (integration_id, release_id) pair. + async fn upsert_slack_message_ref( + &self, + msg_ref: &SlackMessageRef, + ) -> Result<(), IntegrationError>; } // ── Token generation ──────────────────────────────────────────────── @@ -319,6 +410,8 @@ pub struct InMemoryIntegrationStore { deliveries: std::sync::Mutex>, /// Stores token_hash -> integration_id for lookup. token_hashes: std::sync::Mutex>, + slack_user_links: std::sync::Mutex>, + slack_message_refs: std::sync::Mutex>, } impl InMemoryIntegrationStore { @@ -328,6 +421,8 @@ impl InMemoryIntegrationStore { rules: std::sync::Mutex::new(Vec::new()), deliveries: std::sync::Mutex::new(Vec::new()), token_hashes: std::sync::Mutex::new(HashMap::new()), + slack_user_links: std::sync::Mutex::new(Vec::new()), + slack_message_refs: std::sync::Mutex::new(Vec::new()), } } } @@ -453,6 +548,23 @@ impl IntegrationStore for InMemoryIntegrationStore { Ok(()) } + async fn update_integration_config( + &self, + organisation: &str, + id: &str, + name: &str, + config: &IntegrationConfig, + ) -> Result<(), IntegrationError> { + let mut store = self.integrations.lock().unwrap(); + let integ = store + .iter_mut() + .find(|i| i.id == id && i.organisation == organisation) + .ok_or_else(|| IntegrationError::NotFound(id.to_string()))?; + integ.name = name.to_string(); + integ.config = config.clone(); + Ok(()) + } + async fn list_rules( &self, integration_id: &str, @@ -564,6 +676,80 @@ impl IntegrationStore for InMemoryIntegrationStore { .cloned() .ok_or(IntegrationError::NotFound(id)) } + + async fn get_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result, IntegrationError> { + let links = self.slack_user_links.lock().unwrap(); + Ok(links + .iter() + .find(|l| l.user_id == user_id && l.team_id == team_id) + .cloned()) + } + + async fn upsert_slack_user_link(&self, link: &SlackUserLink) -> Result<(), IntegrationError> { + let mut links = self.slack_user_links.lock().unwrap(); + if let Some(existing) = links + .iter_mut() + .find(|l| l.user_id == link.user_id && l.team_id == link.team_id) + { + *existing = link.clone(); + } else { + links.push(link.clone()); + } + Ok(()) + } + + async fn delete_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result<(), IntegrationError> { + let mut links = self.slack_user_links.lock().unwrap(); + links.retain(|l| !(l.user_id == user_id && l.team_id == team_id)); + Ok(()) + } + + async fn list_slack_user_links( + &self, + user_id: &str, + ) -> Result, IntegrationError> { + let links = self.slack_user_links.lock().unwrap(); + Ok(links + .iter() + .filter(|l| l.user_id == user_id) + .cloned() + .collect()) + } + + async fn get_slack_message_ref( + &self, + integration_id: &str, + release_id: &str, + ) -> Result, IntegrationError> { + let refs = self.slack_message_refs.lock().unwrap(); + Ok(refs + .iter() + .find(|r| r.integration_id == integration_id && r.release_id == release_id) + .cloned()) + } + + async fn upsert_slack_message_ref( + &self, + msg_ref: &SlackMessageRef, + ) -> Result<(), IntegrationError> { + let mut refs = self.slack_message_refs.lock().unwrap(); + if let Some(existing) = refs.iter_mut().find(|r| { + r.integration_id == msg_ref.integration_id && r.release_id == msg_ref.release_id + }) { + *existing = msg_ref.clone(); + } else { + refs.push(msg_ref.clone()); + } + Ok(()) + } } #[cfg(test)] diff --git a/crates/forage-core/src/integrations/nats.rs b/crates/forage-core/src/integrations/nats.rs index 501010e..775d1f0 100644 --- a/crates/forage-core/src/integrations/nats.rs +++ b/crates/forage-core/src/integrations/nats.rs @@ -20,11 +20,21 @@ pub struct NotificationEnvelope { pub struct ReleaseContextEnvelope { pub slug: String, pub artifact_id: String, + #[serde(default)] + pub release_intent_id: String, pub destination: String, pub environment: String, pub source_username: String, + #[serde(default)] + pub source_user_id: String, pub commit_sha: String, pub commit_branch: String, + #[serde(default)] + pub context_title: String, + #[serde(default)] + pub context_web: String, + #[serde(default)] + pub destination_count: i32, pub error_message: Option, } @@ -41,11 +51,16 @@ impl From<&NotificationEvent> for NotificationEnvelope { release: e.release.as_ref().map(|r| ReleaseContextEnvelope { slug: r.slug.clone(), artifact_id: r.artifact_id.clone(), + release_intent_id: r.release_intent_id.clone(), destination: r.destination.clone(), environment: r.environment.clone(), source_username: r.source_username.clone(), + source_user_id: r.source_user_id.clone(), commit_sha: r.commit_sha.clone(), commit_branch: r.commit_branch.clone(), + context_title: r.context_title.clone(), + context_web: r.context_web.clone(), + destination_count: r.destination_count, error_message: r.error_message.clone(), }), } @@ -65,11 +80,16 @@ impl From for NotificationEvent { release: e.release.map(|r| ReleaseContext { slug: r.slug, artifact_id: r.artifact_id, + release_intent_id: r.release_intent_id, destination: r.destination, environment: r.environment, source_username: r.source_username, + source_user_id: r.source_user_id, commit_sha: r.commit_sha, commit_branch: r.commit_branch, + context_title: r.context_title, + context_web: r.context_web, + destination_count: r.destination_count, error_message: r.error_message, }), } @@ -107,11 +127,16 @@ mod tests { release: Some(ReleaseContext { slug: "v1.2.3".into(), artifact_id: "art_123".into(), + release_intent_id: "ri_1".into(), destination: "prod-eu".into(), environment: "production".into(), source_username: "alice".into(), + source_user_id: "alice_id".into(), commit_sha: "abc1234def".into(), commit_branch: "main".into(), + context_title: "Release failed".into(), + context_web: String::new(), + destination_count: 3, error_message: Some("health check timeout".into()), }), } diff --git a/crates/forage-core/src/integrations/router.rs b/crates/forage-core/src/integrations/router.rs index 272bcf2..fa2c5e0 100644 --- a/crates/forage-core/src/integrations/router.rs +++ b/crates/forage-core/src/integrations/router.rs @@ -19,11 +19,16 @@ pub struct NotificationEvent { pub struct ReleaseContext { pub slug: String, pub artifact_id: String, + pub release_intent_id: String, pub destination: String, pub environment: String, pub source_username: String, + pub source_user_id: String, pub commit_sha: String, pub commit_branch: String, + pub context_title: String, + pub context_web: String, + pub destination_count: i32, pub error_message: Option, } @@ -38,9 +43,29 @@ pub enum DispatchTask { headers: std::collections::HashMap, payload: WebhookPayload, }, + /// Slack channel message via bot token (supports update-in-place). + /// Falls back to webhook_url if access_token is empty. Slack { integration_id: String, webhook_url: String, + access_token: String, + channel_id: String, + release_id: String, + notification_id: String, + event_type: String, + /// The full event, needed to rebuild the message after merging destination state. + event: NotificationEvent, + message: SlackMessage, + }, + /// Personal DM to a user who linked their Slack account. + SlackDm { + integration_id: String, + access_token: String, + slack_user_id: String, + release_id: String, + notification_id: String, + event_type: String, + event: NotificationEvent, message: SlackMessage, }, } @@ -74,11 +99,28 @@ pub fn route_notification( headers: headers.clone(), payload: payload.clone(), }, - IntegrationConfig::Slack { webhook_url, .. } => { - let message = format_slack_message(event); + IntegrationConfig::Slack { + webhook_url, + access_token, + channel_id, + .. + } => { + let message = format_slack_message(event, &std::collections::HashMap::new(), ""); + // Group by release slug (shared across all destinations in a release) + let release_id = event + .release + .as_ref() + .map(|r| r.slug.clone()) + .unwrap_or_default(); DispatchTask::Slack { integration_id: integration.id.clone(), webhook_url: webhook_url.clone(), + access_token: access_token.clone(), + channel_id: channel_id.clone(), + release_id, + notification_id: event.id.clone(), + event_type: event.notification_type.clone(), + event: event.clone(), message, } } @@ -86,21 +128,105 @@ pub fn route_notification( .collect() } -/// Find matching integrations and produce dispatch tasks. +/// Find matching integrations and produce dispatch tasks (channel + DM). pub async fn route_notification_for_org( store: &dyn IntegrationStore, event: &NotificationEvent, ) -> Vec { - match store + let integrations = match store .list_matching_integrations(&event.organisation, &event.notification_type) .await { - Ok(integrations) => route_notification(event, &integrations), + Ok(i) => i, Err(e) => { tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations"); - vec![] + return vec![]; + } + }; + + let mut tasks = route_notification(event, &integrations); + + // Produce personal DM tasks for the release owner (if they linked Slack) + if let Some(release) = &event.release { + tracing::debug!( + source_user_id = %release.source_user_id, + source_username = %release.source_username, + "DM routing: checking release owner" + ); + } + // Only DM on actual deploy events, not bare annotations + let dm_event_types = ["release_started", "release_succeeded", "release_failed"]; + if let Some(release) = event.release.as_ref().filter(|r| { + !r.source_user_id.is_empty() && dm_event_types.contains(&event.notification_type.as_str()) + }) { + let slack_count = integrations.iter().filter(|i| matches!(&i.config, IntegrationConfig::Slack { .. })).count(); + tracing::debug!( + total_integrations = integrations.len(), + slack_integrations = slack_count, + "DM routing: iterating integrations for DM lookup" + ); + // For each Slack integration with a bot token, check if the author linked that workspace + for integration in &integrations { + if let IntegrationConfig::Slack { + team_id, + access_token, + .. + } = &integration.config + { + tracing::debug!( + integration_id = %integration.id, + team_id = %team_id, + has_token = !access_token.is_empty(), + "DM routing: checking slack integration" + ); + if access_token.is_empty() || team_id.is_empty() { + continue; // manual webhook, no bot token + } + // Look up the release author's Slack link for this workspace + match store + .get_slack_user_link(&release.source_user_id, team_id) + .await + { + Ok(Some(link)) => { + tracing::info!( + user_id = %release.source_user_id, + team_id = %team_id, + slack_user_id = %link.slack_user_id, + "DM routing: found slack link, creating DM task" + ); + let message = format_slack_message(event, &std::collections::HashMap::new(), ""); + tasks.push(DispatchTask::SlackDm { + integration_id: integration.id.clone(), + access_token: access_token.clone(), + slack_user_id: link.slack_user_id, + release_id: release.slug.clone(), + notification_id: event.id.clone(), + event_type: event.notification_type.clone(), + event: event.clone(), + message, + }); + } + Ok(None) => { + tracing::debug!( + user_id = %release.source_user_id, + team_id = %team_id, + "DM routing: no slack link found for user in this workspace" + ); + } + Err(e) => { + tracing::warn!( + user = %release.source_user_id, + team_id = %team_id, + error = %e, + "failed to look up slack user link for DM" + ); + } + } + } } } + + tasks } fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload { @@ -125,125 +251,100 @@ fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload { } } -fn format_slack_message(event: &NotificationEvent) -> SlackMessage { - let color = match event.notification_type.as_str() { - "release_succeeded" => "#36a64f", - "release_failed" => "#dc3545", - "release_started" => "#0d6efd", - "release_annotated" => "#6c757d", - _ => "#6c757d", +/// Build a compact Slack message showing release progress across destinations. +/// +/// `forage_url` is the base URL for deep links (e.g. "https://client.dev.forage.sh"). +/// When `accumulated` is non-empty, renders all known destination statuses. +/// When empty (first message or webhook fallback), shows just the current event's destination. +pub fn format_slack_message( + event: &NotificationEvent, + accumulated: &std::collections::HashMap, + forage_url: &str, +) -> SlackMessage { + let release = event.release.as_ref(); + + // Determine aggregate color from accumulated destinations + let color = if accumulated.is_empty() { + match event.notification_type.as_str() { + "release_succeeded" => "#36a64f", + "release_failed" => "#dc3545", + "release_started" => "#0d6efd", + "release_annotated" => "#6c757d", + _ => "#6c757d", + } + } else { + aggregate_color(accumulated) }; - let status_emoji = match event.notification_type.as_str() { - "release_succeeded" => ":white_check_mark:", - "release_failed" => ":x:", - "release_started" => ":rocket:", - "release_annotated" => ":memo:", - _ => ":bell:", - }; + let title = &event.title; + + let status_emoji = aggregate_emoji(if accumulated.is_empty() { + &event.notification_type + } else { + return format_accumulated_message(event, color, accumulated, forage_url); + }); // Fallback text (shown in notifications/previews) - let text = format!("{} {}", status_emoji, event.title); + let text = format!("{status_emoji} {title}"); - // Build Block Kit blocks let mut blocks: Vec = Vec::new(); - // Header - blocks.push(serde_json::json!({ - "type": "header", - "text": { - "type": "plain_text", - "text": event.title, - "emoji": true - } - })); + // Header: emoji + title, with "View Release" button + let release_url = release + .map(|r| build_release_url(event, r, forage_url)) + .unwrap_or_default(); - // Body section (if present) - if !event.body.is_empty() { + let mut header_block = serde_json::json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("{status_emoji} *{title}*") + } + }); + if !release_url.is_empty() { + header_block["accessory"] = build_view_button(&release_url); + } + blocks.push(header_block); + + // Commit/change title + if let Some(r) = release.filter(|r| !r.context_title.is_empty()) { blocks.push(serde_json::json!({ "type": "section", - "text": { - "type": "mrkdwn", - "text": event.body - } + "text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) } })); } - // Release metadata fields - if let Some(ref r) = event.release { - let mut fields = vec![ - serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Organisation*\n{}", event.organisation) - }), - serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Project*\n{}", event.project) - }), - ]; + // Metadata line + if let Some(r) = release { + blocks.push(build_metadata_context(event, r, forage_url)); + blocks.push(serde_json::json!({ "type": "divider" })); + // Single destination status if !r.destination.is_empty() { - fields.push(serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Destination*\n`{}`", r.destination) - })); - } - - if !r.environment.is_empty() { - fields.push(serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Environment*\n{}", r.environment) - })); - } - - if !r.commit_sha.is_empty() { - let short_sha = &r.commit_sha[..r.commit_sha.len().min(7)]; - fields.push(serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Commit*\n`{}`", short_sha) - })); - } - - if !r.commit_branch.is_empty() { - fields.push(serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Branch*\n`{}`", r.commit_branch) - })); - } - - if !r.source_username.is_empty() { - fields.push(serde_json::json!({ - "type": "mrkdwn", - "text": format!("*Author*\n{}", r.source_username) - })); - } - - blocks.push(serde_json::json!({ - "type": "section", - "fields": fields - })); - - // Error message (if any) - if let Some(ref err) = r.error_message { + let dest_emoji = match event.notification_type.as_str() { + "release_succeeded" => ":white_check_mark:", + "release_failed" => ":x:", + "release_started" => ":arrows_counterclockwise:", + _ => ":bell:", + }; + let status_label = match event.notification_type.as_str() { + "release_succeeded" => "Deployed", + "release_failed" => "Failed", + "release_started" => "Deploying", + "release_annotated" => "Annotated", + _ => "Unknown", + }; + let mut dest_line = format!("{dest_emoji} `{}` {status_label}", r.destination); + if let Some(ref err) = r.error_message { + dest_line.push_str(&format!(" — _{err}_")); + } blocks.push(serde_json::json!({ "type": "section", - "text": { - "type": "mrkdwn", - "text": format!(":warning: *Error:* {}", err) - } + "text": { "type": "mrkdwn", "text": dest_line } })); } } - // Context line with timestamp - blocks.push(serde_json::json!({ - "type": "context", - "elements": [{ - "type": "mrkdwn", - "text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp) - }] - })); - SlackMessage { text, color: color.to_string(), @@ -251,6 +352,340 @@ fn format_slack_message(event: &NotificationEvent) -> SlackMessage { } } +/// Render the full multi-destination message from accumulated state. +fn format_accumulated_message( + event: &NotificationEvent, + color: &str, + destinations: &std::collections::HashMap, + forage_url: &str, +) -> SlackMessage { + let release = event.release.as_ref(); + let title = &event.title; + let emoji = aggregate_emoji_from_destinations(destinations); + let text = format!("{emoji} {title}"); + + let mut blocks: Vec = Vec::new(); + + // Header with "View Release" button + let release_url = release + .map(|r| build_release_url(event, r, forage_url)) + .unwrap_or_default(); + + let mut header_block = serde_json::json!({ + "type": "section", + "text": { "type": "mrkdwn", "text": format!("{emoji} *{title}*") } + }); + if !release_url.is_empty() { + header_block["accessory"] = build_view_button(&release_url); + } + blocks.push(header_block); + + // Commit/change title (e.g. "fix: correct timezone handling in cron scheduler (#80)") + if let Some(r) = release.filter(|r| !r.context_title.is_empty()) { + blocks.push(serde_json::json!({ + "type": "section", + "text": { "type": "mrkdwn", "text": format!(":memo: {}", r.context_title) } + })); + } + + // Metadata + if let Some(r) = release { + blocks.push(build_metadata_context(event, r, forage_url)); + } + + blocks.push(serde_json::json!({ "type": "divider" })); + + // Destination progress line + let done = destinations + .values() + .filter(|d| d.status == "succeeded" || d.status == "failed") + .count(); + let total = destinations.len(); + + // Compact destination line: ✅ dev ✅ staging 🔄 prod (2/3) + let mut dest_parts: Vec = Vec::new(); + let mut sorted_dests: Vec<_> = destinations.iter().collect(); + sorted_dests.sort_by_key(|(name, _)| name.as_str()); + + for (name, status) in &sorted_dests { + let emoji = match status.status.as_str() { + "succeeded" => ":white_check_mark:", + "failed" => ":x:", + "started" => ":arrows_counterclockwise:", + _ => ":hourglass:", + }; + dest_parts.push(format!("{emoji} {name}")); + } + + let dest_count = format!(" ({done}/{total})"); + + blocks.push(serde_json::json!({ + "type": "section", + "text": { "type": "mrkdwn", "text": format!("{}{dest_count}", dest_parts.join(" ")) } + })); + + // Show errors inline if any failed + for (name, status) in &sorted_dests { + if let Some(ref err) = status.error { + blocks.push(serde_json::json!({ + "type": "context", + "elements": [{ "type": "mrkdwn", "text": format!(":warning: *{name}:* {err}") }] + })); + } + } + + SlackMessage { + text, + color: color.to_string(), + blocks, + } +} + +/// Build the metadata context block shared by both message formats. +fn build_metadata_context( + event: &NotificationEvent, + r: &ReleaseContext, + _forage_url: &str, +) -> serde_json::Value { + let mut parts = Vec::new(); + + parts.push(format!("*{}* / {}", event.project, r.slug)); + + if !r.commit_branch.is_empty() { + parts.push(format!("`{}`", r.commit_branch)); + } + if !r.commit_sha.is_empty() { + parts.push(format!("`{}`", &r.commit_sha[..r.commit_sha.len().min(7)])); + } + if !r.source_username.is_empty() { + parts.push(r.source_username.clone()); + } + + // Source link (e.g. GitHub commit/PR) + if !r.context_web.is_empty() { + parts.push(format!("<{}|source>", r.context_web)); + } + + serde_json::json!({ + "type": "context", + "elements": [{ "type": "mrkdwn", "text": parts.join(" · ") }] + }) +} + +/// Build the release URL for deep linking. +fn build_release_url( + event: &NotificationEvent, + r: &ReleaseContext, + forage_url: &str, +) -> String { + if forage_url.is_empty() { + return String::new(); + } + format!( + "{}/orgs/{}/projects/{}/releases/{}", + forage_url.trim_end_matches('/'), + event.organisation, + event.project, + r.slug, + ) +} + +/// Build a "View Release" button block for the header section accessory. +fn build_view_button(url: &str) -> serde_json::Value { + serde_json::json!({ + "type": "button", + "text": { "type": "plain_text", "text": "View Release" }, + "url": url, + }) +} + +/// Build Slack blocks for pipeline stage progress. +/// Renders each stage as a line: emoji + label (e.g. "✅ Deployed to `dev`", "⏳ Wait 5s"). +/// Stages are topologically sorted by `depends_on` to match pipeline execution order. +pub fn format_pipeline_blocks( + stages: &[crate::platform::PipelineRunStageState], +) -> Vec { + if stages.is_empty() { + return Vec::new(); + } + + // Topological sort by depends_on + let sorted = topo_sort_stages(stages); + + let mut lines: Vec = Vec::new(); + + for stage in &sorted { + let emoji = match stage.status.as_str() { + "SUCCEEDED" => ":white_check_mark:", + "RUNNING" => ":arrows_counterclockwise:", + "FAILED" => ":x:", + "CANCELLED" => ":no_entry_sign:", + _ => ":radio_button:", // PENDING + }; + + let label = match stage.stage_type.as_str() { + "deploy" => { + let env = stage.environment.as_deref().unwrap_or("unknown"); + match stage.status.as_str() { + "SUCCEEDED" => format!("Deployed to `{env}`"), + "RUNNING" => format!("Deploying to `{env}`"), + "FAILED" => format!("Deploy to `{env}` failed"), + _ => format!("Deploy to `{env}`"), + } + } + "wait" => { + let duration = stage.duration_seconds.unwrap_or(0); + let dur_str = if duration >= 60 { + format!("{}m", duration / 60) + } else { + format!("{duration}s") + }; + match stage.status.as_str() { + "SUCCEEDED" => format!("Waited {dur_str}"), + "RUNNING" => format!("Waiting {dur_str}"), + _ => format!("Wait {dur_str}"), + } + } + _ => format!("Stage {}", stage.stage_id), + }; + + let mut line = format!("{emoji} {label}"); + if let Some(ref err) = stage.error_message { + line.push_str(&format!(" — _{err}_")); + } + lines.push(line); + } + + let done = stages + .iter() + .filter(|s| s.status == "SUCCEEDED" || s.status == "FAILED" || s.status == "CANCELLED") + .count(); + let total = stages.len(); + + let mut blocks = Vec::new(); + + // Pipeline header + stages + blocks.push(serde_json::json!({ + "type": "section", + "text": { "type": "mrkdwn", "text": lines.join("\n") } + })); + + // Progress count + blocks.push(serde_json::json!({ + "type": "context", + "elements": [{ "type": "mrkdwn", "text": format!("{done}/{total} stages complete") }] + })); + + // Divider before destinations + blocks.push(serde_json::json!({ "type": "divider" })); + + blocks +} + +/// Topological sort of pipeline stages by `depends_on`. +/// Falls back to input order if the graph has issues. +fn topo_sort_stages( + stages: &[crate::platform::PipelineRunStageState], +) -> Vec { + use std::collections::{HashMap, VecDeque}; + + // Build in-degree map + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + + for s in stages { + in_degree.entry(s.stage_id.as_str()).or_insert(0); + for dep in &s.depends_on { + dependents + .entry(dep.as_str()) + .or_default() + .push(s.stage_id.as_str()); + *in_degree.entry(s.stage_id.as_str()).or_insert(0) += 1; + } + } + + // Kahn's algorithm + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|&(_, deg)| *deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut sorted_ids: Vec = Vec::with_capacity(stages.len()); + while let Some(id) = queue.pop_front() { + sorted_ids.push(id.to_string()); + if let Some(deps) = dependents.get(id) { + for &dep in deps { + if let Some(deg) = in_degree.get_mut(dep) { + *deg -= 1; + if *deg == 0 { + queue.push_back(dep); + } + } + } + } + } + + // Build result in sorted order, falling back to input order for missing + let id_to_idx: HashMap<&str, usize> = sorted_ids + .iter() + .enumerate() + .map(|(i, id)| (id.as_str(), i)) + .collect(); + + let mut indexed: Vec<(usize, &crate::platform::PipelineRunStageState)> = stages + .iter() + .map(|s| { + let idx = id_to_idx + .get(s.stage_id.as_str()) + .copied() + .unwrap_or(usize::MAX); + (idx, s) + }) + .collect(); + indexed.sort_by_key(|(idx, _)| *idx); + + indexed.into_iter().map(|(_, s)| s.clone()).collect() +} + +fn aggregate_color( + destinations: &std::collections::HashMap, +) -> &'static str { + let has_failed = destinations.values().any(|d| d.status == "failed"); + let has_started = destinations.values().any(|d| d.status == "started"); + if has_failed { + "#dc3545" // red + } else if has_started { + "#0d6efd" // blue (still in progress) + } else { + "#36a64f" // green (all done) + } +} + +fn aggregate_emoji(event_type: &str) -> &'static str { + match event_type { + "release_succeeded" => ":white_check_mark:", + "release_failed" => ":x:", + "release_started" => ":rocket:", + "release_annotated" => ":memo:", + _ => ":bell:", + } +} + +fn aggregate_emoji_from_destinations( + destinations: &std::collections::HashMap, +) -> &'static str { + let has_failed = destinations.values().any(|d| d.status == "failed"); + let has_started = destinations.values().any(|d| d.status == "started"); + if has_failed { + ":x:" + } else if has_started { + ":rocket:" + } else { + ":white_check_mark:" + } +} + #[cfg(test)] mod tests { use super::*; @@ -268,11 +703,16 @@ mod tests { release: Some(ReleaseContext { slug: "test-release".into(), artifact_id: "art_123".into(), + release_intent_id: "ri_1".into(), destination: "prod-eu".into(), environment: "production".into(), source_username: "alice".into(), + source_user_id: "alice_id".into(), commit_sha: "abc1234def".into(), commit_branch: "main".into(), + context_title: "Release failed".into(), + context_web: String::new(), + destination_count: 3, error_message: Some("health check timeout".into()), }), } @@ -384,16 +824,211 @@ mod tests { fn slack_message_color_success() { let mut event = test_event(); event.notification_type = "release_succeeded".into(); - let msg = format_slack_message(&event); + let msg = format_slack_message(&event, &HashMap::new(), ""); assert_eq!(msg.color, "#36a64f"); } #[test] fn slack_message_includes_error() { let event = test_event(); - let msg = format_slack_message(&event); + let msg = format_slack_message(&event, &HashMap::new(), ""); // Error message is rendered in blocks, not the fallback text field let blocks_str = serde_json::to_string(&msg.blocks).unwrap(); assert!(blocks_str.contains("health check timeout")); } + + #[test] + fn slack_message_accumulated_shows_all_destinations() { + let event = test_event(); + let mut dests = HashMap::new(); + dests.insert("prod-eu".into(), super::super::DestinationStatus { + environment: "production".into(), + status: "succeeded".into(), + error: None, + }); + dests.insert("staging".into(), super::super::DestinationStatus { + environment: "staging".into(), + status: "started".into(), + error: None, + }); + let msg = format_slack_message(&event, &dests, ""); + // Should be blue (still deploying) + assert_eq!(msg.color, "#0d6efd"); + let blocks_str = serde_json::to_string(&msg.blocks).unwrap(); + assert!(blocks_str.contains("prod-eu")); + assert!(blocks_str.contains("staging")); + } + + #[test] + fn slack_message_accumulated_all_succeeded() { + let mut event = test_event(); + // Set destination_count to match the 2 destinations we provide + if let Some(ref mut r) = event.release { + r.destination_count = 2; + } + let mut dests = HashMap::new(); + dests.insert("prod-eu".into(), super::super::DestinationStatus { + environment: "production".into(), + status: "succeeded".into(), + error: None, + }); + dests.insert("staging".into(), super::super::DestinationStatus { + environment: "staging".into(), + status: "succeeded".into(), + error: None, + }); + let msg = format_slack_message(&event, &dests, ""); + assert_eq!(msg.color, "#36a64f"); // green — all done, no pending + } + + #[test] + fn slack_message_in_progress_is_blue() { + let event = test_event(); + let mut dests = HashMap::new(); + dests.insert("prod-eu".into(), super::super::DestinationStatus { + environment: "production".into(), + status: "started".into(), + error: None, + }); + // A destination still in progress → blue + let msg = format_slack_message(&event, &dests, ""); + assert_eq!(msg.color, "#0d6efd"); // blue — in progress + } + + #[test] + fn slack_message_accumulated_shows_errors() { + let event = test_event(); + let mut dests = HashMap::new(); + dests.insert("prod-eu".into(), super::super::DestinationStatus { + environment: "production".into(), + status: "failed".into(), + error: Some("OOM killed".into()), + }); + let msg = format_slack_message(&event, &dests, ""); + assert_eq!(msg.color, "#dc3545"); // red + let blocks_str = serde_json::to_string(&msg.blocks).unwrap(); + assert!(blocks_str.contains("OOM killed")); + } + + #[test] + fn pipeline_blocks_renders_stages() { + use crate::platform::PipelineRunStageState; + + let stages = vec![ + PipelineRunStageState { + stage_id: "s1".into(), + depends_on: vec![], + stage_type: "deploy".into(), + status: "SUCCEEDED".into(), + environment: Some("dev".into()), + duration_seconds: None, + queued_at: None, + started_at: None, + completed_at: None, + error_message: None, + wait_until: None, + release_ids: vec![], + }, + PipelineRunStageState { + stage_id: "s2".into(), + depends_on: vec!["s1".into()], + stage_type: "wait".into(), + status: "SUCCEEDED".into(), + environment: None, + duration_seconds: Some(3), + queued_at: None, + started_at: None, + completed_at: None, + error_message: None, + wait_until: None, + release_ids: vec![], + }, + PipelineRunStageState { + stage_id: "s3".into(), + depends_on: vec!["s2".into()], + stage_type: "deploy".into(), + status: "RUNNING".into(), + environment: Some("staging".into()), + duration_seconds: None, + queued_at: None, + started_at: None, + completed_at: None, + error_message: None, + wait_until: None, + release_ids: vec![], + }, + PipelineRunStageState { + stage_id: "s4".into(), + depends_on: vec!["s3".into()], + stage_type: "wait".into(), + status: "PENDING".into(), + environment: None, + duration_seconds: Some(5), + queued_at: None, + started_at: None, + completed_at: None, + error_message: None, + wait_until: None, + release_ids: vec![], + }, + PipelineRunStageState { + stage_id: "s5".into(), + depends_on: vec!["s4".into()], + stage_type: "deploy".into(), + status: "PENDING".into(), + environment: Some("prod".into()), + duration_seconds: None, + queued_at: None, + started_at: None, + completed_at: None, + error_message: None, + wait_until: None, + release_ids: vec![], + }, + ]; + + let blocks = format_pipeline_blocks(&stages); + assert_eq!(blocks.len(), 3); // stages block + progress context + divider + + let text = blocks[0]["text"]["text"].as_str().unwrap(); + assert!(text.contains("Deployed to `dev`")); + assert!(text.contains("Waited 3s")); + assert!(text.contains("Deploying to `staging`")); + assert!(text.contains("Wait 5s")); + assert!(text.contains("Deploy to `prod`")); + + let progress = blocks[1]["elements"][0]["text"].as_str().unwrap(); + assert_eq!(progress, "2/5 stages complete"); + } + + #[test] + fn pipeline_blocks_empty_stages_returns_nothing() { + let blocks = format_pipeline_blocks(&[]); + assert!(blocks.is_empty()); + } + + #[test] + fn pipeline_blocks_shows_errors() { + use crate::platform::PipelineRunStageState; + + let stages = vec![PipelineRunStageState { + stage_id: "s1".into(), + depends_on: vec![], + stage_type: "deploy".into(), + status: "FAILED".into(), + environment: Some("prod".into()), + duration_seconds: None, + queued_at: None, + started_at: None, + completed_at: None, + error_message: Some("OOM killed".into()), + wait_until: None, + release_ids: vec![], + }]; + + let blocks = format_pipeline_blocks(&stages); + let text = blocks[0]["text"]["text"].as_str().unwrap(); + assert!(text.contains("Deploy to `prod` failed")); + assert!(text.contains("OOM killed")); + } } diff --git a/crates/forage-db/src/integrations.rs b/crates/forage-db/src/integrations.rs index d033753..91c5c72 100644 --- a/crates/forage-db/src/integrations.rs +++ b/crates/forage-db/src/integrations.rs @@ -1,6 +1,7 @@ use forage_core::integrations::{ CreateIntegrationInput, DeliveryStatus, Integration, IntegrationConfig, IntegrationError, - IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, NOTIFICATION_TYPES, + IntegrationStore, IntegrationType, NotificationDelivery, NotificationRule, SlackMessageRef, + SlackUserLink, NOTIFICATION_TYPES, }; use sqlx::PgPool; use uuid::Uuid; @@ -221,6 +222,36 @@ impl IntegrationStore for PgIntegrationStore { Ok(()) } + async fn update_integration_config( + &self, + organisation: &str, + id: &str, + name: &str, + config: &IntegrationConfig, + ) -> Result<(), IntegrationError> { + let uuid: Uuid = id + .parse() + .map_err(|_| IntegrationError::NotFound(id.to_string()))?; + let encrypted = self.encrypt_config(config)?; + + let result = sqlx::query( + "UPDATE integrations SET name = $1, config_encrypted = $2, updated_at = NOW() + WHERE id = $3 AND organisation = $4", + ) + .bind(name) + .bind(&encrypted) + .bind(uuid) + .bind(organisation) + .execute(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + + if result.rows_affected() == 0 { + return Err(IntegrationError::NotFound(id.to_string())); + } + Ok(()) + } + async fn list_rules( &self, integration_id: &str, @@ -392,6 +423,189 @@ impl IntegrationStore for PgIntegrationStore { self.row_to_integration(row) } + + // ── Slack user links ───────────────────────────────────────── + + async fn get_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result, IntegrationError> { + let row: Option = sqlx::query_as( + "SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at + FROM slack_user_links WHERE user_id = $1 AND team_id = $2", + ) + .bind(user_id) + .bind(team_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + + Ok(row.map(|r| SlackUserLink { + id: r.id.to_string(), + user_id: r.user_id, + team_id: r.team_id, + team_name: r.team_name, + slack_user_id: r.slack_user_id, + slack_username: r.slack_username, + created_at: r.created_at.to_rfc3339(), + })) + } + + async fn upsert_slack_user_link( + &self, + link: &SlackUserLink, + ) -> Result<(), IntegrationError> { + sqlx::query( + "INSERT INTO slack_user_links (id, user_id, team_id, team_name, slack_user_id, slack_username, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id, team_id) DO UPDATE SET + slack_user_id = EXCLUDED.slack_user_id, + slack_username = EXCLUDED.slack_username, + team_name = EXCLUDED.team_name", + ) + .bind(Uuid::parse_str(&link.id).map_err(|e| IntegrationError::Store(e.to_string()))?) + .bind(&link.user_id) + .bind(&link.team_id) + .bind(&link.team_name) + .bind(&link.slack_user_id) + .bind(&link.slack_username) + .execute(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + Ok(()) + } + + async fn delete_slack_user_link( + &self, + user_id: &str, + team_id: &str, + ) -> Result<(), IntegrationError> { + sqlx::query("DELETE FROM slack_user_links WHERE user_id = $1 AND team_id = $2") + .bind(user_id) + .bind(team_id) + .execute(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + Ok(()) + } + + async fn list_slack_user_links( + &self, + user_id: &str, + ) -> Result, IntegrationError> { + let rows: Vec = sqlx::query_as( + "SELECT id, user_id, team_id, team_name, slack_user_id, slack_username, created_at + FROM slack_user_links WHERE user_id = $1 ORDER BY created_at", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|r| SlackUserLink { + id: r.id.to_string(), + user_id: r.user_id, + team_id: r.team_id, + team_name: r.team_name, + slack_user_id: r.slack_user_id, + slack_username: r.slack_username, + created_at: r.created_at.to_rfc3339(), + }) + .collect()) + } + + // ── Slack message refs ─────────────────────────────────────── + + async fn get_slack_message_ref( + &self, + integration_id: &str, + release_id: &str, + ) -> Result, IntegrationError> { + let iid = + Uuid::parse_str(integration_id).map_err(|e| IntegrationError::Store(e.to_string()))?; + let row: Option = sqlx::query_as( + "SELECT id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at + FROM slack_message_refs WHERE integration_id = $1 AND release_id = $2", + ) + .bind(iid) + .bind(release_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + + Ok(row.map(|r| SlackMessageRef { + id: r.id.to_string(), + integration_id: r.integration_id.to_string(), + release_id: r.release_id, + channel_id: r.channel_id, + message_ts: r.message_ts, + last_event_type: r.last_event_type, + destinations: serde_json::from_value(r.destinations).unwrap_or_default(), + release_title: r.release_title, + created_at: r.created_at.to_rfc3339(), + updated_at: r.updated_at.to_rfc3339(), + })) + } + + async fn upsert_slack_message_ref( + &self, + msg_ref: &SlackMessageRef, + ) -> Result<(), IntegrationError> { + let iid = Uuid::parse_str(&msg_ref.integration_id) + .map_err(|e| IntegrationError::Store(e.to_string()))?; + let destinations_json = serde_json::to_value(&msg_ref.destinations) + .map_err(|e| IntegrationError::Store(e.to_string()))?; + sqlx::query( + "INSERT INTO slack_message_refs (id, integration_id, release_id, channel_id, message_ts, last_event_type, destinations, release_title, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + ON CONFLICT (integration_id, release_id) DO UPDATE SET + message_ts = EXCLUDED.message_ts, + last_event_type = EXCLUDED.last_event_type, + destinations = EXCLUDED.destinations, + release_title = EXCLUDED.release_title, + updated_at = NOW()", + ) + .bind(Uuid::parse_str(&msg_ref.id).map_err(|e| IntegrationError::Store(e.to_string()))?) + .bind(iid) + .bind(&msg_ref.release_id) + .bind(&msg_ref.channel_id) + .bind(&msg_ref.message_ts) + .bind(&msg_ref.last_event_type) + .bind(destinations_json) + .bind(&msg_ref.release_title) + .execute(&self.pool) + .await + .map_err(|e| IntegrationError::Store(e.to_string()))?; + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct SlackUserLinkRow { + id: Uuid, + user_id: String, + team_id: String, + team_name: String, + slack_user_id: String, + slack_username: String, + created_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow)] +struct SlackMessageRefRow { + id: Uuid, + integration_id: Uuid, + release_id: String, + channel_id: String, + message_ts: String, + last_event_type: String, + destinations: serde_json::Value, + release_title: String, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, } #[derive(sqlx::FromRow)] diff --git a/crates/forage-db/src/migrations/20260309000004_slack_user_links_and_message_refs.sql b/crates/forage-db/src/migrations/20260309000004_slack_user_links_and_message_refs.sql new file mode 100644 index 0000000..9f18724 --- /dev/null +++ b/crates/forage-db/src/migrations/20260309000004_slack_user_links_and_message_refs.sql @@ -0,0 +1,29 @@ +-- Slack user identity links (user-level "Sign in with Slack") +CREATE TABLE IF NOT EXISTS slack_user_links ( + id UUID PRIMARY KEY, + user_id TEXT NOT NULL, + team_id TEXT NOT NULL, + team_name TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL, + slack_username TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, team_id) +); + +CREATE INDEX idx_slack_user_links_user ON slack_user_links(user_id); +CREATE INDEX idx_slack_user_links_team_slack ON slack_user_links(team_id, slack_user_id); + +-- Slack message refs for update-in-place pattern (one message per release) +CREATE TABLE IF NOT EXISTS slack_message_refs ( + id UUID PRIMARY KEY, + integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE, + release_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_ts TEXT NOT NULL, + last_event_type TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (integration_id, release_id) +); + +CREATE INDEX idx_slack_message_refs_lookup ON slack_message_refs(integration_id, release_id); diff --git a/crates/forage-db/src/migrations/20260309000005_slack_message_ref_destinations.sql b/crates/forage-db/src/migrations/20260309000005_slack_message_ref_destinations.sql new file mode 100644 index 0000000..aee74c9 --- /dev/null +++ b/crates/forage-db/src/migrations/20260309000005_slack_message_ref_destinations.sql @@ -0,0 +1,3 @@ +-- Add per-destination status tracking and release title to slack_message_refs +ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS destinations JSONB NOT NULL DEFAULT '{}'; +ALTER TABLE slack_message_refs ADD COLUMN IF NOT EXISTS release_title TEXT NOT NULL DEFAULT ''; diff --git a/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs index 8158643..7aca494 100644 --- a/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs +++ b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs @@ -572,6 +572,8 @@ pub struct ReleaseContext { /// Number of destinations involved #[prost(int32, tag="16")] pub destination_count: i32, + #[prost(string, tag="17")] + pub source_user_id: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Notification { diff --git a/crates/forage-server/src/auth.rs b/crates/forage-server/src/auth.rs index a7a670b..008ad10 100644 --- a/crates/forage-server/src/auth.rs +++ b/crates/forage-server/src/auth.rs @@ -69,19 +69,28 @@ impl FromRequestParts for Session { .get_user(&session_data.access_token) .await { - let orgs = state + // Preserve existing orgs on failure — a transient gRPC error + // should not wipe the cached org list. + let previous_orgs = session_data + .user + .as_ref() + .map(|u| u.orgs.clone()) + .unwrap_or_default(); + let orgs = match state .platform_client .list_my_organisations(&session_data.access_token) .await - .ok() - .unwrap_or_default() - .into_iter() - .map(|o| CachedOrg { - organisation_id: o.organisation_id, - name: o.name, - role: o.role, - }) - .collect(); + { + Ok(fresh) => fresh + .into_iter() + .map(|o| CachedOrg { + organisation_id: o.organisation_id, + name: o.name, + role: o.role, + }) + .collect(), + Err(_) => previous_orgs, + }; session_data.user = Some(CachedUser { user_id: user.user_id.clone(), username: user.username.clone(), @@ -99,11 +108,46 @@ impl FromRequestParts for Session { } } } else { - // Throttle last_seen_at writes: only update if older than 5 minutes - let now = chrono::Utc::now(); - if now - session_data.last_seen_at > chrono::Duration::minutes(5) { - session_data.last_seen_at = now; - let _ = state.sessions.update(&session_id, session_data.clone()).await; + // Backfill: if we have a user but empty orgs, try to fetch them. + // This handles the case where list_my_organisations failed during login. + let needs_org_backfill = session_data + .user + .as_ref() + .is_some_and(|u| u.orgs.is_empty()); + + if needs_org_backfill { + if let Ok(orgs) = state + .platform_client + .list_my_organisations(&session_data.access_token) + .await + { + if !orgs.is_empty() { + if let Some(ref mut user) = session_data.user { + tracing::info!( + user_id = %user.user_id, + org_count = orgs.len(), + "backfilled empty org list" + ); + user.orgs = orgs + .into_iter() + .map(|o| CachedOrg { + organisation_id: o.organisation_id, + name: o.name, + role: o.role, + }) + .collect(); + } + session_data.last_seen_at = chrono::Utc::now(); + let _ = state.sessions.update(&session_id, session_data.clone()).await; + } + } + } else { + // Throttle last_seen_at writes: only update if older than 5 minutes + let now = chrono::Utc::now(); + if now - session_data.last_seen_at > chrono::Duration::minutes(5) { + session_data.last_seen_at = now; + let _ = state.sessions.update(&session_id, session_data.clone()).await; + } } } diff --git a/crates/forage-server/src/forest_client.rs b/crates/forage-server/src/forest_client.rs index 66348aa..20e0aa3 100644 --- a/crates/forage-server/src/forest_client.rs +++ b/crates/forage-server/src/forest_client.rs @@ -98,6 +98,45 @@ impl GrpcForestClient { fn authed_request(access_token: &str, msg: T) -> Result, AuthError> { bearer_request(access_token, msg).map_err(AuthError::Other) } + + /// Fetch release intent states using a service token (for background workers). + pub async fn get_release_intent_states_with_token( + &self, + service_token: &str, + organisation: &str, + project: Option<&str>, + include_completed: bool, + ) -> Result, String> { + let req = bearer_request( + service_token, + forage_grpc::GetReleaseIntentStatesRequest { + organisation: organisation.into(), + project: project.map(|p| p.into()), + include_completed, + }, + ) + .map_err(|e| format!("invalid token: {e}"))?; + + let resp = self + .release_client() + .get_release_intent_states(req) + .await + .map_err(|e| format!("gRPC: {e}"))?; + + Ok(resp + .into_inner() + .release_intents + .into_iter() + .map(|ri| forage_core::platform::ReleaseIntentState { + release_intent_id: ri.release_intent_id, + artifact_id: ri.artifact_id, + project: ri.project, + created_at: ri.created_at, + stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(), + steps: ri.steps.into_iter().map(convert_release_step_state).collect(), + }) + .collect()) + } } fn map_status(status: tonic::Status) -> AuthError { diff --git a/crates/forage-server/src/main.rs b/crates/forage-server/src/main.rs index 1bf68b9..522570a 100644 --- a/crates/forage-server/src/main.rs +++ b/crates/forage-server/src/main.rs @@ -167,13 +167,13 @@ async fn main() -> anyhow::Result<()> { std::env::var("SLACK_CLIENT_ID"), std::env::var("SLACK_CLIENT_SECRET"), ) { - let base_url = std::env::var("FORAGE_BASE_URL") + let redirect_host = std::env::var("SLACK_REDIRECT_HOST") .unwrap_or_else(|_| format!("http://localhost:{port}")); tracing::info!("Slack OAuth enabled"); state = state.with_slack_config(crate::state::SlackConfig { client_id, client_secret, - base_url, + redirect_host, }); } @@ -197,9 +197,15 @@ async fn main() -> anyhow::Result<()> { state = state.with_integration_store(store.clone()); if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") { + let forage_url = std::env::var("FORAGE_URL") + .or_else(|_| std::env::var("SLACK_REDIRECT_HOST")) + .unwrap_or_else(|_| format!("http://localhost:{port}")); + if let Some(ref js) = nats_jetstream { // JetStream mode: ingester publishes, consumer dispatches tracing::info!("starting notification pipeline (JetStream)"); + let grpc_for_consumer = forest_client.clone(); + let token_for_consumer = service_token.clone(); mad.add(notification_ingester::NotificationIngester { grpc: forest_client, jetstream: js.clone(), @@ -208,6 +214,9 @@ async fn main() -> anyhow::Result<()> { mad.add(notification_consumer::NotificationConsumer { jetstream: js.clone(), store: store.clone(), + forage_url, + grpc: grpc_for_consumer, + service_token: token_for_consumer, }); } else { // Fallback: direct dispatch (no durability) @@ -216,6 +225,7 @@ async fn main() -> anyhow::Result<()> { grpc: forest_client, store: store.clone(), service_token, + forage_url, }); } } else { diff --git a/crates/forage-server/src/notification_consumer.rs b/crates/forage-server/src/notification_consumer.rs index a80cdf0..0cb2c88 100644 --- a/crates/forage-server/src/notification_consumer.rs +++ b/crates/forage-server/src/notification_consumer.rs @@ -10,6 +10,7 @@ use forage_core::integrations::IntegrationStore; use notmad::{Component, ComponentInfo, MadError}; use tokio_util::sync::CancellationToken; +use crate::forest_client::GrpcForestClient; use crate::notification_worker::NotificationDispatcher; /// Background component that pulls notification events from NATS JetStream @@ -17,6 +18,9 @@ use crate::notification_worker::NotificationDispatcher; pub struct NotificationConsumer { pub jetstream: jetstream::Context, pub store: Arc, + pub forage_url: String, + pub grpc: Arc, + pub service_token: String, } impl Component for NotificationConsumer { @@ -25,7 +29,10 @@ impl Component for NotificationConsumer { } async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { - let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone())); + let dispatcher = Arc::new( + NotificationDispatcher::new(self.store.clone(), self.forage_url.clone()) + .with_grpc(self.grpc.clone(), self.service_token.clone()), + ); let mut backoff = 1u64; diff --git a/crates/forage-server/src/notification_worker.rs b/crates/forage-server/src/notification_worker.rs index 4998539..265f1b7 100644 --- a/crates/forage-server/src/notification_worker.rs +++ b/crates/forage-server/src/notification_worker.rs @@ -15,15 +15,26 @@ use crate::forest_client::GrpcForestClient; pub struct NotificationDispatcher { http: reqwest::Client, store: Arc, + forage_url: String, + /// gRPC client for querying pipeline state (optional — absent in tests). + grpc: Option>, + /// Service token for authenticating gRPC calls to fetch pipeline state. + service_token: String, } impl NotificationDispatcher { - pub fn new(store: Arc) -> Self { + pub fn new(store: Arc, forage_url: String) -> Self { let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .expect("failed to build reqwest client"); - Self { http, store } + Self { http, store, forage_url, grpc: None, service_token: String::new() } + } + + pub fn with_grpc(mut self, grpc: Arc, service_token: String) -> Self { + self.grpc = Some(grpc); + self.service_token = service_token; + self } /// Execute a dispatch task with retry (3 attempts, exponential backoff). @@ -35,8 +46,15 @@ impl NotificationDispatcher { .. } => (integration_id.clone(), payload.notification_id.clone()), DispatchTask::Slack { - integration_id, .. - } => (integration_id.clone(), String::new()), + integration_id, + notification_id, + .. + } => (integration_id.clone(), notification_id.clone()), + DispatchTask::SlackDm { + integration_id, + notification_id, + .. + } => (integration_id.clone(), notification_id.clone()), }; let delays = [1, 5, 25]; // seconds @@ -55,6 +73,26 @@ impl NotificationDispatcher { return; } Err(e) => { + // Don't retry errors that will never succeed + let non_retryable = is_non_retryable_error(&e); + if non_retryable { + tracing::error!( + integration_id = %integration_id, + error = %e, + "non-retryable delivery error" + ); + let _ = self + .store + .record_delivery( + &integration_id, + ¬ification_id, + DeliveryStatus::Failed, + Some(&e), + ) + .await; + return; + } + tracing::warn!( integration_id = %integration_id, attempt = attempt + 1, @@ -125,38 +163,371 @@ impl NotificationDispatcher { } } DispatchTask::Slack { + integration_id, webhook_url, + access_token, + channel_id, + release_id, + event_type, + event, message, .. } => { - // Use Block Kit attachments for rich formatting - let payload = serde_json::json!({ - "text": message.text, - "attachments": [{ - "color": message.color, - "blocks": message.blocks, - }] - }); - - let resp = self - .http - .post(webhook_url) - .header("Content-Type", "application/json") - .json(&payload) - .send() + // If we have a bot token, use chat.postMessage/chat.update for update-in-place + if !access_token.is_empty() && !channel_id.is_empty() && !release_id.is_empty() { + self.dispatch_slack_bot( + integration_id, + access_token, + channel_id, + release_id, + event_type, + event, + ) .await - .map_err(|e| format!("slack http: {e}"))?; - - let status = resp.status(); - if status.is_success() { - Ok(()) } else { - let body = resp.text().await.unwrap_or_default(); - Err(format!("Slack HTTP {status}: {body}")) + // Fallback: webhook URL (no update-in-place possible) + self.dispatch_slack_webhook(webhook_url, message).await } } + DispatchTask::SlackDm { + integration_id, + access_token, + slack_user_id, + release_id, + event_type, + event, + message: _, + .. + } => { + // DM uses the same bot token post/update pattern, but channel = user ID. + // Prefix release_id so the message ref is distinct from channel messages. + let dm_release_id = format!("dm:{slack_user_id}:{release_id}"); + self.dispatch_slack_bot( + integration_id, + access_token, + slack_user_id, // Slack accepts user ID as channel for DMs + &dm_release_id, + event_type, + event, + ) + .await + } } } + + /// Post or update a Slack message via the bot token API. + /// Merges per-destination status into the message ref and rebuilds the message. + async fn dispatch_slack_bot( + &self, + integration_id: &str, + access_token: &str, + channel: &str, + release_id: &str, + event_type: &str, + event: &forage_core::integrations::router::NotificationEvent, + ) -> Result<(), String> { + use forage_core::integrations::{DestinationStatus, SlackMessageRef}; + use forage_core::integrations::router::format_slack_message; + + // Get existing ref (with accumulated destinations) if we already posted + let existing_ref = self + .store + .get_slack_message_ref(integration_id, release_id) + .await + .unwrap_or(None); + + // Merge this notification's destination into the accumulated map + let mut destinations = existing_ref + .as_ref() + .map(|r| r.destinations.clone()) + .unwrap_or_default(); + + if let Some(ref r) = event.release { + if !r.destination.is_empty() { + let status = match event_type { + "release_started" => "started", + "release_succeeded" => "succeeded", + "release_failed" => "failed", + _ => "started", + }; + destinations.insert( + r.destination.clone(), + DestinationStatus { + environment: r.environment.clone(), + status: status.to_string(), + error: r.error_message.clone(), + }, + ); + } + } + + // Build the message with the full accumulated state + let mut message = format_slack_message(event, &destinations, &self.forage_url); + + // Query pipeline stages and insert before destinations + if let Some(ref r) = event.release { + if !r.release_intent_id.is_empty() { + if let Some(stages) = self + .fetch_pipeline_stages(&event.organisation, &event.project, &r.release_intent_id) + .await + { + let pipeline_blocks = + forage_core::integrations::router::format_pipeline_blocks(&stages); + if !pipeline_blocks.is_empty() { + // Insert pipeline before the destination section. + // Find the last "context" block (metadata); pipeline goes right after it, + // pushing destinations and errors down. + let insert_at = message + .blocks + .iter() + .rposition(|b| b["type"] == "context") + .map(|i| i + 1) + .unwrap_or(message.blocks.len()); + for (i, block) in pipeline_blocks.into_iter().enumerate() { + message.blocks.insert(insert_at + i, block); + } + } + } + } + } + let release_title = event + .release + .as_ref() + .filter(|r| !r.context_title.is_empty()) + .map(|r| r.context_title.clone()) + .or_else(|| existing_ref.as_ref().map(|r| r.release_title.clone())) + .unwrap_or_else(|| event.title.clone()); + + let blocks_payload = serde_json::json!([{ + "color": message.color, + "blocks": message.blocks, + }]); + + // The `text` field is a fallback for notifications/accessibility only. + // Slack renders it above attachments in some clients, causing duplication. + // Use a minimal fallback; the attachment blocks carry the rich content. + let fallback_text = format!("Release update: {}/{}", event.organisation, event.project); + + if let Some(ref msg_ref) = existing_ref { + // Update existing message + let payload = serde_json::json!({ + "channel": msg_ref.channel_id, + "ts": msg_ref.message_ts, + "text": fallback_text, + "attachments": blocks_payload, + }); + + let resp = self + .http + .post("https://slack.com/api/chat.update") + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("slack chat.update http: {e}"))?; + + let body: serde_json::Value = + resp.json().await.map_err(|e| format!("slack chat.update parse: {e}"))?; + + if body["ok"].as_bool() != Some(true) { + let err = body["error"].as_str().unwrap_or("unknown"); + if err == "message_not_found" { + tracing::warn!( + integration_id = %integration_id, + release_id = %release_id, + "slack message not found, posting new one" + ); + // Fall through to post a new one + } else { + return Err(format!("slack chat.update: {err}")); + } + } else { + // Update the ref with merged destinations + let updated = SlackMessageRef { + id: msg_ref.id.clone(), + integration_id: integration_id.to_string(), + release_id: release_id.to_string(), + channel_id: msg_ref.channel_id.clone(), + message_ts: msg_ref.message_ts.clone(), + last_event_type: event_type.to_string(), + destinations, + release_title, + created_at: msg_ref.created_at.clone(), + updated_at: chrono::Utc::now().to_rfc3339(), + }; + let _ = self.store.upsert_slack_message_ref(&updated).await; + return Ok(()); + } + } + + // Try to join the channel first + let _ = self.slack_join_channel(access_token, channel).await; + + // Post new message + let payload = serde_json::json!({ + "channel": channel, + "text": fallback_text, + "attachments": blocks_payload, + }); + + let resp = self + .http + .post("https://slack.com/api/chat.postMessage") + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("slack chat.postMessage http: {e}"))?; + + let body: serde_json::Value = + resp.json().await.map_err(|e| format!("slack chat.postMessage parse: {e}"))?; + + if body["ok"].as_bool() != Some(true) { + let err = body["error"].as_str().unwrap_or("unknown"); + return Err(format!("slack chat.postMessage: {err}")); + } + + // Store the message ref with initial destinations + let ts = body["ts"].as_str().unwrap_or_default(); + let posted_channel = body["channel"].as_str().unwrap_or(channel); + + if !ts.is_empty() && !release_id.is_empty() { + let msg_ref = SlackMessageRef { + id: uuid::Uuid::new_v4().to_string(), + integration_id: integration_id.to_string(), + release_id: release_id.to_string(), + channel_id: posted_channel.to_string(), + message_ts: ts.to_string(), + last_event_type: event_type.to_string(), + destinations, + release_title, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }; + let _ = self.store.upsert_slack_message_ref(&msg_ref).await; + } + + Ok(()) + } + + /// Try to join a Slack channel. Silently succeeds if already a member or channel is private. + async fn slack_join_channel(&self, access_token: &str, channel: &str) -> Result<(), String> { + let payload = serde_json::json!({ "channel": channel }); + + let resp = self + .http + .post("https://slack.com/api/conversations.join") + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("slack conversations.join http: {e}"))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("slack conversations.join parse: {e}"))?; + + if body["ok"].as_bool() == Some(true) { + tracing::info!(channel = %channel, "bot joined slack channel"); + } else { + let err = body["error"].as_str().unwrap_or("unknown"); + // channel_not_found, method_not_supported_for_channel_type (private), already_in_channel + // These are all acceptable — we tried our best + tracing::debug!(channel = %channel, error = %err, "conversations.join failed (may be private channel)"); + } + + Ok(()) + } + + /// Fetch pipeline stages for a release intent via gRPC. + /// Returns None if gRPC is not configured or the call fails. + async fn fetch_pipeline_stages( + &self, + organisation: &str, + project: &str, + release_intent_id: &str, + ) -> Option> { + let grpc = self.grpc.as_ref()?; + if self.service_token.is_empty() { + return None; + } + + match grpc + .get_release_intent_states_with_token( + &self.service_token, + organisation, + Some(project), + true, // include_completed so we get the current intent + ) + .await + { + Ok(intents) => { + // Find the matching release intent + intents + .into_iter() + .find(|i| i.release_intent_id == release_intent_id) + .map(|i| i.stages) + } + Err(e) => { + tracing::warn!( + release_intent_id = %release_intent_id, + error = %e, + "failed to fetch pipeline stages" + ); + None + } + } + } + + /// Fallback: post via incoming webhook URL (no update-in-place). + async fn dispatch_slack_webhook( + &self, + webhook_url: &str, + message: &forage_core::integrations::router::SlackMessage, + ) -> Result<(), String> { + let payload = serde_json::json!({ + "text": message.text, + "attachments": [{ + "color": message.color, + "blocks": message.blocks, + }] + }); + + let resp = self + .http + .post(webhook_url) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| format!("slack http: {e}"))?; + + let status = resp.status(); + if status.is_success() { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("Slack HTTP {status}: {body}")) + } + } +} + +/// Slack API errors that will never succeed on retry. +fn is_non_retryable_error(err: &str) -> bool { + const NON_RETRYABLE: &[&str] = &[ + "channel_not_found", + "not_in_channel", + "is_archived", + "invalid_auth", + "token_revoked", + "account_inactive", + "no_permission", + "missing_scope", + "not_authed", + "invalid_arguments", + ]; + NON_RETRYABLE.iter().any(|code| err.contains(code)) } // ── Proto conversion ──────────────────────────────────────────────── @@ -174,11 +545,16 @@ pub fn proto_to_event(n: &forage_grpc::Notification) -> NotificationEvent { let release = n.release_context.as_ref().map(|r| ReleaseContext { slug: r.slug.clone(), artifact_id: r.artifact_id.clone(), + release_intent_id: r.release_intent_id.clone(), destination: r.destination.clone(), environment: r.environment.clone(), source_username: r.source_username.clone(), + source_user_id: r.source_user_id.clone(), commit_sha: r.commit_sha.clone(), commit_branch: r.commit_branch.clone(), + context_title: r.context_title.clone(), + context_web: r.context_web.clone(), + destination_count: r.destination_count, error_message: if r.error_message.is_empty() { None } else { @@ -207,6 +583,8 @@ pub struct NotificationListener { pub store: Arc, /// Service token (PAT) for authenticating with forest-server's NotificationService. pub service_token: String, + /// Base URL of the Forage web UI for deep links (e.g. "https://forage.example.com"). + pub forage_url: String, } impl Component for NotificationListener { @@ -215,7 +593,10 @@ impl Component for NotificationListener { } async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> { - let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone())); + let dispatcher = Arc::new( + NotificationDispatcher::new(self.store.clone(), self.forage_url.clone()) + .with_grpc(self.grpc.clone(), self.service_token.clone()), + ); // For now, listen on the global stream (no org filter). // Forest's ListenNotifications with no org filter returns all notifications diff --git a/crates/forage-server/src/routes/auth.rs b/crates/forage-server/src/routes/auth.rs index a54ff3b..a5b82f2 100644 --- a/crates/forage-server/src/routes/auth.rs +++ b/crates/forage-server/src/routes/auth.rs @@ -1,9 +1,10 @@ -use axum::extract::State; +use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Form, Router}; use chrono::Utc; +use forage_core::integrations::SlackUserLink; use minijinja::context; use serde::Deserialize; @@ -35,6 +36,18 @@ pub fn router() -> Router { "/settings/account/notifications", post(update_notification_preference), ) + .route( + "/settings/account/slack/connect", + get(slack_connect), + ) + .route( + "/settings/account/slack/callback", + get(slack_user_callback), + ) + .route( + "/settings/account/slack/disconnect", + post(slack_disconnect), + ) } // ─── Signup ───────────────────────────────────────────────────────── @@ -95,34 +108,42 @@ async fn signup_submit( { Ok(tokens) => { // Fetch user info for the session cache - let mut user_cache = state + let user_cache = match state .forest_client .get_user(&tokens.access_token) .await - .ok() - .map(|u| CachedUser { - user_id: u.user_id, - username: u.username, - emails: u.emails, - orgs: vec![], - }); - - // Cache org memberships in the session - if let Some(ref mut user) = user_cache - && let Ok(orgs) = state - .platform_client - .list_my_organisations(&tokens.access_token) - .await { - user.orgs = orgs - .into_iter() - .map(|o| CachedOrg { - organisation_id: o.organisation_id, - name: o.name, - role: o.role, + Ok(u) => { + let orgs = match state + .platform_client + .list_my_organisations(&tokens.access_token) + .await + { + Ok(orgs) => orgs + .into_iter() + .map(|o| CachedOrg { + organisation_id: o.organisation_id, + name: o.name, + role: o.role, + }) + .collect(), + Err(e) => { + tracing::warn!(error = %e, "failed to fetch orgs during signup"); + vec![] + } + }; + Some(CachedUser { + user_id: u.user_id, + username: u.username, + emails: u.emails, + orgs, }) - .collect(); - } + } + Err(e) => { + tracing::warn!(error = %e, "failed to fetch user during signup"); + None + } + }; let now = Utc::now(); let session_data = SessionData { @@ -247,34 +268,42 @@ async fn login_submit( .await { Ok(tokens) => { - let mut user_cache = state + let user_cache = match state .forest_client .get_user(&tokens.access_token) .await - .ok() - .map(|u| CachedUser { - user_id: u.user_id, - username: u.username, - emails: u.emails, - orgs: vec![], - }); - - // Cache org memberships in the session - if let Some(ref mut user) = user_cache - && let Ok(orgs) = state - .platform_client - .list_my_organisations(&tokens.access_token) - .await { - user.orgs = orgs - .into_iter() - .map(|o| CachedOrg { - organisation_id: o.organisation_id, - name: o.name, - role: o.role, + Ok(u) => { + let orgs = match state + .platform_client + .list_my_organisations(&tokens.access_token) + .await + { + Ok(orgs) => orgs + .into_iter() + .map(|o| CachedOrg { + organisation_id: o.organisation_id, + name: o.name, + role: o.role, + }) + .collect(), + Err(e) => { + tracing::warn!(error = %e, "failed to fetch orgs during login"); + vec![] + } + }; + Some(CachedUser { + user_id: u.user_id, + username: u.username, + emails: u.emails, + orgs, }) - .collect(); - } + } + Err(e) => { + tracing::warn!(error = %e, "failed to fetch user during login"); + None + } + }; let now = Utc::now(); let session_data = SessionData { @@ -495,7 +524,17 @@ async fn account_page( .get_notification_preferences(&session.access_token) .await .unwrap_or_default(); - render_account(&state, &session, None, &prefs) + + let slack_links = if let Some(store) = state.integration_store.as_ref() { + store + .list_slack_user_links(&session.user.user_id) + .await + .unwrap_or_default() + } else { + vec![] + }; + + render_account(&state, &session, None, &prefs, &slack_links) } #[allow(clippy::result_large_err)] @@ -504,6 +543,7 @@ fn render_account( session: &Session, error: Option<&str>, notification_prefs: &[forage_core::platform::NotificationPreference], + slack_links: &[SlackUserLink], ) -> Result { let html = state .templates @@ -529,6 +569,14 @@ fn render_account( .filter(|p| p.enabled) .map(|p| format!("{}|{}", p.notification_type, p.channel)) .collect::>(), + has_slack_oauth => state.slack_config.is_some(), + slack_links => slack_links.iter().map(|l| context! { + id => &l.id, + team_id => &l.team_id, + team_name => &l.team_name, + slack_user_id => &l.slack_user_id, + slack_username => &l.slack_username, + }).collect::>(), }, ) .map_err(|e| { @@ -559,7 +607,7 @@ async fn update_username_submit( } if let Err(e) = validate_username(&form.username) { - return render_account(&state, &session, Some(&e.0), &[]); + return render_account(&state, &session, Some(&e.0), &[], &[]); } match state @@ -581,11 +629,11 @@ async fn update_username_submit( Ok(Redirect::to("/settings/account").into_response()) } Err(forage_core::auth::AuthError::AlreadyExists(_)) => { - render_account(&state, &session, Some("Username is already taken."), &[]) + render_account(&state, &session, Some("Username is already taken."), &[], &[]) } Err(e) => { tracing::error!("failed to update username: {e}"); - render_account(&state, &session, Some("Could not update username. Please try again."), &[]) + render_account(&state, &session, Some("Could not update username. Please try again."), &[], &[]) } } } @@ -613,11 +661,11 @@ async fn change_password_submit( } if form.new_password != form.new_password_confirm { - return render_account(&state, &session, Some("New passwords do not match."), &[]); + return render_account(&state, &session, Some("New passwords do not match."), &[], &[]); } if let Err(e) = validate_password(&form.new_password) { - return render_account(&state, &session, Some(&e.0), &[]); + return render_account(&state, &session, Some(&e.0), &[], &[]); } match state @@ -632,11 +680,11 @@ async fn change_password_submit( { Ok(()) => Ok(Redirect::to("/settings/account").into_response()), Err(forage_core::auth::AuthError::InvalidCredentials) => { - render_account(&state, &session, Some("Current password is incorrect."), &[]) + render_account(&state, &session, Some("Current password is incorrect."), &[], &[]) } Err(e) => { tracing::error!("failed to change password: {e}"); - render_account(&state, &session, Some("Could not change password. Please try again."), &[]) + render_account(&state, &session, Some("Could not change password. Please try again."), &[], &[]) } } } @@ -662,7 +710,7 @@ async fn add_email_submit( } if let Err(e) = validate_email(&form.email) { - return render_account(&state, &session, Some(&e.0), &[]); + return render_account(&state, &session, Some(&e.0), &[], &[]); } match state @@ -687,11 +735,11 @@ async fn add_email_submit( Ok(Redirect::to("/settings/account").into_response()) } Err(forage_core::auth::AuthError::AlreadyExists(_)) => { - render_account(&state, &session, Some("Email is already registered."), &[]) + render_account(&state, &session, Some("Email is already registered."), &[], &[]) } Err(e) => { tracing::error!("failed to add email: {e}"); - render_account(&state, &session, Some("Could not add email. Please try again."), &[]) + render_account(&state, &session, Some("Could not add email. Please try again."), &[], &[]) } } } @@ -736,7 +784,7 @@ async fn remove_email_submit( } Err(e) => { tracing::error!("failed to remove email: {e}"); - render_account(&state, &session, Some("Could not remove email. Please try again."), &[]) + render_account(&state, &session, Some("Could not remove email. Please try again."), &[], &[]) } } } @@ -780,3 +828,225 @@ async fn update_notification_preference( Ok(Redirect::to("/settings/account").into_response()) } + +// ─── Slack user enrollment ──────────────────────────────────────────── + +async fn slack_connect( + State(state): State, + session: Session, +) -> Result { + let slack_config = state.slack_config.as_ref().ok_or_else(|| { + error_page( + &state, + StatusCode::SERVICE_UNAVAILABLE, + "Slack not configured", + "Slack OAuth is not configured on this server.", + ) + })?; + + let redirect_uri = format!( + "{}/settings/account/slack/callback", + slack_config.redirect_host + ); + let url = format!( + "https://slack.com/oauth/v2/authorize?client_id={}&user_scope=identity.basic&redirect_uri={}&state={}", + urlencoding::encode(&slack_config.client_id), + urlencoding::encode(&redirect_uri), + urlencoding::encode(&session.user.user_id), + ); + + Ok(Redirect::to(&url)) +} + +#[derive(Deserialize)] +struct SlackUserCallbackQuery { + code: Option, + state: Option, + error: Option, +} + +async fn slack_user_callback( + State(state): State, + session: Session, + Query(query): Query, +) -> Result { + // Handle user-denied case + if let Some(err) = query.error { + tracing::warn!("Slack user OAuth denied: {err}"); + return Ok(Redirect::to("/settings/account").into_response()); + } + + let code = query.code.ok_or_else(|| { + error_page( + &state, + StatusCode::BAD_REQUEST, + "Invalid request", + "Missing authorization code from Slack.", + ) + })?; + + // Verify state matches our user_id to prevent CSRF + let state_param = query.state.unwrap_or_default(); + if state_param != session.user.user_id { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "State parameter mismatch. Please try connecting again.", + )); + } + + let slack_config = state.slack_config.as_ref().ok_or_else(|| { + error_page( + &state, + StatusCode::SERVICE_UNAVAILABLE, + "Not configured", + "Slack OAuth is not configured.", + ) + })?; + + let integration_store = state.integration_store.as_ref().ok_or_else(|| { + error_page( + &state, + StatusCode::SERVICE_UNAVAILABLE, + "Not available", + "Slack account linking requires a database. Set DATABASE_URL to enable.", + ) + })?; + + let redirect_uri = format!( + "{}/settings/account/slack/callback", + slack_config.redirect_host + ); + + // Exchange the authorization code for a user access token + let http = reqwest::Client::new(); + let token_resp = http + .post("https://slack.com/api/oauth.v2.access") + .form(&[ + ("client_id", slack_config.client_id.as_str()), + ("client_secret", slack_config.client_secret.as_str()), + ("code", &code), + ("redirect_uri", &redirect_uri), + ]) + .send() + .await + .map_err(|e| { + internal_error( + &state, + "slack user oauth", + &format!("Failed to contact Slack: {e}"), + ) + })?; + + let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| { + internal_error( + &state, + "slack user oauth", + &format!("Failed to parse Slack response: {e}"), + ) + })?; + + if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) { + let err_msg = resp_body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + tracing::error!("Slack user OAuth error: {err_msg}"); + return Err(error_page( + &state, + StatusCode::BAD_GATEWAY, + "Slack error", + &format!("Slack returned an error: {err_msg}"), + )); + } + + // For user-scoped OAuth, the user token is nested under authed_user + let authed_user = resp_body.get("authed_user").ok_or_else(|| { + internal_error( + &state, + "slack user oauth", + &"Missing authed_user in Slack response", + ) + })?; + + let slack_user_id = authed_user["id"].as_str().unwrap_or("").to_string(); + let user_access_token = authed_user["access_token"].as_str().unwrap_or("").to_string(); + let team_id = resp_body["team"]["id"].as_str().unwrap_or("").to_string(); + let team_name = resp_body["team"]["name"].as_str().unwrap_or("").to_string(); + + // Fetch display name via users.identity (requires identity.basic user scope) + let slack_username = if user_access_token.is_empty() { + slack_user_id.clone() + } else { + let identity_name: Option = async { + let r = http + .get("https://slack.com/api/users.identity") + .bearer_auth(&user_access_token) + .send() + .await + .ok()?; + let body: serde_json::Value = r.json().await.ok()?; + let name = body["user"]["name"].as_str()?.to_string(); + if name.is_empty() { None } else { Some(name) } + } + .await; + + identity_name.unwrap_or_else(|| slack_user_id.clone()) + }; + + let now = chrono::Utc::now().to_rfc3339(); + let link = SlackUserLink { + id: uuid::Uuid::new_v4().to_string(), + user_id: session.user.user_id.clone(), + team_id, + team_name, + slack_user_id, + slack_username, + created_at: now, + }; + + integration_store + .upsert_slack_user_link(&link) + .await + .map_err(|e| internal_error(&state, "upsert slack user link", &e))?; + + Ok(Redirect::to("/settings/account").into_response()) +} + +#[derive(Deserialize)] +struct SlackDisconnectForm { + team_id: String, + _csrf: String, +} + +async fn slack_disconnect( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "CSRF validation failed.", + )); + } + + let integration_store = state.integration_store.as_ref().ok_or_else(|| { + error_page( + &state, + StatusCode::SERVICE_UNAVAILABLE, + "Not available", + "Slack account linking requires a database.", + ) + })?; + + integration_store + .delete_slack_user_link(&session.user.user_id, &form.team_id) + .await + .map_err(|e| internal_error(&state, "delete slack user link", &e))?; + + Ok(Redirect::to("/settings/account").into_response()) +} diff --git a/crates/forage-server/src/routes/integrations.rs b/crates/forage-server/src/routes/integrations.rs index 0432ba9..3ea376e 100644 --- a/crates/forage-server/src/routes/integrations.rs +++ b/crates/forage-server/src/routes/integrations.rs @@ -61,6 +61,10 @@ pub fn router() -> Router { "/orgs/{org}/settings/integrations/slack", post(create_slack), ) + .route( + "/orgs/{org}/settings/integrations/{id}/reinstall", + post(reinstall_slack), + ) .route( "/integrations/slack/callback", get(slack_oauth_callback), @@ -433,6 +437,7 @@ async fn integration_detail( created_at => &integration.created_at, }, config => config_display, + has_slack_oauth => state.slack_config.is_some(), rules => rules_ctx, deliveries => deliveries_ctx, test_sent => query.test.is_some(), @@ -575,17 +580,22 @@ async fn test_integration( release: Some(ReleaseContext { slug: "test-release".into(), artifact_id: "art_test".into(), + release_intent_id: String::new(), destination: "staging".into(), environment: "staging".into(), source_username: session.user.username.clone(), + source_user_id: session.user.user_id.clone(), commit_sha: "abc1234".into(), commit_branch: "main".into(), + context_title: "Test notification from Forage".into(), + context_web: String::new(), + destination_count: 1, error_message: None, }), }; let tasks = forage_core::integrations::router::route_notification(&test_event, &[integration]); - let dispatcher = NotificationDispatcher::new(Arc::clone(store)); + let dispatcher = NotificationDispatcher::new(Arc::clone(store), String::new()); for task in &tasks { dispatcher.dispatch(task).await; } @@ -597,6 +607,409 @@ async fn test_integration( .into_response()) } +// ─── Install Slack page ───────────────────────────────────────────── + +async fn install_slack_page( + State(state): State, + session: Session, + Path(org): Path, + Query(query): Query, +) -> Result { + let cached_org = require_org_membership(&state, &session.user.orgs, &org)?; + require_admin(&state, cached_org)?; + require_integration_store(&state)?; + + let slack_oauth_url = state.slack_config.as_ref().map(|sc| { + format!( + "https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}/integrations/slack/callback&state={}", + urlencoding::encode(&sc.client_id), + urlencoding::encode(&sc.redirect_host), + urlencoding::encode(&org), + ) + }); + + let html = state + .templates + .render( + "pages/install_slack.html.jinja", + context! { + title => format!("Install Slack - {} - Forage", org), + description => "Set up a Slack integration", + user => context! { + username => &session.user.username, + user_id => &session.user.user_id, + }, + current_org => &org, + orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::>(), + csrf_token => &session.csrf_token, + active_tab => "integrations", + error => query.error, + slack_oauth_url => slack_oauth_url, + has_slack_oauth => state.slack_config.is_some(), + }, + ) + .map_err(|e| internal_error(&state, "template error", &e))?; + + Ok(Html(html).into_response()) +} + +// ─── Create Slack (manual webhook URL fallback) ────────────────────── + +#[derive(Deserialize)] +struct CreateSlackForm { + _csrf: String, + name: String, + webhook_url: String, + #[serde(default)] + channel_name: String, +} + +async fn create_slack( + State(state): State, + session: Session, + Path(org): Path, + Form(form): Form, +) -> Result { + let cached_org = require_org_membership(&state, &session.user.orgs, &org)?; + require_admin(&state, cached_org)?; + require_integration_store(&state)?; + validate_csrf(&session, &form._csrf)?; + + if let Err(e) = validate_integration_name(&form.name) { + return Ok(Redirect::to(&format!( + "/orgs/{}/settings/integrations/install/slack?error={}", + org, + urlencoding::encode(&e.to_string()) + )) + .into_response()); + } + + if let Err(e) = validate_slack_webhook_url(&form.webhook_url) { + return Ok(Redirect::to(&format!( + "/orgs/{}/settings/integrations/install/slack?error={}", + org, + urlencoding::encode(&e) + )) + .into_response()); + } + + let channel = if form.channel_name.is_empty() { + "#general".to_string() + } else { + form.channel_name + }; + + let config = IntegrationConfig::Slack { + team_id: String::new(), + team_name: String::new(), + channel_id: String::new(), + channel_name: channel, + access_token: String::new(), + webhook_url: form.webhook_url, + }; + + let store = state.integration_store.as_ref().unwrap(); + let created = store + .create_integration(&CreateIntegrationInput { + organisation: org.clone(), + integration_type: IntegrationType::Slack, + name: form.name, + config, + created_by: session.user.user_id.clone(), + }) + .await + .map_err(|e| internal_error(&state, "create slack", &e))?; + + let html = state + .templates + .render( + "pages/integration_installed.html.jinja", + context! { + title => format!("{} installed - Forage", created.name), + description => "Integration installed successfully", + user => context! { + username => &session.user.username, + user_id => &session.user.user_id, + }, + current_org => &org, + orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::>(), + csrf_token => &session.csrf_token, + active_tab => "integrations", + integration => context! { + id => &created.id, + name => &created.name, + type_display => created.integration_type.display_name(), + }, + api_token => created.api_token, + }, + ) + .map_err(|e| internal_error(&state, "template error", &e))?; + + Ok(Html(html).into_response()) +} + +// ─── Reinstall Slack ───────────────────────────────────────────────── + +#[derive(Deserialize)] +struct ReinstallForm { + _csrf: String, +} + +async fn reinstall_slack( + State(state): State, + session: Session, + Path((org, id)): Path<(String, String)>, + Form(form): Form, +) -> Result { + let cached_org = require_org_membership(&state, &session.user.orgs, &org)?; + require_admin(&state, cached_org)?; + require_integration_store(&state)?; + validate_csrf(&session, &form._csrf)?; + + // Verify the integration exists and is a Slack integration + let store = state.integration_store.as_ref().unwrap(); + let integration = store.get_integration(&org, &id).await.map_err(|e| { + error_page( + &state, + axum::http::StatusCode::NOT_FOUND, + "Not found", + &format!("Integration not found: {e}"), + ) + })?; + + if integration.integration_type != IntegrationType::Slack { + return Err(error_page( + &state, + axum::http::StatusCode::BAD_REQUEST, + "Invalid request", + "Only Slack integrations can be reinstalled via OAuth.", + )); + } + + let slack_config = state.slack_config.as_ref().ok_or_else(|| { + error_page( + &state, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "Not configured", + "Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.", + ) + })?; + + // Encode org + integration ID in state so callback can update instead of create + let oauth_state = format!("{}:reinstall:{}", org, id); + let redirect_uri = format!( + "{}/integrations/slack/callback", + slack_config.redirect_host + ); + let url = format!( + "https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}&state={}", + urlencoding::encode(&slack_config.client_id), + urlencoding::encode(&redirect_uri), + urlencoding::encode(&oauth_state), + ); + + Ok(Redirect::to(&url).into_response()) +} + +// ─── Slack OAuth callback ──────────────────────────────────────────── + +#[derive(Deserialize)] +struct SlackCallbackQuery { + code: Option, + state: Option, + error: Option, +} + +async fn slack_oauth_callback( + State(state): State, + session: Session, + Query(query): Query, +) -> Result { + let raw_state = query.state.ok_or_else(|| { + error_page( + &state, + axum::http::StatusCode::BAD_REQUEST, + "Invalid request", + "Missing state parameter from Slack callback.", + ) + })?; + + // Parse state: either "{org}" (new install) or "{org}:reinstall:{id}" (reinstall) + let (org, reinstall_id) = if raw_state.contains(":reinstall:") { + let parts: Vec<&str> = raw_state.splitn(2, ":reinstall:").collect(); + (parts[0].to_string(), Some(parts[1].to_string())) + } else { + (raw_state, None) + }; + + // If Slack returned an error (user denied) + if let Some(err) = query.error { + let redirect_to = if let Some(ref rid) = reinstall_id { + format!("/orgs/{}/settings/integrations/{}?error={}", org, rid, urlencoding::encode(&format!("Slack authorization denied: {err}"))) + } else { + format!("/orgs/{}/settings/integrations/install/slack?error={}", org, urlencoding::encode(&format!("Slack authorization denied: {err}"))) + }; + return Ok(Redirect::to(&redirect_to).into_response()); + } + + let code = query.code.ok_or_else(|| { + error_page( + &state, + axum::http::StatusCode::BAD_REQUEST, + "Invalid request", + "Missing authorization code from Slack.", + ) + })?; + + let cached_org = require_org_membership(&state, &session.user.orgs, &org)?; + require_admin(&state, cached_org)?; + require_integration_store(&state)?; + + let slack_config = state.slack_config.as_ref().ok_or_else(|| { + error_page( + &state, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "Not configured", + "Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.", + ) + })?; + + // Exchange code for token + let http = reqwest::Client::new(); + let token_resp = http + .post("https://slack.com/api/oauth.v2.access") + .form(&[ + ("client_id", slack_config.client_id.as_str()), + ("client_secret", slack_config.client_secret.as_str()), + ("code", &code), + ( + "redirect_uri", + &format!("{}/integrations/slack/callback", slack_config.redirect_host), + ), + ]) + .send() + .await + .map_err(|e| { + internal_error(&state, "slack oauth", &format!("Failed to contact Slack: {e}")) + })?; + + let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| { + internal_error( + &state, + "slack oauth", + &format!("Failed to parse Slack response: {e}"), + ) + })?; + + if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) { + let err_msg = resp_body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Ok(Redirect::to(&format!( + "/orgs/{}/settings/integrations/install/slack?error={}", + org, + urlencoding::encode(&format!("Slack error: {err_msg}")) + )) + .into_response()); + } + + // Extract fields from Slack response + let team_id = resp_body["team"]["id"] + .as_str() + .unwrap_or("") + .to_string(); + let team_name = resp_body["team"]["name"] + .as_str() + .unwrap_or("") + .to_string(); + let access_token = resp_body["access_token"] + .as_str() + .unwrap_or("") + .to_string(); + + let (channel_id, channel_name, webhook_url) = + if let Some(wh) = resp_body.get("incoming_webhook") { + ( + wh["channel_id"].as_str().unwrap_or("").to_string(), + wh["channel"].as_str().unwrap_or("").to_string(), + wh["url"].as_str().unwrap_or("").to_string(), + ) + } else { + (String::new(), String::new(), String::new()) + }; + + let integration_name = if channel_name.is_empty() { + format!("Slack - {team_name}") + } else { + format!("Slack - {channel_name}") + }; + + let config = IntegrationConfig::Slack { + team_id, + team_name, + channel_id, + channel_name, + access_token, + webhook_url, + }; + + let store = state.integration_store.as_ref().unwrap(); + + if let Some(ref existing_id) = reinstall_id { + // Reinstall: update existing integration's config + store + .update_integration_config(&org, existing_id, &integration_name, &config) + .await + .map_err(|e| internal_error(&state, "reinstall slack", &e))?; + + Ok(Redirect::to(&format!( + "/orgs/{}/settings/integrations/{}", + org, existing_id + )) + .into_response()) + } else { + // New install: create integration + let created = store + .create_integration(&CreateIntegrationInput { + organisation: org.clone(), + integration_type: IntegrationType::Slack, + name: integration_name, + config, + created_by: session.user.user_id.clone(), + }) + .await + .map_err(|e| internal_error(&state, "create slack", &e))?; + + let html = state + .templates + .render( + "pages/integration_installed.html.jinja", + context! { + title => format!("{} installed - Forage", created.name), + description => "Integration installed successfully", + user => context! { + username => &session.user.username, + user_id => &session.user.user_id, + }, + current_org => &org, + orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::>(), + csrf_token => &session.csrf_token, + active_tab => "integrations", + integration => context! { + id => &created.id, + name => &created.name, + type_display => created.integration_type.display_name(), + }, + api_token => created.api_token, + }, + ) + .map_err(|e| internal_error(&state, "template error", &e))?; + + Ok(Html(html).into_response()) + } +} + // ─── Helpers ──────────────────────────────────────────────────────── fn notification_type_label(nt: &str) -> &str { @@ -608,3 +1021,14 @@ fn notification_type_label(nt: &str) -> &str { other => other, } } + +fn validate_slack_webhook_url(url: &str) -> Result<(), String> { + if url.starts_with("https://hooks.slack.com/") + || url.starts_with("http://localhost") + || url.starts_with("http://127.0.0.1") + { + Ok(()) + } else { + Err("Slack webhook URL must start with https://hooks.slack.com/".to_string()) + } +} diff --git a/crates/forage-server/src/state.rs b/crates/forage-server/src/state.rs index 2b510db..d6b0c47 100644 --- a/crates/forage-server/src/state.rs +++ b/crates/forage-server/src/state.rs @@ -12,7 +12,7 @@ use forage_core::session::SessionStore; pub struct SlackConfig { pub client_id: String, pub client_secret: String, - pub base_url: String, + pub redirect_host: String, } #[derive(Clone)] diff --git a/crates/forage-server/src/tests/mod.rs b/crates/forage-server/src/tests/mod.rs index 2c89fd9..e95d3aa 100644 --- a/crates/forage-server/src/tests/mod.rs +++ b/crates/forage-server/src/tests/mod.rs @@ -4,5 +4,6 @@ mod integration_tests; mod nats_tests; mod pages_tests; mod platform_tests; +mod slack_tests; mod token_tests; mod webhook_delivery_tests; diff --git a/crates/forage-server/src/tests/nats_tests.rs b/crates/forage-server/src/tests/nats_tests.rs index bdf632a..f43c181 100644 --- a/crates/forage-server/src/tests/nats_tests.rs +++ b/crates/forage-server/src/tests/nats_tests.rs @@ -86,11 +86,16 @@ fn test_event(org: &str) -> NotificationEvent { release: Some(ReleaseContext { slug: "v3.0".into(), artifact_id: "art_nats".into(), + release_intent_id: "ri_nats".into(), destination: "prod".into(), environment: "production".into(), source_username: "alice".into(), + source_user_id: String::new(), commit_sha: "aabbccdd".into(), commit_branch: "main".into(), + context_title: "Deploy v3.0 succeeded".into(), + context_web: String::new(), + destination_count: 1, error_message: None, }), } @@ -108,11 +113,16 @@ fn failed_event(org: &str) -> NotificationEvent { release: Some(ReleaseContext { slug: "v3.0".into(), artifact_id: "art_nats".into(), + release_intent_id: "ri_nats".into(), destination: "prod".into(), environment: "production".into(), source_username: "bob".into(), + source_user_id: String::new(), commit_sha: "deadbeef".into(), commit_branch: "hotfix".into(), + context_title: "Deploy v3.0 failed".into(), + context_web: String::new(), + destination_count: 1, error_message: Some("OOM killed".into()), }), } @@ -144,7 +154,7 @@ async fn process_payload_routes_and_dispatches_to_webhook() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -170,7 +180,7 @@ async fn process_payload_skips_when_no_matching_integrations() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let result = NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher).await; assert!(result.is_ok(), "should succeed with no matching integrations"); } @@ -178,7 +188,7 @@ async fn process_payload_skips_when_no_matching_integrations() { #[tokio::test] async fn process_payload_rejects_invalid_json() { let store = Arc::new(InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let result = NotificationConsumer::process_payload(b"not-json", store.as_ref(), &dispatcher).await; @@ -219,7 +229,7 @@ async fn process_payload_respects_disabled_rules() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -285,7 +295,7 @@ async fn process_payload_dispatches_to_multiple_integrations() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -318,7 +328,7 @@ async fn process_payload_records_delivery_status() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -354,7 +364,7 @@ async fn process_payload_records_failed_delivery() { let envelope = NotificationEnvelope::from(&event); let payload = serde_json::to_vec(&envelope).unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -463,7 +473,7 @@ async fn jetstream_publish_and_consume_delivers_webhook() { .expect("message error"); // Process through the consumer logic - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher) .await .unwrap(); @@ -542,7 +552,7 @@ async fn jetstream_multiple_messages_all_delivered() { .unwrap(); let mut messages = pull_consumer.messages().await.unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); for _ in 0..3 { let msg = tokio::time::timeout(Duration::from_secs(5), messages.next()) @@ -632,7 +642,7 @@ async fn jetstream_message_for_wrong_org_skips_dispatch() { .unwrap() .unwrap(); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher) .await .unwrap(); diff --git a/crates/forage-server/src/tests/slack_tests.rs b/crates/forage-server/src/tests/slack_tests.rs new file mode 100644 index 0000000..8f806b3 --- /dev/null +++ b/crates/forage-server/src/tests/slack_tests.rs @@ -0,0 +1,624 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use forage_core::integrations::{ + CreateIntegrationInput, IntegrationConfig, IntegrationStore, IntegrationType, +}; +use tower::ServiceExt; + +use crate::test_support::*; + +fn build_app_with_integrations() -> ( + axum::Router, + std::sync::Arc, + std::sync::Arc, +) { + let (state, sessions, integrations) = + test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new()); + let app = crate::build_router(state); + (app, sessions, integrations) +} + +// ─── Install Slack page ───────────────────────────────────────────── + +#[tokio::test] +async fn install_slack_page_returns_200() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/settings/integrations/install/slack") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("Install Slack")); + assert!(text.contains("Webhook URL")); +} + +#[tokio::test] +async fn install_slack_page_returns_403_for_non_admin() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session_member(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/settings/integrations/install/slack") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn install_slack_page_shows_manual_form_without_oauth() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/settings/integrations/install/slack") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + // Should show manual webhook URL form + assert!(text.contains("hooks.slack.com")); + // Should NOT show "Add to Slack" button (no OAuth configured) + assert!(!text.contains("Add to Slack")); +} + +// ─── Create Slack (manual webhook URL) ────────────────────────────── + +#[tokio::test] +async fn create_slack_success_shows_installed_page() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=%23deploys"; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("installed")); + assert!(text.contains("fgi_")); // API token shown + assert!(text.contains("#deploys")); + + // Verify it was created as Slack type + let all = integrations.list_integrations("testorg").await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "#deploys"); + match &all[0].config { + IntegrationConfig::Slack { channel_name, webhook_url, .. } => { + assert_eq!(channel_name, "#deploys"); + assert!(webhook_url.contains("hooks.slack.com")); + } + _ => panic!("expected Slack config"), + } +} + +#[tokio::test] +async fn create_slack_defaults_channel_to_general() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let body = "_csrf=test-csrf&name=alerts&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name="; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let all = integrations.list_integrations("testorg").await.unwrap(); + match &all[0].config { + IntegrationConfig::Slack { channel_name, .. } => { + assert_eq!(channel_name, "#general"); + } + _ => panic!("expected Slack config"), + } +} + +#[tokio::test] +async fn create_slack_invalid_csrf_returns_403() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let body = "_csrf=wrong-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name="; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn create_slack_rejects_non_slack_url() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fexample.com%2Fhook&channel_name="; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Should redirect back to install page with error + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("install/slack")); + assert!(location.contains("error=")); +} + +#[tokio::test] +async fn create_slack_non_admin_returns_403() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session_member(&sessions).await; + + let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name="; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn create_slack_rejects_empty_name() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let body = "_csrf=test-csrf&name=&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name="; + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/orgs/testorg/settings/integrations/slack") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Should redirect back with error + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("install/slack")); + assert!(location.contains("error=")); +} + +// ─── Slack integration detail ─────────────────────────────────────── + +#[tokio::test] +async fn slack_integration_detail_shows_config() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let created = integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "#deploys".into(), + config: IntegrationConfig::Slack { + team_id: "T123".into(), + team_name: "My Team".into(), + channel_id: "C456".into(), + channel_name: "#deploys".into(), + access_token: "xoxb-test".into(), + webhook_url: "https://hooks.slack.com/test".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .uri(&format!( + "/orgs/testorg/settings/integrations/{}", + created.id + )) + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("#deploys")); + assert!(text.contains("My Team")); + assert!(text.contains("Slack")); +} + +#[tokio::test] +async fn slack_integration_detail_manual_mode_shows_webhook_url() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + // Manual mode: empty team_name + let created = integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "manual-slack".into(), + config: IntegrationConfig::Slack { + team_id: String::new(), + team_name: String::new(), + channel_id: String::new(), + channel_name: "#deploys".into(), + access_token: String::new(), + webhook_url: "https://hooks.slack.com/services/T123/B456/xyz".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .uri(&format!( + "/orgs/testorg/settings/integrations/{}", + created.id + )) + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("hooks.slack.com")); +} + +// ─── Slack in integrations catalog ────────────────────────────────── + +#[tokio::test] +async fn integrations_page_shows_slack_as_available() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/settings/integrations") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + // Slack should be a clickable link to install page + assert!(text.contains("install/slack")); +} + +// ─── Slack shows in installed list ────────────────────────────────── + +#[tokio::test] +async fn integrations_page_shows_installed_slack() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "#alerts".into(), + config: IntegrationConfig::Slack { + team_id: "T123".into(), + team_name: "Test".into(), + channel_id: "C456".into(), + channel_name: "#alerts".into(), + access_token: "xoxb-test".into(), + webhook_url: "https://hooks.slack.com/test".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/settings/integrations") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("#alerts")); + assert!(text.contains("Slack")); +} + +// ─── Slack OAuth callback without session ─────────────────────────── + +#[tokio::test] +async fn slack_callback_without_state_returns_error() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/integrations/slack/callback?code=test-code") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn slack_callback_with_error_redirects() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/integrations/slack/callback?state=testorg&error=access_denied") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("install/slack")); + assert!(location.contains("error=")); + assert!(location.contains("access_denied")); +} + +#[tokio::test] +async fn slack_callback_without_oauth_config_returns_503() { + let (app, sessions, _) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/integrations/slack/callback?code=test-code&state=testorg") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // No SlackConfig set, so should return 503 + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +// ─── Reinstall Slack ───────────────────────────────────────────────── + +#[tokio::test] +async fn reinstall_slack_redirects_to_oauth_error_without_slack_config() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let created = integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "#deploys".into(), + config: IntegrationConfig::Slack { + team_id: "T123".into(), + team_name: "My Team".into(), + channel_id: "C456".into(), + channel_name: "#deploys".into(), + access_token: "xoxb-test".into(), + webhook_url: "https://hooks.slack.com/test".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let body = format!("_csrf=test-csrf"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(&format!( + "/orgs/testorg/settings/integrations/{}/reinstall", + created.id + )) + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // No SlackConfig set → 503 + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn reinstall_slack_non_admin_returns_403() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session_member(&sessions).await; + + let created = integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "#deploys".into(), + config: IntegrationConfig::Slack { + team_id: "T123".into(), + team_name: "My Team".into(), + channel_id: "C456".into(), + channel_name: "#deploys".into(), + access_token: "xoxb-test".into(), + webhook_url: "https://hooks.slack.com/test".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let body = format!("_csrf=test-csrf"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(&format!( + "/orgs/testorg/settings/integrations/{}/reinstall", + created.id + )) + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn reinstall_slack_invalid_csrf_returns_403() { + let (app, sessions, integrations) = build_app_with_integrations(); + let cookie = create_test_session(&sessions).await; + + let created = integrations + .create_integration(&CreateIntegrationInput { + organisation: "testorg".into(), + integration_type: IntegrationType::Slack, + name: "#deploys".into(), + config: IntegrationConfig::Slack { + team_id: "T123".into(), + team_name: "My Team".into(), + channel_id: "C456".into(), + channel_name: "#deploys".into(), + access_token: "xoxb-test".into(), + webhook_url: "https://hooks.slack.com/test".into(), + }, + created_by: "user-123".into(), + }) + .await + .unwrap(); + + let body = format!("_csrf=wrong-csrf"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri(&format!( + "/orgs/testorg/settings/integrations/{}/reinstall", + created.id + )) + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/forage-server/src/tests/webhook_delivery_tests.rs b/crates/forage-server/src/tests/webhook_delivery_tests.rs index 0f850a8..3218dd6 100644 --- a/crates/forage-server/src/tests/webhook_delivery_tests.rs +++ b/crates/forage-server/src/tests/webhook_delivery_tests.rs @@ -105,11 +105,16 @@ fn test_event(org: &str) -> NotificationEvent { release: Some(ReleaseContext { slug: "my-api-v2".into(), artifact_id: "art_abc".into(), + release_intent_id: "ri_1".into(), destination: "prod-eu".into(), environment: "production".into(), source_username: "alice".into(), + source_user_id: String::new(), commit_sha: "deadbeef1234567".into(), commit_branch: "main".into(), + context_title: "Deploy v2.0 succeeded".into(), + context_web: String::new(), + destination_count: 1, error_message: None, }), } @@ -127,11 +132,16 @@ fn failed_event(org: &str) -> NotificationEvent { release: Some(ReleaseContext { slug: "my-api-v2".into(), artifact_id: "art_abc".into(), + release_intent_id: "ri_2".into(), destination: "prod-eu".into(), environment: "production".into(), source_username: "bob".into(), + source_user_id: String::new(), commit_sha: "cafebabe0000000".into(), commit_branch: "hotfix/fix-crash".into(), + context_title: "Deploy v2.0 failed".into(), + context_web: String::new(), + destination_count: 1, error_message: Some("container exited with code 137".into()), }), } @@ -143,7 +153,7 @@ fn failed_event(org: &str) -> NotificationEvent { async fn dispatcher_delivers_webhook_to_http_server() { let (url, receiver) = start_receiver().await; let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let event = test_event("testorg"); let integration = store @@ -195,7 +205,7 @@ async fn dispatcher_delivers_webhook_to_http_server() { async fn dispatcher_signs_webhook_with_hmac() { let (url, receiver) = start_receiver().await; let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let secret = "webhook-secret-42"; let event = test_event("testorg"); @@ -236,7 +246,7 @@ async fn dispatcher_signs_webhook_with_hmac() { async fn dispatcher_delivers_failed_event_with_error_message() { let (url, receiver) = start_receiver().await; let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let event = failed_event("testorg"); let integration = store @@ -275,7 +285,7 @@ async fn dispatcher_delivers_failed_event_with_error_message() { async fn dispatcher_records_successful_delivery() { let (url, _receiver) = start_receiver().await; let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let event = test_event("testorg"); let integration = store @@ -313,7 +323,7 @@ async fn dispatcher_retries_on_server_error() { *receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR); let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let event = test_event("testorg"); let integration = store @@ -351,7 +361,7 @@ async fn dispatcher_retries_on_server_error() { async fn dispatcher_handles_unreachable_url() { // Port 1 is almost certainly not listening let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new()); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); let event = test_event("testorg"); let integration = store @@ -421,7 +431,7 @@ async fn full_flow_event_routes_and_delivers() { // Should only match testorg's integration (not otherorg's) assert_eq!(tasks.len(), 1); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); for task in &tasks { dispatcher.dispatch(task).await; } @@ -516,7 +526,7 @@ async fn disabled_rule_filters_event_type() { assert_eq!(tasks.len(), 1, "release_failed should still match"); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); dispatcher.dispatch(&tasks[0]).await; let deliveries = receiver.deliveries.lock().unwrap(); @@ -566,7 +576,7 @@ async fn multiple_integrations_all_receive_same_event() { forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await; assert_eq!(tasks.len(), 2); - let dispatcher = NotificationDispatcher::new(store.clone()); + let dispatcher = NotificationDispatcher::new(store.clone(), String::new()); for task in &tasks { dispatcher.dispatch(task).await; } diff --git a/specs/features/006-notification-integrations.md b/specs/features/006-notification-integrations.md index 8d9dc55..4ccd3bb 100644 --- a/specs/features/006-notification-integrations.md +++ b/specs/features/006-notification-integrations.md @@ -238,7 +238,7 @@ Notification rules: 1. Admin clicks "Add Slack" → `POST /orgs/{org}/settings/integrations/slack` with CSRF 2. Server generates OAuth state (CSRF + org), stores in session, redirects to: - `https://slack.com/oauth/v2/authorize?client_id=...&scope=incoming-webhook,chat:write&redirect_uri=...&state=...` + `https://slack.com/oauth/v2/authorize?client_id=...&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri=...&state=...` 3. User authorizes in Slack 4. Slack redirects to `GET /orgs/{org}/settings/integrations/slack/callback?code=...&state=...` 5. Server validates state, exchanges code for access token via Slack API diff --git a/static/css/style.css b/static/css/style.css index 0c01362..2e2d779 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-800:oklch(45.9% .187 3.815);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.end\!{inset-inline-end:var(--spacing)!important}.-top-3{top:calc(var(--spacing) * -3)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-\[3px\]{top:3px}.right-1\.5{right:calc(var(--spacing) * 1.5)}.left-0{left:calc(var(--spacing) * 0)}.left-0\.5{left:calc(var(--spacing) * .5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[3px\]{left:3px}.left-\[calc\(100\%-1\.125rem\)\]{left:calc(100% - 1.125rem)}.z-20{z-index:20}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-auto{margin-top:auto}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-48{width:calc(var(--spacing) * 48)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[200px\]{max-width:200px}.max-w-\[250px\]{max-width:250px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[140px\]{min-width:140px}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-blue-200{border-color:var(--color-blue-200)}.border-gray-50{border-color:var(--color-gray-50)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-900{border-color:var(--color-gray-900)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-purple-200{border-color:var(--color-purple-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-transparent{border-color:#0000}.border-t-gray-600{border-top-color:var(--color-gray-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-400{background-color:var(--color-blue-400)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-gray-950{background-color:var(--color-gray-950)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-400{background-color:var(--color-orange-400)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-pink-100{background-color:var(--color-pink-100)}.bg-pink-500{background-color:var(--color-pink-500)}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-500{background-color:var(--color-violet-500)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.p-1{padding:calc(var(--spacing) * 1)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-400{color:var(--color-blue-400)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-700{color:var(--color-indigo-700)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-pink-800{color:var(--color-pink-800)}.text-purple-400{color:var(--color-purple-400)}.text-purple-700{color:var(--color-purple-700)}.text-purple-800{color:var(--color-purple-800)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-white{color:var(--color-white)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.text-yellow-800{color:var(--color-yellow-800)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.group-open\:hidden:is(:where(.group):is([open],:popover-open,:open) *){display:none}.group-open\:inline:is(:where(.group):is([open],:popover-open,:open) *){display:inline}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}@media (hover:hover){.group-hover\:border-gray-300:is(:where(.group):hover *){border-color:var(--color-gray-300)}.group-hover\:text-gray-500:is(:where(.group):hover *){color:var(--color-gray-500)}.group-hover\:text-violet-700:is(:where(.group):hover *){color:var(--color-violet-700)}}.first\:rounded-t-lg:first-child{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.last\:rounded-b-lg:last-child{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-black:hover{color:var(--color-black)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-700:hover{color:var(--color-green-700)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-800:hover{color:var(--color-red-800)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-sm:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-gray-400:focus{--tw-ring-color:var(--color-gray-400)}.focus\:ring-gray-900:focus{--tw-ring-color:var(--color-gray-900)}.focus\:ring-green-500:focus{--tw-ring-color:var(--color-green-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.has-\[\:checked\]\:border-blue-500:has(:checked){border-color:var(--color-blue-500)}.has-\[\:checked\]\:bg-blue-50:has(:checked){background-color:var(--color-blue-50)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}@media (prefers-color-scheme:dark){:root,:host{--color-white:oklch(14.5% .015 260);--color-black:oklch(98% .002 248);--color-gray-50:oklch(17.5% .02 260);--color-gray-100:oklch(21% .024 265);--color-gray-200:oklch(27.8% .025 257);--color-gray-300:oklch(37.3% .025 260);--color-gray-400:oklch(55.1% .02 264);--color-gray-500:oklch(60% .02 264);--color-gray-600:oklch(70.7% .017 261);--color-gray-700:oklch(80% .012 258);--color-gray-800:oklch(87.2% .008 258);--color-gray-900:oklch(93% .005 265);--color-gray-950:oklch(96.7% .003 265);--color-green-50:oklch(20% .04 155);--color-green-100:oklch(25% .06 155);--color-green-200:oklch(30% .08 155);--color-green-300:oklch(42% .12 154);--color-green-700:oklch(75% .15 150);--color-green-800:oklch(80% .12 150);--color-red-50:oklch(22% .04 17);--color-red-200:oklch(32% .06 18);--color-red-600:oklch(65% .2 27);--color-red-700:oklch(72% .18 27);--color-red-800:oklch(77% .15 27);--color-blue-100:oklch(22% .04 255);--color-blue-600:oklch(62% .2 263);--color-blue-700:oklch(72% .17 264);--color-blue-800:oklch(77% .15 265);--color-orange-100:oklch(25% .05 75);--color-orange-800:oklch(78% .13 37);--color-yellow-100:oklch(25% .06 103);--color-yellow-700:oklch(72% .12 66);--color-yellow-800:oklch(77% .1 62);--color-violet-100:oklch(22% .04 295);--color-violet-200:oklch(28% .06 294);--color-violet-400:oklch(45% .14 293);--color-violet-600:oklch(60% .2 293);--color-violet-800:oklch(75% .18 293);--color-purple-100:oklch(22% .04 307);--color-purple-800:oklch(75% .17 304);--color-pink-100:oklch(22% .04 342);--color-pink-800:oklch(75% .15 4);--color-amber-400:oklch(80% .17 84)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-800:oklch(45.9% .187 3.815);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.end\!{inset-inline-end:var(--spacing)!important}.-top-3{top:calc(var(--spacing) * -3)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-\[3px\]{top:3px}.right-1\.5{right:calc(var(--spacing) * 1.5)}.left-0{left:calc(var(--spacing) * 0)}.left-0\.5{left:calc(var(--spacing) * .5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[3px\]{left:3px}.left-\[calc\(100\%-1\.125rem\)\]{left:calc(100% - 1.125rem)}.z-20{z-index:20}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-8{margin-block:calc(var(--spacing) * 8)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-auto{margin-top:auto}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-48{width:calc(var(--spacing) * 48)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[200px\]{max-width:200px}.max-w-\[250px\]{max-width:250px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[140px\]{min-width:140px}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-blue-200{border-color:var(--color-blue-200)}.border-gray-50{border-color:var(--color-gray-50)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-900{border-color:var(--color-gray-900)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-purple-200{border-color:var(--color-purple-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-transparent{border-color:#0000}.border-t-gray-600{border-top-color:var(--color-gray-600)}.bg-\[\#4A154B\]{background-color:#4a154b}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-400{background-color:var(--color-blue-400)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-300{background-color:var(--color-gray-300)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-gray-950{background-color:var(--color-gray-950)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-400{background-color:var(--color-orange-400)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-pink-100{background-color:var(--color-pink-100)}.bg-pink-500{background-color:var(--color-pink-500)}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-500{background-color:var(--color-violet-500)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.p-1{padding:calc(var(--spacing) * 1)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-400{color:var(--color-blue-400)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-700{color:var(--color-indigo-700)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-pink-800{color:var(--color-pink-800)}.text-purple-400{color:var(--color-purple-400)}.text-purple-700{color:var(--color-purple-700)}.text-purple-800{color:var(--color-purple-800)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-white{color:var(--color-white)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.text-yellow-800{color:var(--color-yellow-800)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.group-open\:hidden:is(:where(.group):is([open],:popover-open,:open) *){display:none}.group-open\:inline:is(:where(.group):is([open],:popover-open,:open) *){display:inline}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}@media (hover:hover){.group-hover\:border-gray-300:is(:where(.group):hover *){border-color:var(--color-gray-300)}.group-hover\:text-gray-500:is(:where(.group):hover *){color:var(--color-gray-500)}.group-hover\:text-violet-700:is(:where(.group):hover *){color:var(--color-violet-700)}}.first\:rounded-t-lg:first-child{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.last\:rounded-b-lg:last-child{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-black:hover{color:var(--color-black)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-700:hover{color:var(--color-green-700)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-800:hover{color:var(--color-red-800)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-sm:hover{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-gray-400:focus{--tw-ring-color:var(--color-gray-400)}.focus\:ring-gray-900:focus{--tw-ring-color:var(--color-gray-900)}.focus\:ring-green-500:focus{--tw-ring-color:var(--color-green-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.has-\[\:checked\]\:border-blue-500:has(:checked){border-color:var(--color-blue-500)}.has-\[\:checked\]\:bg-blue-50:has(:checked){background-color:var(--color-blue-50)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}@media (prefers-color-scheme:dark){:root,:host{--color-white:oklch(14.5% .015 260);--color-black:oklch(98% .002 248);--color-gray-50:oklch(17.5% .02 260);--color-gray-100:oklch(21% .024 265);--color-gray-200:oklch(27.8% .025 257);--color-gray-300:oklch(37.3% .025 260);--color-gray-400:oklch(55.1% .02 264);--color-gray-500:oklch(60% .02 264);--color-gray-600:oklch(70.7% .017 261);--color-gray-700:oklch(80% .012 258);--color-gray-800:oklch(87.2% .008 258);--color-gray-900:oklch(93% .005 265);--color-gray-950:oklch(96.7% .003 265);--color-green-50:oklch(20% .04 155);--color-green-100:oklch(25% .06 155);--color-green-200:oklch(30% .08 155);--color-green-300:oklch(42% .12 154);--color-green-700:oklch(75% .15 150);--color-green-800:oklch(80% .12 150);--color-red-50:oklch(22% .04 17);--color-red-200:oklch(32% .06 18);--color-red-600:oklch(65% .2 27);--color-red-700:oklch(72% .18 27);--color-red-800:oklch(77% .15 27);--color-blue-100:oklch(22% .04 255);--color-blue-600:oklch(62% .2 263);--color-blue-700:oklch(72% .17 264);--color-blue-800:oklch(77% .15 265);--color-orange-100:oklch(25% .05 75);--color-orange-800:oklch(78% .13 37);--color-yellow-100:oklch(25% .06 103);--color-yellow-700:oklch(72% .12 66);--color-yellow-800:oklch(77% .1 62);--color-violet-100:oklch(22% .04 295);--color-violet-200:oklch(28% .06 294);--color-violet-400:oklch(45% .14 293);--color-violet-600:oklch(60% .2 293);--color-violet-800:oklch(75% .18 293);--color-purple-100:oklch(22% .04 307);--color-purple-800:oklch(75% .17 304);--color-pink-100:oklch(22% .04 342);--color-pink-800:oklch(75% .15 4);--color-amber-400:oklch(80% .17 84)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/templates/pages/account.html.jinja b/templates/pages/account.html.jinja index 255544d..af7eaec 100644 --- a/templates/pages/account.html.jinja +++ b/templates/pages/account.html.jinja @@ -84,6 +84,47 @@ + {# Linked Slack accounts #} + {% if has_slack_oauth %} +
+

Linked Slack accounts

+

Link your Slack identity to receive personal DMs about your releases.

+ + {% if slack_links %} +
+ {% for link in slack_links %} +
+
+
+ +
+
+

{{ link.team_name }}

+

@{{ link.slack_username }}

+
+
+
+ + + +
+
+ {% endfor %} +
+ {% endif %} + + + + Add to Slack + +
+ {% endif %} + {# Notification preferences #}

Notification preferences

diff --git a/templates/pages/install_slack.html.jinja b/templates/pages/install_slack.html.jinja index 8446c40..afb5df8 100644 --- a/templates/pages/install_slack.html.jinja +++ b/templates/pages/install_slack.html.jinja @@ -7,8 +7,8 @@