diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 1322338f0c..f2afbc5361 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -2,6 +2,7 @@ import type { ExecutionContext, ScheduledController } from '@cloudflare/workers- import type { Bindings } from '../../supabase/functions/_backend/utils/cloudflare.ts' import { app as accept_invitation } from '../../supabase/functions/_backend/private/accept_invitation.ts' import { app as admin_credits } from '../../supabase/functions/_backend/private/admin_credits.ts' +import { app as admin_org_support_channel } from '../../supabase/functions/_backend/private/admin_org_support_channel.ts' import { app as admin_stats } from '../../supabase/functions/_backend/private/admin_stats.ts' import { app as channel_device } from '../../supabase/functions/_backend/private/channel_device.ts' import { app as channel_stats } from '../../supabase/functions/_backend/private/channel_stats.ts' @@ -138,6 +139,7 @@ appPrivate.route('/invite_existing_user_to_org', invite_existing_user_to_org) appPrivate.route('/set_org_email', set_org_email) appPrivate.route('/validate_password_compliance', validate_password_compliance) appPrivate.route('/admin_credits', admin_credits) +appPrivate.route('/admin_org_support_channel', admin_org_support_channel) appPrivate.route('/admin_stats', admin_stats) appPrivate.route('/stats', stats_priv) appPrivate.route('/channel_stats', channel_stats) diff --git a/messages/en.context.json b/messages/en.context.json index a3406926ab..997555e165 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -269,6 +269,17 @@ "admin-notifications-sent-last-month": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-notifications-title": "Used in Capgo web console areas: pages/admin/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-organizations": "Used in Capgo web console areas: constants, pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-adoption-chart-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-adoption-chart-title": "Used in Capgo web console areas: pages/admin/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-channel-adoption": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-channel-chart-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-channel-chart-title": "Used in Capgo web console areas: pages/admin/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-channel-count": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-count": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-sso-adoption": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-sso-chart-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-sso-chart-title": "Used in Capgo web console areas: pages/admin/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "enterprise-sso-count": "Used in Capgo web console areas: pages/admin/dashboard. Role: chart series label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-users-country-breakdown": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-users-country-breakdown-description": "Used in Capgo web console areas: pages/admin/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "admin-users-country-chart": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2825,6 +2836,7 @@ "sso-add-provider": "Used in Capgo web console areas: components/organizations. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-check-failed": "Used in Capgo web console. Role: toast or status message about \"sso check failed\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-configuration": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "sso-configured": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-configuration-description": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-copied-to-clipboard": "Used in Capgo web console areas: components/organizations. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-copy": "Used in Capgo web console areas: components/organizations. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2836,6 +2848,7 @@ "sso-delete-confirm": "Used in Capgo web console areas: components/organizations. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-delete-title": "Used in Capgo web console areas: components/organizations. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-detected": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "sso-required": "Used in Capgo web console areas: pages. Role: UI sentence shown when a domain requires SSO. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-dismiss": "Used in Capgo web console areas: components/organizations. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-dns-record-name": "Used in Capgo web console areas: components/organizations. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "sso-dns-record-type": "Used in Capgo web console areas: components/organizations. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -2958,6 +2971,20 @@ "support-usernames-prompt-remind-later": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "support-usernames-prompt-save": "Used in Capgo web console areas: components/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "support-usernames-prompt-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-clear": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-description": "Used in Capgo web console areas: pages/settings/organization. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-discord": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-none": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-open": "Used in Capgo web console areas: pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-save": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-saved": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-set": "Used in Capgo web console areas: pages/admin/dashboard. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-slack": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-teams": "Used in Capgo web console areas: pages/admin/dashboard, pages/settings/organization. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-title": "Used in Capgo web console areas: pages/settings/organization. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-type": "Used in Capgo web console areas: pages/admin/dashboard. Role: form field label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "support-channel-url": "Used in Capgo web console areas: pages/admin/dashboard. Role: form field label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "switch-to-billing-period": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "team": "Used in Capgo web console areas: constants. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "terms-of-service": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 6c13c03cff..cc37c46ea7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -243,6 +243,17 @@ "admin-credits-title": "Grant Credits", "admin-dashboard": "Admin Dashboard", "admin-organizations": "Organizations", + "enterprise-adoption-chart-description": "Share of paid Enterprise organizations that have enabled SSO or a dedicated support channel.", + "enterprise-adoption-chart-title": "Enterprise feature adoption", + "enterprise-channel-adoption": "Support channel adoption", + "enterprise-channel-chart-description": "Cumulative paid Enterprise organizations versus those with a Slack, Discord, or Teams channel.", + "enterprise-channel-chart-title": "Enterprise support channels", + "enterprise-channel-count": "Support channels", + "enterprise-count": "Enterprise organizations", + "enterprise-sso-adoption": "SSO adoption", + "enterprise-sso-chart-description": "Cumulative paid Enterprise organizations versus those with active SSO.", + "enterprise-sso-chart-title": "Enterprise SSO", + "enterprise-sso-count": "SSO configured", "admin-users-email-type-breakdown": "Email Type Breakdown", "admin-users-email-type-breakdown-description": "Registration mix in the selected period, split between professional, personal, and disposable email domains.", "admin-users-email-type-disposable": "Disposable Emails", @@ -2570,15 +2581,31 @@ "support-usernames-prompt-remind-later": "Remind me later", "support-usernames-prompt-helper": "Add Discord and GitHub so we can match your Capgo Cloud account when you open a support issue. Priority GitHub support is included on Maker and above, and for everyone during trial.", "support-usernames-prompt-title": "Speed up Capgo Cloud support", + "support-channel": "Support channel", + "support-channel-clear": "Clear channel", + "support-channel-description": "Your dedicated Capgo support channel. Open it to talk with the Capgo team.", + "support-channel-discord": "Discord", + "support-channel-none": "Not set", + "support-channel-open": "Open channel", + "support-channel-save": "Save channel", + "support-channel-saved": "Support channel updated", + "support-channel-set": "Set channel", + "support-channel-slack": "Slack", + "support-channel-teams": "Teams", + "support-channel-title": "Dedicated support channel", + "support-channel-type": "Channel type", + "support-channel-url": "Channel link", "something-went-wrong-try-again-later": "Something went wrong! Try again later.", "sso-add-provider": "Add SSO Provider", "sso-configuration": "SSO Configuration", + "sso-configured": "SSO", "sso-configuration-description": "Configure SAML 2.0 Single Sign-On for your organization", "sso-create-provider": "Create Provider", "sso-creating": "Creating...", "sso-delete-confirm": "Are you sure you want to delete the SSO provider for {domain}?", "sso-delete-title": "Delete SSO Provider", "sso-detected": "Single Sign-On available", + "sso-required": "This domain requires Single Sign-On", "sso-dismiss": "Dismiss", "sso-dns-record-name": "Name", "sso-dns-record-type": "Type", diff --git a/playwright/e2e/sso-login.spec.ts b/playwright/e2e/sso-login.spec.ts index 0a2aa38f6c..41ea123397 100644 --- a/playwright/e2e/sso-login.spec.ts +++ b/playwright/e2e/sso-login.spec.ts @@ -54,7 +54,7 @@ test.describe('SSO Login Flow', () => { await expect.poll(() => checks).toBeGreaterThan(1) }) - test('should use SSO only when the domain has SSO', async ({ page }) => { + test('should keep password login when SSO is available but not enforced', async ({ page }) => { await page.route('**/private/sso/check-domain', async (route) => { await route.fulfill({ status: 200, @@ -63,6 +63,22 @@ test.describe('SSO Login Flow', () => { }) }) + await page.fill('[data-test="email"]', 'user@sso.example') + await expect(page.locator('[data-test="sso-login"]')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-password-ready="true"]')).toHaveCount(1) + await expect(page.locator('[data-test="password"]')).toBeVisible() + await expect(page.locator('[data-test="submit"]')).toBeVisible() + }) + + test('should hide password login when SSO is enforced', async ({ page }) => { + await page.route('**/private/sso/check-domain', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ has_sso: true, enforce_sso: true }), + }) + }) + await page.fill('[data-test="email"]', 'user@sso.example') await expect(page.locator('[data-test="sso-login"]')).toBeVisible({ timeout: 10000 }) await expect(page.locator('[data-password-ready="false"]')).toHaveCount(1) diff --git a/pr-preview/admin-enterprise-orgs.webp b/pr-preview/admin-enterprise-orgs.webp new file mode 100644 index 0000000000..89c0a85310 Binary files /dev/null and b/pr-preview/admin-enterprise-orgs.webp differ diff --git a/pr-preview/login-sso-enforced.webp b/pr-preview/login-sso-enforced.webp new file mode 100644 index 0000000000..f81255b9dd Binary files /dev/null and b/pr-preview/login-sso-enforced.webp differ diff --git a/pr-preview/login-sso-optional.webp b/pr-preview/login-sso-optional.webp new file mode 100644 index 0000000000..5e5e9d9810 Binary files /dev/null and b/pr-preview/login-sso-optional.webp differ diff --git a/pr-preview/org-support-channel.webp b/pr-preview/org-support-channel.webp new file mode 100644 index 0000000000..cda21edeb2 Binary files /dev/null and b/pr-preview/org-support-channel.webp differ diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index c58fb3f0f4..452f52e741 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -1530,6 +1530,36 @@ "table": "orgs", "type": "timestamp with time zone" }, + { + "default": null, + "generated": "", + "identity": "", + "name": "support_channel_type", + "notNull": false, + "position": 26, + "table": "orgs", + "type": "text" + }, + { + "default": null, + "generated": "", + "identity": "", + "name": "support_channel_url", + "notNull": false, + "position": 27, + "table": "orgs", + "type": "text" + }, + { + "default": null, + "generated": "", + "identity": "", + "name": "support_channel_set_at", + "notNull": false, + "position": 28, + "table": "orgs", + "type": "timestamp with time zone" + }, { "default": "now()", "generated": "", @@ -2009,6 +2039,20 @@ "type": "c", "valid": true }, + { + "definition": "CHECK (support_channel_type IS NULL OR (support_channel_type = ANY (ARRAY['slack'::text, 'discord'::text, 'teams'::text])))", + "name": "orgs_support_channel_type_check", + "table": "orgs", + "type": "c", + "valid": true + }, + { + "definition": "CHECK (support_channel_type IS NULL AND support_channel_url IS NULL OR support_channel_type IS NOT NULL AND support_channel_url IS NOT NULL AND char_length(support_channel_url) <= 2048 AND support_channel_url ~ '^https://'::text)", + "name": "orgs_support_channel_url_check", + "table": "orgs", + "type": "c", + "valid": true + }, { "definition": "UNIQUE (customer_id)", "name": "unique customer_id on orgs", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index f62cc02328..2925ae89e0 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -403,10 +403,15 @@ CREATE TABLE public.orgs ( auto_top_up_enabled boolean DEFAULT false NOT NULL, auto_top_up_threshold numeric(18,6) DEFAULT 10 NOT NULL, auto_top_up_last_attempt_at timestamp with time zone, + support_channel_type text, + support_channel_url text, + support_channel_set_at timestamp with time zone, CONSTRAINT orgs_max_apikey_expiration_days_valid CHECK (((max_apikey_expiration_days IS NULL) OR ((max_apikey_expiration_days >= 1) AND (max_apikey_expiration_days <= 365)))), CONSTRAINT orgs_onboarding_valid CHECK (((jsonb_typeof(onboarding) = 'object'::text) AND ((NOT (onboarding ? 'intent'::text)) OR ((onboarding ->> 'intent'::text) = ANY (ARRAY['unknown'::text, 'ota'::text, 'builder'::text, 'both'::text, 'exploring'::text]))))), CONSTRAINT orgs_password_policy_config_min_length_check CHECK (((password_policy_config IS NULL) OR ((jsonb_typeof(password_policy_config) = 'object'::text) AND ((NOT (password_policy_config ? 'min_length'::text)) OR ((jsonb_typeof((password_policy_config -> 'min_length'::text)) = 'number'::text) AND (((password_policy_config ->> 'min_length'::text))::numeric = trunc(((password_policy_config ->> 'min_length'::text))::numeric)) AND ((((password_policy_config ->> 'min_length'::text))::numeric >= (6)::numeric) AND (((password_policy_config ->> 'min_length'::text))::numeric <= (72)::numeric))))))), - CONSTRAINT orgs_required_encryption_key_valid CHECK (((required_encryption_key IS NULL) OR (length((required_encryption_key)::text) = ANY (ARRAY[20, 21])))) + CONSTRAINT orgs_required_encryption_key_valid CHECK (((required_encryption_key IS NULL) OR (length((required_encryption_key)::text) = ANY (ARRAY[20, 21])))), + CONSTRAINT orgs_support_channel_type_check CHECK (((support_channel_type IS NULL) OR (support_channel_type = ANY (ARRAY['slack'::text, 'discord'::text, 'teams'::text])))), + CONSTRAINT orgs_support_channel_url_check CHECK ((((support_channel_type IS NULL) AND (support_channel_url IS NULL)) OR ((support_channel_type IS NOT NULL) AND (support_channel_url IS NOT NULL) AND (char_length(support_channel_url) <= 2048) AND (support_channel_url ~ '^https://'::text)))) ); ALTER TABLE ONLY public.orgs REPLICA IDENTITY FULL; diff --git a/src/pages/admin/dashboard/organizations.vue b/src/pages/admin/dashboard/organizations.vue index 9db1c516bd..a17c903461 100644 --- a/src/pages/admin/dashboard/organizations.vue +++ b/src/pages/admin/dashboard/organizations.vue @@ -8,7 +8,10 @@ import type { TableColumn } from '~/components/comp_def' import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' +import { toast } from 'vue-sonner' import AdminFilterBar from '~/components/admin/AdminFilterBar.vue' +import AdminMultiLineChart from '~/components/admin/AdminMultiLineChart.vue' +import ChartCard from '~/components/dashboard/ChartCard.vue' import { formatLocalDate, formatLocalDateTime } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' import { defaultApiHost, useSupabase } from '~/services/supabase' @@ -18,6 +21,7 @@ import { useMainStore } from '~/stores/main' type BillingType = 'monthly' | 'yearly' type BillingFilter = BillingType | 'all' +type SupportChannelType = 'slack' | 'discord' | 'teams' interface OrganizationInsight { org_id: string @@ -40,6 +44,9 @@ interface OrganizationInsight { paid_at: string | null registered_at: string distribution_stage: string | null + has_sso: boolean + support_channel_type: SupportChannelType | null + support_channel_url: string | null } interface OrganizationInsightsResponse { @@ -51,6 +58,17 @@ interface OrganizationInsightsResponse { } } +interface EnterpriseAdoptionPoint { + date: string + enterprise_count: number + sso_count: number + channel_count: number +} + +interface EnterpriseAdoptionResponse { + trend?: EnterpriseAdoptionPoint[] +} + const { t } = useI18n() const displayStore = useDisplayStore() const mainStore = useMainStore() @@ -67,6 +85,13 @@ const selectedPlan = ref('Enterprise') const selectedBilling = ref('all') const paidOnly = ref(true) const searchQuery = ref('') +const adoptionTrend = ref([]) +const isLoadingAdoption = ref(false) +const channelEditorOpen = ref(false) +const channelEditorOrg = ref(null) +const channelEditorType = ref('') +const channelEditorUrl = ref('') +const isSavingChannel = ref(false) let loadOrganizationsSequence = 0 let searchReloadTimer: ReturnType | undefined @@ -106,6 +131,163 @@ function formatDistributionStage(stage: string | null) { return key ? t(key) : t('unknown') } +function formatSupportChannelType(type: SupportChannelType | null) { + if (type === 'slack') + return t('support-channel-slack') + if (type === 'discord') + return t('support-channel-discord') + if (type === 'teams') + return t('support-channel-teams') + return t('support-channel-none') +} + +function adoptionPercent(count: number, total: number) { + if (total <= 0) + return 0 + return Math.round((count / total) * 1000) / 10 +} + +function trendPoints(pick: (point: EnterpriseAdoptionPoint) => number) { + return adoptionTrend.value.map(point => ({ date: point.date, value: pick(point) })) +} + +const enterpriseCharts = computed(() => { + const enterpriseSeries = { + label: t('enterprise-count'), + color: '#2563eb', + data: trendPoints(point => point.enterprise_count), + } + return [ + { + id: 'enterprise-sso', + titleKey: 'enterprise-sso-chart-title', + descriptionKey: 'enterprise-sso-chart-description', + valueSuffix: '', + series: [enterpriseSeries, { + label: t('enterprise-sso-count'), + color: '#10b981', + data: trendPoints(point => point.sso_count), + }], + }, + { + id: 'enterprise-channel', + titleKey: 'enterprise-channel-chart-title', + descriptionKey: 'enterprise-channel-chart-description', + valueSuffix: '', + series: [enterpriseSeries, { + label: t('enterprise-channel-count'), + color: '#8b5cf6', + data: trendPoints(point => point.channel_count), + }], + }, + { + id: 'enterprise-adoption', + titleKey: 'enterprise-adoption-chart-title', + descriptionKey: 'enterprise-adoption-chart-description', + valueSuffix: '%', + series: [{ + label: t('enterprise-sso-adoption'), + color: '#10b981', + data: trendPoints(point => adoptionPercent(point.sso_count, point.enterprise_count)), + }, { + label: t('enterprise-channel-adoption'), + color: '#8b5cf6', + data: trendPoints(point => adoptionPercent(point.channel_count, point.enterprise_count)), + }], + }, + ] +}) + +const hasAdoptionData = computed(() => adoptionTrend.value.some(point => point.enterprise_count > 0 || point.sso_count > 0 || point.channel_count > 0)) + +async function loadEnterpriseAdoption(forceRefresh = false) { + isLoadingAdoption.value = true + try { + const payload = await adminStore.fetchStats('enterprise_adoption', forceRefresh) as EnterpriseAdoptionResponse + adoptionTrend.value = Array.isArray(payload?.trend) ? payload.trend : [] + } + catch (error) { + console.error('[Admin Dashboard Organizations] Error loading enterprise adoption:', error) + adoptionTrend.value = [] + } + finally { + isLoadingAdoption.value = false + } +} + +function openChannelEditor(item: OrganizationInsight) { + channelEditorOrg.value = item + channelEditorType.value = item.support_channel_type ?? '' + channelEditorUrl.value = item.support_channel_url ?? '' + channelEditorOpen.value = true +} + +function closeChannelEditor() { + channelEditorOpen.value = false + channelEditorOrg.value = null + channelEditorType.value = '' + channelEditorUrl.value = '' +} + +async function saveSupportChannel(clear = false) { + const org = channelEditorOrg.value + if (!org) + return + + const supportChannelType = clear ? null : (channelEditorType.value || null) + const supportChannelUrl = clear ? null : (channelEditorUrl.value.trim() || null) + if (!clear && (!supportChannelType || !supportChannelUrl)) { + toast.error(t('sso-fill-all-fields')) + return + } + + isSavingChannel.value = true + try { + const supabase = useSupabase() + const { data: { session } } = await supabase.auth.getSession() + if (!session) + throw new Error('Not authenticated') + + const response = await fetch(`${defaultApiHost}/private/admin_org_support_channel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ + org_id: org.org_id, + support_channel_type: supportChannelType, + support_channel_url: supportChannelUrl, + }), + }) + + if (!response.ok) { + const errorData: unknown = await response.json().catch(() => ({})) + throw new Error(`API error: ${response.status} - ${JSON.stringify(errorData)}`) + } + + const index = organizations.value.findIndex(row => row.org_id === org.org_id) + if (index !== -1) { + organizations.value[index] = { + ...organizations.value[index], + support_channel_type: supportChannelType, + support_channel_url: supportChannelUrl, + } + } + + toast.success(t('support-channel-saved')) + closeChannelEditor() + await loadEnterpriseAdoption(true) + } + catch (error) { + console.error('[Admin Dashboard Organizations] Error saving support channel:', error) + toast.error(t('sso-error-updating')) + } + finally { + isSavingChannel.value = false + } +} + function getOrganizationAttentionLabel(item: OrganizationInsight) { const failRate = Number(item.fail_rate || 0) const failedUpdateCount = Number(item.failed_update_count || 0) @@ -306,6 +488,33 @@ const organizationColumns = computed(() => [ sortable: false, displayFunction: (item: OrganizationInsight) => formatDistributionStage(item.distribution_stage), }, + { + label: t('sso-configured'), + key: 'has_sso', + mobile: true, + sortable: false, + displayFunction: (item: OrganizationInsight) => item.has_sso ? t('yes') : t('no'), + }, + { + label: t('support-channel'), + key: 'support_channel_type', + mobile: true, + sortable: false, + renderFunction: (item: OrganizationInsight) => { + return h('div', { class: 'flex min-w-0 items-center gap-2' }, [ + h('span', { class: 'truncate text-slate-700 dark:text-slate-200' }, formatSupportChannelType(item.support_channel_type)), + h('button', { + type: 'button', + class: 'd-btn d-btn-ghost d-btn-xs', + onClick: (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + openChannelEditor(item) + }, + }, item.support_channel_url ? t('update') : t('support-channel-set')), + ]) + }, + }, { label: t('last-build'), key: 'last_build_at', @@ -339,10 +548,12 @@ const organizationColumns = computed(() => [ watch(() => adminStore.activeDateRange, () => { resetToFirstPageAndLoadImmediately() + loadEnterpriseAdoption() }, { deep: true }) watch(() => adminStore.refreshTrigger, () => { loadOrganizationsImmediately() + loadEnterpriseAdoption() }) watch([selectedPlan, selectedBilling, paidOnly], () => { @@ -360,7 +571,7 @@ onMounted(async () => { return } - await loadOrganizations() + await Promise.all([loadOrganizations(), loadEnterpriseAdoption()]) displayStore.NavTitle = t('admin-organizations') }) @@ -376,6 +587,32 @@ displayStore.defaultBack = '/dashboard'
+
+ + + + +
+

@@ -451,5 +688,89 @@ displayStore.defaultBack = '/dashboard'

+ +
+
+

+ {{ t('support-channel-title') }} +

+

+ {{ channelEditorOrg.org_name }} +

+
+ + + + +
+
+ + + +
+
+
diff --git a/src/pages/login.vue b/src/pages/login.vue index 9aaa3a070a..b12f1cd234 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -41,9 +41,10 @@ const captchaComponent = ref | null>(null) // Keep email, password, and OTP in one form so password managers can fill them // with a single biometric prompt. Password stays hidden until the domain is -// known not to use SSO. SSO domains never show a password field. +// known not to enforce SSO. Optional SSO still shows password login. const emailForLogin = ref('') const hasSso = ref(false) +const enforceSso = ref(false) const lastCheckedEmail = ref('') const passwordPathReady = ref(false) const domainCheckTimeoutMs = 5000 @@ -390,14 +391,16 @@ async function refreshSsoForEmail(email: string) { if (!isCurrentDomainCheck(seq, trimmed)) return hasSso.value = result.has_sso + enforceSso.value = result.has_sso && result.enforce_sso === true lastCheckedEmail.value = trimmed - passwordPathReady.value = !result.has_sso + passwordPathReady.value = !enforceSso.value } catch (error) { if (!isCurrentDomainCheck(seq, trimmed)) return console.error('SSO domain check failed', error) hasSso.value = false + enforceSso.value = false lastCheckedEmail.value = '' passwordPathReady.value = true } @@ -427,18 +430,20 @@ watch(emailForLogin, (email) => { if (!isCompletableEmail(trimmed)) { hasSso.value = false + enforceSso.value = false passwordPathReady.value = false lastCheckedEmail.value = '' return } if (lastCheckedEmail.value === trimmed) { - passwordPathReady.value = !hasSso.value + passwordPathReady.value = !enforceSso.value return } if (domain !== lastDomain) { hasSso.value = false + enforceSso.value = false passwordPathReady.value = false lastCheckedEmail.value = '' } @@ -465,7 +470,7 @@ async function handleLoginSubmit(form: { email: string, password: string, code?: console.error('SSO domain check failed', error) } - if (hasSso.value) { + if (enforceSso.value) { await handleSsoLogin() return } @@ -932,7 +937,7 @@ onMounted(checkLogin) />

- {{ t('sso-detected') }} + {{ enforceSso ? t('sso-required') : t('sso-detected') }}

@@ -960,7 +965,7 @@ onMounted(checkLogin)
{ await organizationStore.dedupFetchOrganizations() + await loadSupportChannel(currentOrganization.value?.gid) isLoading.value = false }) -const { currentOrganization } = storeToRefs(organizationStore) const orgName = ref(currentOrganization.value?.name ?? '') const email = ref(currentOrganization.value?.management_email ?? '') +const supportChannelType = ref<'slack' | 'discord' | 'teams' | null>(null) +const supportChannelUrl = ref(null) + +async function loadSupportChannel(orgId: string | undefined) { + supportChannelType.value = null + supportChannelUrl.value = null + if (!orgId) + return + + const { data, error } = await supabase + .from('orgs') + .select('support_channel_type, support_channel_url') + .eq('id', orgId) + .maybeSingle() + + if (error) { + console.error('Failed to load organization support channel', error) + return + } + + const type = data?.support_channel_type + supportChannelType.value = type === 'slack' || type === 'discord' || type === 'teams' ? type : null + supportChannelUrl.value = data?.support_channel_url ?? null +} watch(currentOrganization, (newOrg) => { if (newOrg) { orgName.value = newOrg.name email.value = newOrg.management_email + void loadSupportChannel(newOrg.gid) } }) @@ -188,6 +214,16 @@ function canDeleteOrg() { && organizationStore.organizations.length > 1 } +function supportChannelLabel(type: 'slack' | 'discord' | 'teams' | null) { + if (type === 'slack') + return t('support-channel-slack') + if (type === 'discord') + return t('support-channel-discord') + if (type === 'teams') + return t('support-channel-teams') + return t('support-channel-none') +} + async function deleteOrganization() { dialogRef.value?.open() } @@ -295,6 +331,26 @@ async function copyOrganizationId() {
+
+

+ {{ t('support-channel-title') }} +

+

+ {{ t('support-channel-description') }} +

+ + {{ t('support-channel-open') }} ({{ supportChannelLabel(supportChannelType) }}) + +
+