diff --git a/apps/api/internal/handler/instance.go b/apps/api/internal/handler/instance.go index e4c1bd86..d8c4d5f3 100644 --- a/apps/api/internal/handler/instance.go +++ b/apps/api/internal/handler/instance.go @@ -23,7 +23,7 @@ import ( // Allowed instance setting section keys (must match migration seed). var allowedSettingKeys = map[string]bool{ "general": true, "email": true, "auth": true, "oauth": true, "ai": true, "image": true, - "github_app": true, + "github_app": true, "slack_app": true, } // InstanceHandler serves instance setup (first-run); no auth required. @@ -269,7 +269,7 @@ func (h *InstanceSettingsHandler) GetSettings(c *gin.Context) { out[k] = decryptSectionSecretsInternal(k, row.Value) } // Ensure all sections exist with defaults (migration seed may not have run if DB was created before seed) - for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image", "github_app"} { + for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image", "github_app", "slack_app"} { if _, ok := out[key]; !ok { out[key] = defaultSettingValue(key) } @@ -284,6 +284,7 @@ var secretKeysBySection = map[string][]string{ "ai": {"api_key"}, "image": {"unsplash_access_key"}, "github_app": {"private_key", "client_secret", "webhook_secret"}, + "slack_app": {"client_secret", "signing_secret"}, } // decryptSectionSecretsInternal returns a copy of m with secret fields decrypted. @@ -328,6 +329,8 @@ func defaultSettingValue(key string) model.JSONMap { "app_id": "", "app_name": "", "client_id": "", "client_secret_set": false, "private_key_set": false, "webhook_secret_set": false, } + case "slack_app": + return model.JSONMap{"client_id": "", "client_secret_set": false, "signing_secret_set": false} default: return model.JSONMap{} } @@ -517,6 +520,41 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) { setSecret("webhook_secret", "webhook_secret_set") value = merged } + if key == "slack_app" { + existing, _ := h.Settings.Get(c.Request.Context(), "slack_app") + merged := model.JSONMap{} + + if existing != nil { + for k, v := range existing.Value { + merged[k] = v + } + } else { + for k, v := range defaultSettingValue("slack_app") { + merged[k] = v + } + } + + /* plain feilds */ + for _, feild := range []string{"client_id"} { + if v, ok := req.Value[feild]; ok { + merged[feild] = v + } + } + + /* Secret fields (Encrypt & Set flag) */ + setSecret := func(feild, setKey string) { + if v, ok := req.Value[feild]; ok { + if s, ok := v.(string); ok && s != "" { + merged[feild] = crypto.EncryptOrPlain(s) + merged[setKey] = true + } + } + } + + setSecret("client_secret", "client_secret_set") + setSecret("signing_secret", "signing_secret_set") + value = merged + } if err := h.Settings.Upsert(c.Request.Context(), key, value); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) return diff --git a/apps/web/package.json b/apps/web/package.json index acfe667d..0d519f1c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "start": "vite", "build": "tsc -b && vite build", "typecheck": "tsc -b --noEmit", "lint": "eslint --max-warnings=0 .", diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ddaae18c..a6400070 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -607,6 +607,15 @@ export interface InstanceGitHubAppSection { webhook_secret_set?: boolean; } +/* Slack App config (instance admin). Secrets are never echoed back. */ +export interface InstanceSlackAppSection { + client_id?: string; + client_secret?: string; + client_secret_set?: boolean; + signing_secret?: string; + signing_secret_set?: boolean; +} + /** Available integration provider, returned by GET /api/integrations/. */ export interface IntegrationApiResponse { id: string; diff --git a/apps/web/src/components/layout/InstanceAdminLayout.tsx b/apps/web/src/components/layout/InstanceAdminLayout.tsx index 3a463a85..455d3fe8 100644 --- a/apps/web/src/components/layout/InstanceAdminLayout.tsx +++ b/apps/web/src/components/layout/InstanceAdminLayout.tsx @@ -281,6 +281,7 @@ const AUTH_SUB_LABEL: Record = { const INTEGRATIONS_SUB_LABEL: Record = { github: 'GitHub', + slack: 'Slack', }; export function InstanceAdminLayout() { diff --git a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationSlackPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationSlackPage.tsx new file mode 100644 index 00000000..2695aba0 --- /dev/null +++ b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationSlackPage.tsx @@ -0,0 +1,328 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Eye, EyeOff } from 'lucide-react'; +import { Button, Input } from '../../components/ui'; +import { InstanceAdminCopyRow } from '../../components/instance-admin'; +import { instanceSettingsService } from '../../services/instanceService'; +import { authService } from '../../services/authService'; +import { getApiErrorMessage } from '../../api/client'; +import { useDocumentTitle } from '../../hooks/useDocumentTitle'; +import type { InstanceSlackAppSection } from '../../api/types'; +import { useTranslation, Trans } from 'react-i18next'; + +const IconSlack = () => ( + + + + + + +); + +/** + * Configure the Slack App credentials for the whole instance. Until this is + * filled in, no workspace can connect Slack. Secrets (client secret, signing + * secret) are encrypted at rest and never echoed back from the API — the form + * clears the field after save and shows a *_set badge instead. + */ +export function InstanceAdminIntegrationSlackPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + // Form state. Secrets default to empty; if the corresponding *_set is true, + // the placeholder tells the user "(unchanged if blank)". + const [clientID, setClientID] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [clientSecretSet, setClientSecretSet] = useState(false); + const [signingSecret, setSigningSecret] = useState(''); + const [signingSecretSet, setSigningSecretSet] = useState(false); + + // For the snapshot we compare against to compute isDirty. + const [initial, setInitial] = useState({ + clientID: '', + }); + + const [showClientSecret, setShowClientSecret] = useState(false); + const [showSigningSecret, setShowSigningSecret] = useState(false); + + // URL the admin pastes into the Slack App's "OAuth & Permissions" settings. + const [oauthRedirectBase, setOauthRedirectBase] = useState(''); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + useDocumentTitle('Slack integration'); + + const redirectUrl = useMemo( + () => (oauthRedirectBase ? `${oauthRedirectBase}/auth/slack/callback` : ''), + [oauthRedirectBase], + ); + + useEffect(() => { + let cancelled = false; + Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()]) + .then(([settings, cfg]) => { + if (cancelled) return; + const s = (settings.slack_app || {}) as InstanceSlackAppSection; + setClientID(s.client_id ?? ''); + setClientSecretSet(s.client_secret_set ?? false); + setSigningSecretSet(s.signing_secret_set ?? false); + setInitial({ + clientID: s.client_id ?? '', + }); + if (cfg.oauth_redirect_base) setOauthRedirectBase(cfg.oauth_redirect_base); + else if (typeof window !== 'undefined') setOauthRedirectBase(window.location.origin); + }) + .catch((err) => { + if (!cancelled) setError(getApiErrorMessage(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const isDirty = + clientID !== initial.clientID || clientSecret.length > 0 || signingSecret.length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + setSaving(true); + + const payload: InstanceSlackAppSection = { + client_id: clientID.trim(), + }; + if (clientSecret.trim()) payload.client_secret = clientSecret.trim(); + if (signingSecret.trim()) payload.signing_secret = signingSecret.trim(); + + instanceSettingsService + .updateSection('slack_app', payload as import('../../api/types').InstanceSettingSectionValue) + .then((res) => { + const v = (res.value || {}) as InstanceSlackAppSection; + setClientID(v.client_id ?? ''); + setClientSecretSet(v.client_secret_set ?? false); + setSigningSecretSet(v.signing_secret_set ?? false); + setInitial({ + clientID: v.client_id ?? '', + }); + // Clear local secret fields — they've been saved. + setClientSecret(''); + setSigningSecret(''); + setSuccess('Slack App settings saved. Workspaces can now connect.'); + }) + .catch((err) => setError(getApiErrorMessage(err))) + .finally(() => setSaving(false)); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + return ( +
+
+ + + +
+

+ {t('instanceAdmin.slack.title', 'Slack App')} +

+

+ {t( + 'instanceAdmin.slack.description', + 'Register a Slack App and paste its credentials here. The App is the bridge that lets Devlane exchange notifications, synchronize activity, and enable Slack-powered workflows across all workspaces on this instance.', + )} +

+
+
+ + {error &&

{error}

} + {success &&

{success}

} + +
+

+ {t('instanceAdmin.slack.quickSetup', 'First time? Quick setup:')} +

+
    +
  1. + + Open{' '} + + Slack API → Your Apps → Create New App + + . + +
  2. + +
  3. + + {' '} + Under OAuth & Permissions, add the Redirect URL + provided below. + +
  4. +
  5. + + Add the required bot scopes: chat:write,{' '} + channels:read,{' '} + groups:read. + +
  6. +
  7. {t('instanceAdmin.slack.step4', 'Install the app in your Slack workspace.')}
  8. +
  9. + + Copy the Client ID, Client Secret, and Signing Secret from{' '} + Basic Information into Devlane. + +
  10. +
  11. + + Still need help? See the{' '} + + Slack Quickstart Guide + + . + +
  12. +
+
+ +
+
+

+ {t('instanceAdmin.slack.credentialsTitle', 'Credentials from your Slack App')} +

+
+ setClientID(e.target.value)} + autoComplete="off" + placeholder="e.g. 1234567890.1234567890123" + /> +

+ + Found under Basic Information → App Credentials. + This value is public and safe to share. + +

+ +
+ setClientSecret(e.target.value)} + autoComplete="new-password" + placeholder={clientSecretSet ? '(unchanged if left blank)' : 'Enter client secret'} + /> + +
+

+ + Sent with the Client ID during the OAuth token exchange ( + oauth.v2.access). Stored encrypted at rest. + +

+ +
+ setSigningSecret(e.target.value)} + autoComplete="new-password" + placeholder={ + signingSecretSet ? '(unchanged if left blank)' : 'Enter signing secret' + } + /> + +
+

+ + Used to verify that inbound requests genuinely come from Slack. Stored encrypted at + rest (set INSTANCE_ENCRYPTION_KEY on the API). + +

+
+
+ +
+

+ {t('instanceAdmin.slack.urlsTitle', 'Devlane URLs to paste into the Slack App')} +

+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx index fd11e166..4573a11e 100644 --- a/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx +++ b/apps/web/src/pages/instance-admin/InstanceAdminIntegrationsPage.tsx @@ -6,21 +6,44 @@ import { Skeleton } from '../../components/ui'; import { instanceSettingsService } from '../../services/instanceService'; import { getApiErrorMessage } from '../../api/client'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; -import type { InstanceGitHubAppSection } from '../../api/types'; +import type { InstanceGitHubAppSection, InstanceSlackAppSection } from '../../api/types'; const IconGitHub = () => ( ); +const IconSlack = () => ( + + + + + + +); + +type ProviderCategory = 'source-control' | 'messaging'; interface ProviderRow { - id: 'github'; + id: 'github' | 'slack'; name: string; desc: string; Icon: () => React.ReactElement; editPath: string; configured: boolean; + category: ProviderCategory; } function isGitHubAppConfigured(s: InstanceGitHubAppSection): boolean { @@ -34,9 +57,14 @@ function isGitHubAppConfigured(s: InstanceGitHubAppSection): boolean { ); } +function isSlackConfigured(s: InstanceSlackAppSection): boolean { + return !!(s.client_id && s.client_secret_set && s.signing_secret_set); +} + export function InstanceAdminIntegrationsPage() { const { t } = useTranslation(); const [github, setGithub] = useState({}); + const [slack, setSlack] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useDocumentTitle(t('instanceAdmin.integrations.documentTitle', 'Integrations')); @@ -49,6 +77,8 @@ export function InstanceAdminIntegrationsPage() { if (cancelled) return; const g = (settings.github_app || {}) as InstanceGitHubAppSection; setGithub(g); + const sl = (settings.slack || {}) as InstanceSlackAppSection; + setSlack(sl); }) .catch((err) => { if (!cancelled) setError(getApiErrorMessage(err)); @@ -72,6 +102,16 @@ export function InstanceAdminIntegrationsPage() { Icon: IconGitHub, editPath: '/instance-admin/integrations/github', configured: isGitHubAppConfigured(github), + category: 'source-control', + }, + { + id: 'slack', + name: 'Slack', + desc: 'Post Devlane notifications (assigned, state changed, commented, mentioned) to a Slack channel per project.', + Icon: IconSlack, + editPath: '/instance-admin/integrations/slack', + configured: isSlackConfigured(slack), + category: 'messaging', }, ]; @@ -83,7 +123,7 @@ export function InstanceAdminIntegrationsPage() {
    - {[1].map((i) => ( + {[1, 2].map((i) => (
  • ), ); +const InstanceAdminIntegrationSlackPage = lazy(() => + import('../pages/instance-admin').then((m) => + page({ InstanceAdminIntegrationSlackPage: m.InstanceAdminIntegrationSlackPage }), + ), +); + const InstanceSetupWelcomePage = lazy(() => import('../pages/setup').then((m) => page({ InstanceSetupWelcomePage: m.InstanceSetupWelcomePage }), @@ -399,6 +405,14 @@ const router = createBrowserRouter([ ), }, + { + path: 'integrations/slack', + element: ( + }> + + + ), + }, ], }, {