import type { ComputedRef, Ref } from 'vue' import type { AuthChangeEvent } from '~/services/consoleClient' import type { Database } from '~/types/database.types' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' import { stripeEnabled, useConsole } from '~/services/console' import { addUtcDays, normalizeToUtcStartOfDay } from '~/services/date' import { clearSimulatorSessions } from '~/services/simulatorSessions' import { createSignedImageUrl, getImmediateImageUrl, resolveImagePath } from '~/services/storage' import { clearWebsitePaidUserCookie, syncWebsitePaidUserCookieFromOrganizations } from '~/services/websiteAuthCookie' import { createDeferredPromise } from '../utils/promise' import { useDashboardAppsStore } from './dashboardApps' import { useDisplayStore } from './display' import { useMainStore } from './main' // Password policy configuration interface export interface PasswordPolicyConfig { enabled: boolean min_length: number require_uppercase: boolean require_number: boolean require_special: boolean } // Extended organization type with password policy and 2FA fields (from get_orgs_v7) // Note: Using get_orgs_v7 return type with explicit JSON parsing for password_policy_config type RawOrganization = Database['public']['Functions']['get_orgs_v7']['Returns'][number] export type Organization = Omit & { logo_storage_path?: string | null logo_is_loading?: boolean password_policy_config: PasswordPolicyConfig | null stats_refresh_requested_at: string | null stats_updated_at: string | null } export interface OrganizationApp { app_id: string name: string | null owner_org: string need_onboarding: boolean icon_url: string | null icon_storage_path?: string | null icon_url_loading?: boolean onboarding?: Database['public']['Tables']['apps']['Row']['onboarding'] } export type OrganizationRole = 'owner' | 'org_member' | 'org_billing_admin' | 'org_admin' | 'org_super_admin' type OrgMemberRow = Database['public']['Functions']['get_org_members']['Returns'][number] export type ExtendedOrganizationMember = OrgMemberRow & { id: number | string } export type ExtendedOrganizationMembers = ExtendedOrganizationMember[] type SignedMemberImageCallback = (signedImages: Map) => void type OrgPermissionFloor = 'org_member' | 'org_billing_admin' | 'org_admin' | 'org_super_admin' | 'owner' interface OrganizationFetchOptions { loadImages?: boolean } interface AppOnboardingRefreshOptions { appIds: readonly string[] } // Mapping des rôles RBAC d'organisation vers leurs clés de traduction i18n export const RBAC_ORG_ROLE_I18N_KEYS: Record = { org_super_admin: 'role-org-super-admin', org_admin: 'role-org-admin', org_billing_admin: 'role-org-billing-admin', org_member: 'role-org-member', app_admin: 'role-app-admin', app_preview: 'role-app-preview', app_developer: 'role-app-developer', app_notifications: 'role-app-notifications', app_uploader: 'role-app-uploader', app_reader: 'role-app-reader', channel_admin: 'role-channel-admin', channel_developer: 'role-channel-developer', channel_preview: 'role-channel-preview', channel_uploader: 'role-channel-uploader', channel_reader: 'role-channel-reader', } /** * Obtient la clé i18n pour un rôle RBAC d'organisation * @param role Le nom technique du rôle * @returns La clé de traduction i18n, ou undefined si non mappé */ export function getRbacRoleI18nKey(role: string): string | undefined { return RBAC_ORG_ROLE_I18N_KEYS[role] } const ORG_ROLE_RANK: Record = { org_member: 1, org_billing_admin: 2, org_admin: 3, org_super_admin: 4, owner: 4, } export function roleHasOrgRank(role: string | null | undefined, minRole: 'org_member' | 'org_billing_admin' | 'org_admin' | 'org_super_admin') { if (!role) return false return (ORG_ROLE_RANK[role] ?? 0) >= ORG_ROLE_RANK[minRole] } export function isAdminRole(role: string | null | undefined) { return roleHasOrgRank(role, 'org_admin') } export function isSuperAdminRole(role: string | null | undefined) { return roleHasOrgRank(role, 'org_super_admin') } function normalizeRbacRole(role: string, scope: 'org' | 'app') { if (role === 'owner') return scope === 'org' ? 'org_super_admin' : 'app_admin' if (scope === 'app' && (role === 'org_admin' || role === 'org_super_admin')) return 'app_admin' return role } function matchesRbacRole(role: string, requiredRole: string) { if (role === requiredRole) return true if (requiredRole.startsWith('org_')) return normalizeRbacRole(role, 'org') === requiredRole if (requiredRole.startsWith('app_')) return normalizeRbacRole(role, 'app') === requiredRole return role === requiredRole } export function isPendingOrganizationInvite(org: Pick) { if (org.is_invite !== null && org.is_invite !== undefined) return org.is_invite === true return org.role.startsWith('invite') } function isSelectableOrganization(org: Pick) { return !isPendingOrganizationInvite(org) } const capgo = useConsole() export const useOrganizationStore = defineStore('organization', () => { const main = useMainStore() const _organizations: Ref> = ref(new Map()) const _organizationsByAppId: Ref> = ref(new Map()) const _appsByOrgId: Ref> = ref(new Map()) const _appsByAppId: Ref> = ref(new Map()) const _initialLoadPromise = ref(createDeferredPromise()) const _initialized = ref(false) const organizations: ComputedRef = computed( () => { return Array.from( _organizations.value, ([_key, value]) => value, ) }, ) const hasOrganizations = computed(() => organizations.value.some(org => isSelectableOrganization(org))) const getCurrentRole = async (appOwner: string, appId?: string, channelId?: number): Promise => { if (_organizations.value.size === 0) { // eslint-disable-next-line ts/no-use-before-define await fetchOrganizations() } for (const org of _organizations.value.values()) { if (org.created_by === appOwner) return org.role as OrganizationRole } throw new Error(`Cannot find role for (${appOwner}, ${appId}, ${channelId}))`) } // WARNING: currentOrganization does not guarantee correctness when used in an app-based URL // For example if you try to use this value when fetching app channels it COULD BE incorrect // When trying to fetch an organization in an app based component the following should be used // // const organization = ref(null as null | Organization) // watchEffect(async () => { // await organizationStore.awaitInitialLoad() // organization.value = organizationStore.getOrgByAppId(appId.value) ?? null // } // const currentOrganization = ref(undefined) const currentOrganizationFailed = ref(false) const currentRole = ref(null) const STORAGE_KEY = 'capgo_current_org_id' let organizationLogoLoadRun = 0 let organizationAppIconLoadRun = 0 const getMemberImageKey = (member: { uid?: string | null, id?: number | string | null, email?: string | null }) => { return String(member.uid ?? member.id ?? member.email ?? '') } const loadSignedMemberImages = async ( sources: Array<{ key: string, imageUrl?: string | null }>, onSignedImages: SignedMemberImageCallback, ) => { const signedImageEntries = await Promise.all(sources.map(async (source) => { if (!source.key || !source.imageUrl || getImmediateImageUrl(source.imageUrl)) return null try { const signedImageUrl = await createSignedImageUrl(source.imageUrl) return signedImageUrl ? [source.key, signedImageUrl] as const : null } catch (error) { console.warn('Cannot load signed member image', { memberKey: source.key, error }) return null } })) const signedImages = new Map() for (const entry of signedImageEntries) { if (entry) signedImages.set(entry[0], entry[1]) } if (signedImages.size > 0) onSignedImages(signedImages) } const updateOrganizationLogoState = (orgId: string, patch: Partial, run: number) => { if (run !== organizationLogoLoadRun) return const organization = _organizations.value.get(orgId) if (!organization) return const hasChanges = Object.entries(patch).some(([key, value]) => organization[key as keyof Organization] !== value) if (!hasChanges) return const nextOrganization = { ...organization, ...patch, } const nextOrganizations = new Map(_organizations.value) nextOrganizations.set(orgId, nextOrganization) _organizations.value = nextOrganizations if (currentOrganization.value?.gid === orgId) currentOrganization.value = nextOrganization } const loadOrganizationLogo = async (org: Organization, run: number) => { const logoSource = org.logo_storage_path ?? org.logo const { normalized, shouldSign } = resolveImagePath(logoSource) if (!normalized || !shouldSign) { updateOrganizationLogoState(org.gid, { logo_is_loading: false }, run) return } try { const signedLogo = await createSignedImageUrl(normalized) updateOrganizationLogoState(org.gid, { logo: signedLogo || org.logo, logo_storage_path: normalized, logo_is_loading: false, }, run) } catch (error) { console.warn('Cannot load signed organization logo', { orgId: org.gid, error }) updateOrganizationLogoState(org.gid, { logo_is_loading: false }, run) } } const loadOrganizationLogos = async (sourceOrganizations: Organization[], run: number): Promise => { await Promise.all(sourceOrganizations.map(org => loadOrganizationLogo(org, run).catch((error) => { console.warn('Cannot load signed organization logo', { orgId: org.gid, error }) updateOrganizationLogoState(org.gid, { logo_is_loading: false }, run) }))) } const appWithImmediateIcon = (app: { app_id: string name: string | null owner_org: string need_onboarding: boolean icon_url: string | null onboarding?: Exclude }): OrganizationApp => { const { normalized, shouldSign } = resolveImagePath(app.icon_url) return { app_id: app.app_id, name: app.name, owner_org: app.owner_org, need_onboarding: app.need_onboarding, icon_url: shouldSign ? '' : normalized || null, icon_storage_path: normalized || null, icon_url_loading: shouldSign, onboarding: app.onboarding ?? {}, } } const updateOrganizationAppIconState = (appId: string, patch: Partial, run: number) => { if (run !== organizationAppIconLoadRun) return const app = _appsByAppId.value.get(appId) if (!app) return Object.assign(app, patch) } const loadOrganizationAppIcon = async (app: OrganizationApp, run: number) => { if (!app.icon_url_loading || !app.icon_storage_path) return try { const signedIcon = await createSignedImageUrl(app.icon_storage_path) updateOrganizationAppIconState(app.app_id, { icon_url: signedIcon || '', icon_url_loading: false, }, run) } catch (error) { console.warn('Cannot load signed app icon', { appId: app.app_id, error }) updateOrganizationAppIconState(app.app_id, { icon_url_loading: false }, run) } } const loadOrganizationAppIcons = async (sourceApps: OrganizationApp[], run: number): Promise => { await Promise.all(sourceApps.map(app => loadOrganizationAppIcon(app, run).catch((error) => { console.warn('Cannot load signed app icon', { appId: app.app_id, error }) updateOrganizationAppIconState(app.app_id, { icon_url_loading: false }, run) }))) } let pendingOrganizationImageLoad: Promise | undefined let pendingOrganizationImageLoadRequested = false const loadPendingOrganizationImages = () => { if (pendingOrganizationImageLoad) { pendingOrganizationImageLoadRequested = true return } const imageLoads: Promise[] = [] const organizationsWithPendingLogos = Array.from(_organizations.value.values()) .filter(organization => organization.logo_is_loading) if (organizationsWithPendingLogos.length > 0) imageLoads.push(loadOrganizationLogos(organizationsWithPendingLogos, ++organizationLogoLoadRun)) const appsWithPendingIcons = Array.from(_appsByAppId.value.values()) .filter(app => app.icon_url_loading) if (appsWithPendingIcons.length > 0) imageLoads.push(loadOrganizationAppIcons(appsWithPendingIcons, ++organizationAppIconLoadRun)) if (imageLoads.length === 0) return pendingOrganizationImageLoad = Promise.all(imageLoads) .then(() => undefined) .finally(() => { pendingOrganizationImageLoad = undefined if (pendingOrganizationImageLoadRequested) { pendingOrganizationImageLoadRequested = false loadPendingOrganizationImages() } }) } watch([currentOrganization, stripeEnabled], async ([currentOrganizationRaw, stripeEnabledValue], [previousOrganization]) => { if (!currentOrganizationRaw) { currentRole.value = null useDashboardAppsStore().reset() localStorage.removeItem(STORAGE_KEY) return } localStorage.setItem(STORAGE_KEY, currentOrganizationRaw.gid) currentRole.value = await getCurrentRole(currentOrganizationRaw.created_by) // Don't mark as failed if user lacks 2FA or password access - the data is redacted and unreliable const lacks2FAAccess = currentOrganizationRaw.enforcing_2fa === true && currentOrganizationRaw['2fa_has_access'] === false const lacksPasswordAccess = currentOrganizationRaw.password_policy_config?.enabled && currentOrganizationRaw.password_has_access === false if (lacks2FAAccess || lacksPasswordAccess || !stripeEnabledValue) { currentOrganizationFailed.value = false } else { currentOrganizationFailed.value = !(!!currentOrganizationRaw.paying || (currentOrganizationRaw.trial_left ?? 0) > 0 || !!currentOrganizationRaw.can_use_more) } // Clear caches when org changes to prevent showing stale data from other orgs if (previousOrganization?.gid !== currentOrganizationRaw.gid) { const displayStore = useDisplayStore() displayStore.clearCachesForOrg(currentOrganizationRaw.gid) const dashboardAppsStore = useDashboardAppsStore() dashboardAppsStore.fetchApps() } // Always fetch last 30 UTC days of data and filter client-side for billing period // End date should be next UTC midnight to include all of today's UTC data const todayUtc = normalizeToUtcStartOfDay() const last30DaysEnd = addUtcDays(todayUtc, 1) const last30DaysStart = addUtcDays(todayUtc, -29) try { await main.updateDashboard(currentOrganizationRaw.gid, last30DaysStart.toISOString(), last30DaysEnd.toISOString()) } catch (error) { // Silently catch dashboard errors - they're logged elsewhere and shouldn't block UI console.error('Failed to update dashboard:', error) } }) const loadOrganizationApps = async (selectableOrganizations: Organization[], loadImages: boolean) => { const orgIds = selectableOrganizations.map(org => org.gid) if (orgIds.length === 0) { _appsByOrgId.value = new Map() _appsByAppId.value = new Map() _initialLoadPromise.value.resolve(true) return } const appIconLoadRun = ++organizationAppIconLoadRun const { error, data: allAppsByOwner } = await capgo .from('apps') .select('app_id, name, owner_org, need_onboarding, icon_url, onboarding') .in('owner_org', orgIds) if (error) { console.error('Cannot get app apps for org store', error) return } if (appIconLoadRun !== organizationAppIconLoadRun) return const organizationsByAppId = new Map() const appsByOrgId = new Map() const appsByAppId = new Map() for (const app of allAppsByOwner) { // For each app find the org_id that owns said app // This is needed for the "banner" const org = selectableOrganizations.find(org => org.gid === app.owner_org) if (!org) { console.error(`Cannot find organization for app`, app) _initialLoadPromise.value.reject(`Cannot find organization for app ${app}`) return } organizationsByAppId.set(app.app_id, org) const indexedApp = appWithImmediateIcon(app) appsByAppId.set(indexedApp.app_id, indexedApp) const orgApps = appsByOrgId.get(indexedApp.owner_org) ?? [] orgApps.push(indexedApp) appsByOrgId.set(indexedApp.owner_org, orgApps) } for (const apps of appsByOrgId.values()) { apps.sort((a, b) => (a.name || a.app_id).localeCompare(b.name || b.app_id)) } _organizationsByAppId.value = organizationsByAppId _appsByOrgId.value = appsByOrgId _appsByAppId.value = appsByAppId if (loadImages) void loadOrganizationAppIcons(Array.from(appsByAppId.values()), appIconLoadRun) _initialLoadPromise.value.resolve(true) } const getOrgByAppId = (appId: string) => { return _organizationsByAppId.value.get(appId) } const getAppByAppId = (appId: string) => { return _appsByAppId.value.get(appId) } const getAppsByOrgId = (orgId: string) => { return _appsByOrgId.value.get(orgId) ?? [] } const awaitInitialLoad = () => { return _initialLoadPromise.value.promise } // Index an app created in this session so app-scoped UI (Getting started // nav, app lookups) sees it without refetching every organization. const upsertOrganizationApp = (app: Parameters[0]) => { const org = organizations.value.find(organization => organization.gid === app.owner_org) if (!org) return const indexedApp = appWithImmediateIcon(app) const nextAppsByAppId = new Map(_appsByAppId.value) nextAppsByAppId.set(indexedApp.app_id, indexedApp) const nextOrganizationsByAppId = new Map(_organizationsByAppId.value) nextOrganizationsByAppId.set(indexedApp.app_id, org) const nextAppsByOrgId = new Map(_appsByOrgId.value) const orgApps = (nextAppsByOrgId.get(indexedApp.owner_org) ?? []).filter(orgApp => orgApp.app_id !== indexedApp.app_id) orgApps.push(indexedApp) orgApps.sort((a, b) => (a.name || a.app_id).localeCompare(b.name || b.app_id)) nextAppsByOrgId.set(indexedApp.owner_org, orgApps) _appsByAppId.value = nextAppsByAppId _organizationsByAppId.value = nextOrganizationsByAppId _appsByOrgId.value = nextAppsByOrgId } const appOnboardingWriteGen = new Map() const writeAppFields = (appId: string, patch: Partial) => { const existing = _appsByAppId.value.get(appId) if (!existing) return const next = { ...existing, ...patch } const nextByAppId = new Map(_appsByAppId.value) nextByAppId.set(appId, next) _appsByAppId.value = nextByAppId const orgApps = _appsByOrgId.value.get(existing.owner_org) if (!orgApps) return const nextByOrgId = new Map(_appsByOrgId.value) nextByOrgId.set(existing.owner_org, orgApps.map(app => app.app_id === appId ? next : app)) _appsByOrgId.value = nextByOrgId } const writeAppOnboarding = (appId: string, onboarding: OrganizationApp['onboarding']) => { writeAppFields(appId, { onboarding }) } const updateAppOnboarding = (appId: string, onboarding: OrganizationApp['onboarding'] | null | undefined) => { if (!_appsByAppId.value.get(appId) || onboarding == null) return appOnboardingWriteGen.set(appId, (appOnboardingWriteGen.get(appId) ?? 0) + 1) writeAppOnboarding(appId, onboarding) } const updateAppNeedOnboarding = (appId: string, needOnboarding: boolean) => { if (!_appsByAppId.value.get(appId)) return appOnboardingWriteGen.set(appId, (appOnboardingWriteGen.get(appId) ?? 0) + 1) writeAppFields(appId, { need_onboarding: needOnboarding }) } let appsOnboardingRefreshGen = 0 const appOnboardingRefreshGen = new Map() const publishRefreshedOnboarding = (refreshedApps: Map) => { if (!refreshedApps.size) return // Publish one catalog update per response, rather than copying the maps // and traversing an organization's app list once for every returned row. const nextByAppId = new Map(_appsByAppId.value) const affectedOrgs = new Set() for (const [appId, app] of refreshedApps) { nextByAppId.set(appId, app) affectedOrgs.add(app.owner_org) } const nextByOrgId = new Map(_appsByOrgId.value) for (const id of affectedOrgs) nextByOrgId.set(id, (_appsByOrgId.value.get(id) ?? []).map(app => refreshedApps.get(app.app_id) ?? app)) _appsByAppId.value = nextByAppId _appsByOrgId.value = nextByOrgId } const refreshAppsOnboarding = async (orgId?: string, options?: AppOnboardingRefreshOptions) => { const orgIds = orgId ? [orgId] : Array.from(_appsByOrgId.value.keys()) if (orgIds.length === 0) return const appIds = options ? [...new Set(options.appIds)].filter(appId => orgIds.includes(_appsByAppId.value.get(appId)?.owner_org ?? '')) : orgIds.flatMap(id => (_appsByOrgId.value.get(id) ?? []).map(app => app.app_id)) if (!appIds.length) return const refreshGen = ++appsOnboardingRefreshGen const writeGenSnapshot = new Map(appIds.map(appId => [appId, appOnboardingWriteGen.get(appId) ?? 0])) for (const appId of appIds) appOnboardingRefreshGen.set(appId, refreshGen) const query = capgo .from('apps') .select('app_id, onboarding, need_onboarding') .in('owner_org', orgIds) const { data, error } = await (options ? query.in('app_id', appIds) : query) if (error) { console.error('Cannot refresh app onboarding', error) return } const refreshedApps = new Map() for (const row of data ?? []) { if (appOnboardingRefreshGen.get(row.app_id) !== refreshGen) continue if ((appOnboardingWriteGen.get(row.app_id) ?? 0) !== (writeGenSnapshot.get(row.app_id) ?? 0)) continue const existing = _appsByAppId.value.get(row.app_id) if (!existing || (existing.need_onboarding === row.need_onboarding && JSON.stringify(existing.onboarding) === JSON.stringify(row.onboarding))) continue refreshedApps.set(row.app_id, { ...existing, onboarding: row.onboarding, need_onboarding: row.need_onboarding, }) } publishRefreshedOnboarding(refreshedApps) } const getCurrentRoleForApp = (appId: string) => { if (_organizationsByAppId.value.size < 1) throw new Error('Organizations by app_id map is empty') const org = getOrgByAppId(appId) if (!org) throw new Error(`Cannot find app ${appId} in the app_id -> org map`) return org.role as OrganizationRole } const setCurrentOrganization = (id: string) => { const organization = organizations.value.find(org => org.gid === id) if (!organization) { // The id isn't in the user's org list (e.g. an `?oid=` pointing at an org // they aren't a member of). Blindly assigning the `find()` result would // silently clear currentOrganization to undefined, which downstream pages // (notably the plans page) then treat as a hard error. Log loudly and keep // the current organization untouched instead of clobbering it. console.error(`setCurrentOrganization: no organization with id "${id}" in the user's org list; keeping the current organization`) return } currentOrganization.value = organization } const setCurrentOrganizationToMain = () => { const organization = organizations.value.find(org => org.role === 'owner') if (!organization) throw new Error('User has no main organization') setCurrentOrganization(organization.gid) } const setCurrentOrganizationToFirst = () => { if (organizations.value.length === 0) return const organization = organizations.value[0] setCurrentOrganization(organization.gid) } const getMembers = async (onSignedImages?: SignedMemberImageCallback): Promise => { const currentOrgId = currentOrganization.value?.gid if (!currentOrgId) return [] const { data, error } = await capgo .rpc('get_org_members', { guild_id: currentOrgId, }) if (error || data === null) { return [] } const memberImageSources = data.map(item => ({ key: getMemberImageKey(item), imageUrl: item.image_url, })) const mappedMembers = data.map((item, id) => ({ id, ...item, image_url: getImmediateImageUrl(item.image_url) || '', })) if (onSignedImages) { loadSignedMemberImages(memberImageSources, onSignedImages).catch((error) => { console.warn('Cannot load signed member images', error) }) } return mappedMembers } const fetchOrganizations = async (options: OrganizationFetchOptions = {}) => { const main = useMainStore() const loadImages = options.loadImages ?? true const userId = main.user?.id ?? main.auth?.id if (!userId) return main.invalidatePlatformAdminStatus() // Independent of the organization list: request it alongside get_orgs_v7, not after it. main.resolvePlatformAdminStatus() .catch((error) => { console.error('Failed to resolve platform admin status:', error) }) if (!_initialized.value) { const listener = capgo.auth.onAuthStateChange((event: AuthChangeEvent) => { if (event === 'SIGNED_OUT') { listener.data.subscription.unsubscribe() clearSimulatorSessions() clearWebsitePaidUserCookie() // Remove all from orgs _organizations.value = new Map() _organizationsByAppId.value = new Map() _appsByOrgId.value = new Map() _appsByAppId.value = new Map() _initialLoadPromise.value = createDeferredPromise() currentOrganization.value = undefined currentRole.value = null } }) _initialized.value = true } // We have RLS that ensure that we only select rows where we are member or owner // Using get_orgs_v7 which includes 2FA and password policy fields const { data, error } = await capgo .rpc('get_orgs_v7') if (error) { console.error('Cannot get orgs!', error) clearWebsitePaidUserCookie() throw error } const logoLoadRun = ++organizationLogoLoadRun const mappedData = data.map((item, id) => { const { normalized: logoStoragePath, shouldSign: shouldSignLogo } = resolveImagePath(item.logo) return { id, ...item, logo: getImmediateImageUrl(item.logo) || null, logo_storage_path: logoStoragePath || null, logo_is_loading: shouldSignLogo, password_policy_config: item.password_policy_config as PasswordPolicyConfig | null, } as Organization & { id: number } }) syncWebsitePaidUserCookieFromOrganizations(mappedData) _organizations.value = new Map(mappedData.map(item => [item.gid, item as Organization])) if (loadImages) void loadOrganizationLogos(mappedData, logoLoadRun) const selectableOrganizations = mappedData .filter(org => isSelectableOrganization(org)) .sort((a, b) => b.app_count - a.app_count) if (_organizationsByAppId.value.size === 0) void loadOrganizationApps(selectableOrganizations, loadImages) const organization = selectableOrganizations[0] if (!organization) { // Keep invitation-only organizations available so invite deep links // can still open the accept-invite dialog before the user joins. currentOrganization.value = undefined currentRole.value = null currentOrganizationFailed.value = false _organizationsByAppId.value = new Map() _appsByOrgId.value = new Map() _appsByAppId.value = new Map() _initialLoadPromise.value.resolve(true) return } let selectedOrganization = currentOrganization.value ? selectableOrganizations.find(org => org.gid === currentOrganization.value?.gid) : undefined if (!selectedOrganization) { const storedOrgId = localStorage.getItem(STORAGE_KEY) if (storedOrgId) selectedOrganization = selectableOrganizations.find(org => org.gid === storedOrgId) } currentOrganization.value = selectedOrganization ?? organization // Don't mark as failed if user lacks 2FA or password access - the data is redacted and unreliable const lacks2FAAccess = currentOrganization.value?.enforcing_2fa === true && currentOrganization.value?.['2fa_has_access'] === false const lacksPasswordAccess = currentOrganization.value?.password_policy_config?.enabled && currentOrganization.value?.password_has_access === false if (lacks2FAAccess || lacksPasswordAccess) { currentOrganizationFailed.value = false } else { currentOrganizationFailed.value = !(!!currentOrganization.value?.paying || (currentOrganization.value?.trial_left ?? 0) > 0 || !!currentOrganization.value?.can_use_more) } } const refreshOrganizationLogos = async () => { if (_organizations.value.size === 0) return const nextOrganizations = new Map(_organizations.value) let updated = false for (const [orgId, org] of nextOrganizations.entries()) { const logoSource = org.logo_storage_path ?? org.logo if (!logoSource) continue const logoStoragePath = resolveImagePath(logoSource).normalized const refreshedLogo = await createSignedImageUrl(logoStoragePath || org.logo, { forceRefresh: true }) if (!refreshedLogo || refreshedLogo === org.logo) continue nextOrganizations.set(orgId, { ...org, logo: refreshedLogo, logo_storage_path: logoStoragePath || null, logo_is_loading: false, }) updated = true } if (!updated) return _organizations.value = nextOrganizations if (currentOrganization.value) { const refreshedCurrentOrganization = nextOrganizations.get(currentOrganization.value.gid) if (refreshedCurrentOrganization) { currentOrganization.value.logo = refreshedCurrentOrganization.logo currentOrganization.value.logo_storage_path = refreshedCurrentOrganization.logo_storage_path } } } const dedupFetchOrganizations = async (options: OrganizationFetchOptions = {}) => { const loadImages = options.loadImages ?? true if (_organizations.value.size === 0) { await fetchOrganizations(options) } else if (loadImages) { loadPendingOrganizationImages() } } const getAllOrgs = () => { return _organizations.value } const hasPermissionsInRole = ( minRight: OrgPermissionFloor, requiredRoles: string[] = [], orgId?: string, appId?: string, ) => { const orgFromApp = appId ? getOrgByAppId(appId) : undefined const org = orgId ? _organizations.value.get(orgId) : (orgFromApp ?? currentOrganization.value) const role = org?.role ?? currentRole.value if (!role) return false if (requiredRoles.length > 0) { if (requiredRoles.some(required => matchesRbacRole(role, required))) return true } return roleHasOrgRank(role, minRight === 'owner' ? 'org_super_admin' : minRight) } // Check password policy compliance for all org members in org admin previews. const checkPasswordPolicyImpact = async (orgId: string) => { const { data, error } = await capgo.rpc('check_org_members_password_policy', { org_id: orgId, }) if (error) { console.error('Failed to check password policy impact:', error) return null } return { totalUsers: data.length, compliantUsers: data.filter(u => u.password_policy_compliant), nonCompliantUsers: data.filter(u => !u.password_policy_compliant), } } const canDeleteOrganization = (orgId?: string) => { return hasPermissionsInRole('org_super_admin', ['org_super_admin'], orgId) } const deleteOrganization = async (orgId: string) => { // Validate input if (!orgId || typeof orgId !== 'string' || orgId.trim() === '') { return { data: null, error: new Error('Invalid organization ID') } } // Check if current user has permission to delete this organization const currentUserId = main.user?.id if (!currentUserId) { return { data: null, error: new Error('User not authenticated') } } // Verify user has org_super_admin or owner role for this organization const currentOrg = _organizations.value.get(orgId) if (!currentOrg || !canDeleteOrganization(orgId)) { console.error('Permission denied:', { role: currentOrg?.role, required: ['owner', 'org_super_admin'] }) return { data: null, error: new Error('Insufficient permissions') } } const { data, error } = await capgo.from('orgs') .delete() .eq('id', orgId) if (error) { console.error('Organization deletion failed:', error.message) return { data, error } } return { data, error: null } } return { organizations, currentOrganization, currentOrganizationFailed, currentRole, setCurrentOrganization, setCurrentOrganizationToMain, setCurrentOrganizationToFirst, getMembers, getCurrentRoleForApp, getAllOrgs, hasPermissionsInRole, fetchOrganizations, refreshOrganizationLogos, dedupFetchOrganizations, getOrgByAppId, getAppByAppId, getAppsByOrgId, upsertOrganizationApp, awaitInitialLoad, updateAppOnboarding, updateAppNeedOnboarding, refreshAppsOnboarding, deleteOrganization, canDeleteOrganization, checkPasswordPolicyImpact, hasOrganizations, } })