From 49618e488634ab7e0d24f47d7c712cac941f0e5b Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 28 Aug 2026 16:37:40 +0300 Subject: [PATCH 01/11] feat(admin): enterprise channels and honor SSO enforce Optional SSO hid password login and locked teams out. Admin-set Slack/Discord/Teams links plus adoption charts stay off the member write path. Co-authored-by: Cursor --- cloudflare_workers/api/index.ts | 2 + messages/en.context.json | 27 ++ messages/en.json | 27 ++ playwright/e2e/sso-login.spec.ts | 18 +- src/pages/admin/dashboard/organizations.vue | 338 +++++++++++++++++- src/pages/login.vue | 17 +- src/pages/settings/organization/index.vue | 58 ++- src/stores/adminDashboard.ts | 2 +- src/types/supabase.types.ts | 9 + .../private/admin_org_support_channel.ts | 128 +++++++ .../functions/_backend/private/admin_stats.ts | 7 +- supabase/functions/_backend/utils/pg.ts | 128 ++++++- .../_backend/utils/supabase.types.ts | 9 + supabase/functions/private/index.ts | 2 + .../20260828122104_org_support_channel.sql | 108 ++++++ tests/admin-org-support-channel.test.ts | 198 ++++++++++ tests/admin-stats.unit.test.ts | 9 + .../organization-put-stripe-sync.unit.test.ts | 3 + 18 files changed, 1077 insertions(+), 13 deletions(-) create mode 100644 supabase/functions/_backend/private/admin_org_support_channel.ts create mode 100644 supabase/migrations/20260828122104_org_support_channel.sql create mode 100644 tests/admin-org-support-channel.test.ts 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 1a5c5879bb..94e81b0719 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.", @@ -2816,6 +2827,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.", @@ -2827,6 +2839,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.", @@ -2949,6 +2962,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 4741b00952..ff7d8c6d39 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", @@ -2561,15 +2572,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/src/pages/admin/dashboard/organizations.vue b/src/pages/admin/dashboard/organizations.vue index 9db1c516bd..29be7da2bd 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,148 @@ 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 +} + +const ssoChartSeries = computed(() => [{ + label: t('enterprise-count'), + color: '#2563eb', + data: adoptionTrend.value.map(point => ({ date: point.date, value: point.enterprise_count })), +}, { + label: t('enterprise-sso-count'), + color: '#10b981', + data: adoptionTrend.value.map(point => ({ date: point.date, value: point.sso_count })), +}]) + +const channelChartSeries = computed(() => [{ + label: t('enterprise-count'), + color: '#2563eb', + data: adoptionTrend.value.map(point => ({ date: point.date, value: point.enterprise_count })), +}, { + label: t('enterprise-channel-count'), + color: '#8b5cf6', + data: adoptionTrend.value.map(point => ({ date: point.date, value: point.channel_count })), +}]) + +const adoptionChartSeries = computed(() => [{ + label: t('enterprise-sso-adoption'), + color: '#10b981', + data: adoptionTrend.value.map(point => ({ + date: point.date, + value: adoptionPercent(point.sso_count, point.enterprise_count), + })), +}, { + label: t('enterprise-channel-adoption'), + color: '#8b5cf6', + data: adoptionTrend.value.map(point => ({ + date: point.date, + value: 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 +473,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 +533,12 @@ const organizationColumns = computed(() => [ watch(() => adminStore.activeDateRange, () => { resetToFirstPageAndLoadImmediately() + loadEnterpriseAdoption() }, { deep: true }) watch(() => adminStore.refreshTrigger, () => { loadOrganizationsImmediately() + loadEnterpriseAdoption() }) watch([selectedPlan, selectedBilling, paidOnly], () => { @@ -360,7 +556,7 @@ onMounted(async () => { return } - await loadOrganizations() + await Promise.all([loadOrganizations(), loadEnterpriseAdoption()]) displayStore.NavTitle = t('admin-organizations') }) @@ -376,6 +572,62 @@ displayStore.defaultBack = '/dashboard'
+
+ + + + + + + + + + + + + + +
+

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

+ +
+
+

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

+

+ {{ channelEditorOrg.org_name }} +

+
+ + + + +
+
+ + + +
+
+
diff --git a/src/pages/login.vue b/src/pages/login.vue index 35b845d246..cebd784e72 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -38,9 +38,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 @@ -386,14 +387,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 } @@ -423,18 +426,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 = '' } @@ -461,7 +466,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 } @@ -928,7 +933,7 @@ onMounted(checkLogin) />

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

@@ -956,7 +961,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) }}) + +
+