src/stores/organization.ts
Rama main · réplica de lectura del repositorio original.
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<RawOrganization, 'password_policy_config' | 'stats_refresh_requested_at' | 'stats_updated_at'> & {
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<string, string>) => 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<string, string> = {
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<string, number> = {
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<Organization, 'is_invite' | 'role'>) {
if (org.is_invite !== null && org.is_invite !== undefined)
return org.is_invite === true
return org.role.startsWith('invite')
}
function isSelectableOrganization(org: Pick<Organization, 'is_invite' | 'role'>) {
return !isPendingOrganizationInvite(org)
}
const capgo = useConsole()
export const useOrganizationStore = defineStore('organization', () => {
const main = useMainStore()
const _organizations: Ref<Map<string, Organization>> = ref(new Map())
const _organizationsByAppId: Ref<Map<string, Organization>> = ref(new Map())
const _appsByOrgId: Ref<Map<string, OrganizationApp[]>> = ref(new Map())
const _appsByAppId: Ref<Map<string, OrganizationApp>> = ref(new Map())
const _initialLoadPromise = ref(createDeferredPromise<boolean>())
const _initialized = ref(false)
const organizations: ComputedRef<Organization[]> = 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<OrganizationRole> => {
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<Organization | undefined>(undefined)
const currentOrganizationFailed = ref(false)
const currentRole = ref<OrganizationRole | null>(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<string, string>()
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<Organization>, 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<void> => {
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['onboarding'], undefined>
}): 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<OrganizationApp>, 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<void> => {
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<void> | undefined
let pendingOrganizationImageLoadRequested = false
const loadPendingOrganizationImages = () => {
if (pendingOrganizationImageLoad) {
pendingOrganizationImageLoadRequested = true
return
}
const imageLoads: Promise<void>[] = []
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<string, Organization>()
const appsByOrgId = new Map<string, OrganizationApp[]>()
const appsByAppId = new Map<string, OrganizationApp>()
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<typeof appWithImmediateIcon>[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<string, number>()
const writeAppFields = (appId: string, patch: Partial<OrganizationApp>) => {
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<string, number>()
const publishRefreshedOnboarding = (refreshedApps: Map<string, OrganizationApp>) => {
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<string>()
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<string, OrganizationApp>()
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<ExtendedOrganizationMembers> => {
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<boolean>()
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,
}
})