Ctrl K
ComunidadGuías…

src/stores/organization.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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,
  }
})