@innertia-solutions/innertia-nuxt 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.config.ts +19 -0
- package/components/Organization/Picker.vue +60 -0
- package/components/Organization/Switcher.vue +84 -0
- package/composables/useApiConsolidatedOrganizations.ts +35 -0
- package/composables/useAuth.js +14 -1
- package/composables/useOrganization.ts +94 -0
- package/layouts/backoffice.vue +8 -0
- package/package.json +1 -1
- package/plugins/api-organization.client.ts +37 -0
- package/stores/organization.js +109 -0
package/app.config.ts
CHANGED
|
@@ -101,6 +101,25 @@ export default defineAppConfig({
|
|
|
101
101
|
*/
|
|
102
102
|
menuApps: [] as MenuItem[],
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Soporte de organizaciones (sub-tenant scoping).
|
|
106
|
+
* Debe matchear con `innertia.organizations.enabled` del backend.
|
|
107
|
+
*
|
|
108
|
+
* Cuando está activo:
|
|
109
|
+
* - El interceptor agrega `X-Organization: <slug>` a cada request
|
|
110
|
+
* - Al entrar a un contexto, si el usuario tiene 2+ orgs y no hay elegida → picker
|
|
111
|
+
* - Si tiene 1 org → auto-select
|
|
112
|
+
* - La elección se persiste por contexto en cookie (30 días)
|
|
113
|
+
*/
|
|
114
|
+
organizations: {
|
|
115
|
+
/** Activar feature. Default false. */
|
|
116
|
+
enabled: false,
|
|
117
|
+
/** Si true, los productos pueden exponer toggle "vista consolidada" (X-Consolidated header). */
|
|
118
|
+
allowConsolidated: false,
|
|
119
|
+
/** Si true, bloquea el contexto cuando el user tiene 0 orgs accesibles. Default false (deja pasar). */
|
|
120
|
+
required: false,
|
|
121
|
+
},
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
124
|
* Declaración de "apps" (contextos) del producto.
|
|
106
125
|
* Cada app define un prefijo de URL que mapea a un contexto del backend
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Overlay full-screen para elegir organización al entrar a un contexto sin
|
|
4
|
+
* tener una guardada (o cuando la guardada ya no es accesible).
|
|
5
|
+
*
|
|
6
|
+
* Se renderiza desde los layouts (auth, backoffice) cuando `useOrganization().needsPicker`
|
|
7
|
+
* devuelve `true`. Bloquea el contenido detrás hasta que el user elija.
|
|
8
|
+
*
|
|
9
|
+
* Persistencia: al elegir, se guarda en cookie 30d (via store).
|
|
10
|
+
*/
|
|
11
|
+
import { IconBuildingSkyscraper, IconArrowRight } from '@tabler/icons-vue'
|
|
12
|
+
|
|
13
|
+
const { current: currentApp } = useApp()
|
|
14
|
+
const { available, switchTo } = useOrganization()
|
|
15
|
+
|
|
16
|
+
const title = computed(() =>
|
|
17
|
+
currentApp.value
|
|
18
|
+
? `Elige una organización para continuar en ${currentApp.value.label}`
|
|
19
|
+
: 'Elige una organización'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async function pick(slug: string) {
|
|
23
|
+
await switchTo(slug)
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="fixed inset-0 z-[100] bg-background/95 backdrop-blur-sm flex items-center justify-center p-6">
|
|
29
|
+
<div class="w-full max-w-md space-y-6">
|
|
30
|
+
<div class="text-center space-y-2">
|
|
31
|
+
<div class="mx-auto size-12 rounded-2xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
|
32
|
+
<IconBuildingSkyscraper class="size-6 text-primary dark:text-primary-300" />
|
|
33
|
+
</div>
|
|
34
|
+
<h1 class="text-xl font-bold text-foreground">{{ title }}</h1>
|
|
35
|
+
<p class="text-sm text-muted-foreground">
|
|
36
|
+
Selecciona la organización con la que quieres trabajar. Podrás cambiarla más adelante.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="space-y-2">
|
|
41
|
+
<button
|
|
42
|
+
v-for="org in available"
|
|
43
|
+
:key="org.key"
|
|
44
|
+
type="button"
|
|
45
|
+
@click="pick(org.key)"
|
|
46
|
+
class="group w-full flex items-center gap-3 rounded-xl border border-card-line bg-card px-4 py-3.5 text-left hover:border-primary/40 hover:bg-primary-50/40 dark:hover:bg-primary-900/10 transition-colors"
|
|
47
|
+
>
|
|
48
|
+
<div class="size-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
|
|
49
|
+
<IconBuildingSkyscraper class="size-5 text-primary dark:text-primary-300" />
|
|
50
|
+
</div>
|
|
51
|
+
<div class="flex-1 min-w-0">
|
|
52
|
+
<div class="text-sm font-semibold text-foreground truncate">{{ org.name }}</div>
|
|
53
|
+
<div class="text-xs text-muted-foreground truncate">{{ org.key }}</div>
|
|
54
|
+
</div>
|
|
55
|
+
<IconArrowRight class="size-4 text-muted-foreground group-hover:text-primary transition-colors shrink-0" />
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Dropdown para cambiar entre organizaciones desde dentro de un contexto.
|
|
4
|
+
*
|
|
5
|
+
* - Oculto si el feature está desactivado o el user solo tiene 1 org.
|
|
6
|
+
* - Muestra org actual + lista de accesibles.
|
|
7
|
+
* - Si `allowConsolidated`, agrega toggle "Vista consolidada" al final.
|
|
8
|
+
*
|
|
9
|
+
* Pensado para insertar en el topbar del layout backoffice (slot `topbar-actions`).
|
|
10
|
+
*/
|
|
11
|
+
import { IconBuildingSkyscraper, IconCheck, IconChevronDown, IconLayoutGrid } from '@tabler/icons-vue'
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
isEnabled,
|
|
15
|
+
allowConsolidated,
|
|
16
|
+
available,
|
|
17
|
+
current,
|
|
18
|
+
consolidated,
|
|
19
|
+
switchTo,
|
|
20
|
+
} = useOrganization()
|
|
21
|
+
|
|
22
|
+
const visible = computed(() => isEnabled.value && available.value.length > 1)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div v-if="visible" class="hs-dropdown [--placement:bottom-right] relative inline-flex">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-foreground-inverse hover:bg-plain/10 focus:outline-hidden text-sm"
|
|
30
|
+
aria-haspopup="menu"
|
|
31
|
+
>
|
|
32
|
+
<IconBuildingSkyscraper class="size-4 shrink-0" />
|
|
33
|
+
<span class="hidden md:inline truncate max-w-32">{{ current?.name ?? 'Sin organización' }}</span>
|
|
34
|
+
<IconChevronDown class="size-3.5 shrink-0 opacity-60" />
|
|
35
|
+
</button>
|
|
36
|
+
|
|
37
|
+
<div
|
|
38
|
+
class="hs-dropdown-menu hs-dropdown-open:opacity-100 w-64 transition-[opacity,margin] opacity-0 hidden z-20 bg-dropdown border border-dropdown-line rounded-xl shadow-xl"
|
|
39
|
+
role="menu"
|
|
40
|
+
>
|
|
41
|
+
<div class="p-1 border-b border-dropdown-divider">
|
|
42
|
+
<div class="py-2 px-3">
|
|
43
|
+
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Organización</p>
|
|
44
|
+
</div>
|
|
45
|
+
<button
|
|
46
|
+
v-for="org in available"
|
|
47
|
+
:key="org.key"
|
|
48
|
+
type="button"
|
|
49
|
+
@click="switchTo(org.key)"
|
|
50
|
+
class="w-full flex items-center gap-x-2.5 py-2 px-3 rounded-lg text-sm text-dropdown-item-foreground hover:bg-dropdown-item-hover focus:outline-hidden"
|
|
51
|
+
>
|
|
52
|
+
<div class="size-7 rounded-md bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
|
|
53
|
+
<IconBuildingSkyscraper class="size-3.5 text-primary dark:text-primary-300" />
|
|
54
|
+
</div>
|
|
55
|
+
<div class="flex-1 min-w-0 text-left">
|
|
56
|
+
<div class="text-sm font-medium truncate">{{ org.name }}</div>
|
|
57
|
+
<div class="text-[10px] text-muted-foreground truncate">{{ org.key }}</div>
|
|
58
|
+
</div>
|
|
59
|
+
<IconCheck v-if="current?.key === org.key" class="size-4 text-primary shrink-0" />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div v-if="allowConsolidated" class="p-1">
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
@click="consolidated = !consolidated"
|
|
67
|
+
class="w-full flex items-center gap-x-2.5 py-2 px-3 rounded-lg text-sm text-dropdown-item-foreground hover:bg-dropdown-item-hover focus:outline-hidden"
|
|
68
|
+
>
|
|
69
|
+
<IconLayoutGrid class="size-4 shrink-0 text-muted-foreground" />
|
|
70
|
+
<span class="flex-1 text-left">Vista consolidada</span>
|
|
71
|
+
<span
|
|
72
|
+
class="inline-flex items-center h-4 w-7 rounded-full transition-colors"
|
|
73
|
+
:class="consolidated ? 'bg-primary' : 'bg-muted'"
|
|
74
|
+
>
|
|
75
|
+
<span
|
|
76
|
+
class="inline-block size-3 bg-white rounded-full transition-transform"
|
|
77
|
+
:class="consolidated ? 'translate-x-3.5' : 'translate-x-0.5'"
|
|
78
|
+
/>
|
|
79
|
+
</span>
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper de useApi que inyecta `X-Consolidated: true` en cada request.
|
|
3
|
+
*
|
|
4
|
+
* Úsalo solo en composables/páginas que requieren scope multi-org del backend
|
|
5
|
+
* (ej. dashboards consolidados, reportes ejecutivos). El usuario debe tener
|
|
6
|
+
* permisos correspondientes — el backend valida y filtra por `accessibleOrganizationIds()`.
|
|
7
|
+
*
|
|
8
|
+
* Uso típico:
|
|
9
|
+
* const api = useApiConsolidatedOrganizations()
|
|
10
|
+
* const stats = await api.get('reports/all-orgs-summary')
|
|
11
|
+
*
|
|
12
|
+
* El header se envía SIEMPRE en las requests hechas con esta instancia,
|
|
13
|
+
* independiente del toggle global `organizationStore.consolidated`.
|
|
14
|
+
*/
|
|
15
|
+
export function useApiConsolidatedOrganizations() {
|
|
16
|
+
const api = useApi()
|
|
17
|
+
|
|
18
|
+
// Decora cada método para agregar el header X-Consolidated: true
|
|
19
|
+
const withConsolidated = (options: any = {}) => ({
|
|
20
|
+
...options,
|
|
21
|
+
headers: {
|
|
22
|
+
...(options?.headers ?? {}),
|
|
23
|
+
'X-Consolidated': 'true',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
get: (url: string, options: any = {}) => api.get(url, withConsolidated(options)),
|
|
29
|
+
post: (url: string, data?: any, options: any = {}) => api.post(url, data, withConsolidated(options)),
|
|
30
|
+
put: (url: string, data?: any, options: any = {}) => api.put(url, data, withConsolidated(options)),
|
|
31
|
+
patch: (url: string, data?: any, options: any = {}) => api.patch(url, data, withConsolidated(options)),
|
|
32
|
+
delete: (url: string, options: any = {}) => api.delete(url, withConsolidated(options)),
|
|
33
|
+
upload: (url: string, data?: any, options: any = {}) => (api as any).upload?.(url, data, withConsolidated(options)),
|
|
34
|
+
}
|
|
35
|
+
}
|
package/composables/useAuth.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// useAuthStore, useApi auto-imported
|
|
1
|
+
// useAuthStore, useApi, useOrganizationStore auto-imported
|
|
2
2
|
|
|
3
3
|
export function useAuth() {
|
|
4
4
|
const authStore = useAuthStore()
|
|
5
|
+
const organizationStore = useOrganizationStore()
|
|
5
6
|
const api = useApi()
|
|
6
7
|
const config = useRuntimeConfig()
|
|
7
8
|
const loginPath = config.public.loginPath || '/login'
|
|
@@ -31,6 +32,17 @@ export function useAuth() {
|
|
|
31
32
|
authStore.saveUser(data.user ?? data)
|
|
32
33
|
authStore.savePermissions(data.permissions ?? [])
|
|
33
34
|
authStore.availableContexts = data.availableContexts ?? []
|
|
35
|
+
|
|
36
|
+
// Hidratar organizaciones accesibles por contexto (si el backend las devuelve).
|
|
37
|
+
// Shape esperado: { backoffice: [{ id, key, name }], technician: [...] }
|
|
38
|
+
if (data.organizations) {
|
|
39
|
+
organizationStore.setAvailable(data.organizations)
|
|
40
|
+
// Auto-select para contextos donde el user tiene 1 sola org
|
|
41
|
+
for (const ctx of Object.keys(data.organizations)) {
|
|
42
|
+
organizationStore.autoSelectFor(ctx)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
applyAppearance(data.preferences?.appearance)
|
|
35
47
|
return data
|
|
36
48
|
}
|
|
@@ -54,6 +66,7 @@ export function useAuth() {
|
|
|
54
66
|
}
|
|
55
67
|
queryClient.clear()
|
|
56
68
|
authStore.logout()
|
|
69
|
+
organizationStore.reset()
|
|
57
70
|
await navigateTo(loginPath)
|
|
58
71
|
}
|
|
59
72
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable de organizaciones (sub-tenant scoping).
|
|
3
|
+
*
|
|
4
|
+
* Lee el contexto actual del usuario (via useApp) y expone su org activa + lista
|
|
5
|
+
* de accesibles para ese contexto. Cambiar de contexto rota la org automáticamente.
|
|
6
|
+
*
|
|
7
|
+
* Uso típico:
|
|
8
|
+
* const { current, available, needsPicker, switchTo } = useOrganization()
|
|
9
|
+
*
|
|
10
|
+
* <OrganizationSwitcher v-if="available.length > 1" />
|
|
11
|
+
*/
|
|
12
|
+
export function useOrganization() {
|
|
13
|
+
const { current: currentApp } = useApp()
|
|
14
|
+
const organizationStore = useOrganizationStore()
|
|
15
|
+
const appConfig = useAppConfig()
|
|
16
|
+
const { fetchMe } = useAuth()
|
|
17
|
+
|
|
18
|
+
const isEnabled = computed(() => !!appConfig.innertia?.organizations?.enabled)
|
|
19
|
+
const allowConsolidated = computed(() => !!appConfig.innertia?.organizations?.allowConsolidated)
|
|
20
|
+
const required = computed(() => !!appConfig.innertia?.organizations?.required)
|
|
21
|
+
|
|
22
|
+
/** Contexto actual (key) — desde useApp. */
|
|
23
|
+
const currentContext = computed<string | null>(() => currentApp.value?.context ?? null)
|
|
24
|
+
|
|
25
|
+
/** Lista de orgs accesibles para el contexto actual. */
|
|
26
|
+
const available = computed(() => {
|
|
27
|
+
if (!currentContext.value) return []
|
|
28
|
+
return organizationStore.availableFor(currentContext.value)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Org actual (object completo) para el contexto. */
|
|
32
|
+
const current = computed(() => {
|
|
33
|
+
if (!currentContext.value) return null
|
|
34
|
+
return organizationStore.currentObjectFor(currentContext.value)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Slug de la org actual (útil para headers, comparaciones). */
|
|
38
|
+
const currentSlug = computed(() => current.value?.key ?? null)
|
|
39
|
+
|
|
40
|
+
/** ¿Hay que mostrar picker? */
|
|
41
|
+
const needsPicker = computed(() => {
|
|
42
|
+
if (!isEnabled.value) return false
|
|
43
|
+
if (!currentContext.value) return false
|
|
44
|
+
return organizationStore.needsPickerFor(currentContext.value)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** ¿El user tiene 0 orgs en este contexto Y el feature requiere org? */
|
|
48
|
+
const blocked = computed(() => {
|
|
49
|
+
if (!isEnabled.value || !required.value) return false
|
|
50
|
+
if (!currentContext.value) return false
|
|
51
|
+
return available.value.length === 0
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** Toggle vista consolidada (solo si está permitida globalmente). */
|
|
55
|
+
const consolidated = computed({
|
|
56
|
+
get: () => allowConsolidated.value && organizationStore.consolidated,
|
|
57
|
+
set: (v) => organizationStore.setConsolidated(v),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cambia la org actual del contexto. Re-fetcha auth/me para refrescar permisos
|
|
62
|
+
* scopeados al backend con la nueva org.
|
|
63
|
+
*/
|
|
64
|
+
async function switchTo(slug: string) {
|
|
65
|
+
if (!currentContext.value) return
|
|
66
|
+
organizationStore.setCurrent(currentContext.value, slug)
|
|
67
|
+
try {
|
|
68
|
+
await fetchMe()
|
|
69
|
+
} catch {
|
|
70
|
+
// best-effort
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Auto-select de la org si solo hay 1 disponible. Llamar al montar layouts. */
|
|
75
|
+
function autoSelect() {
|
|
76
|
+
if (!currentContext.value || !isEnabled.value) return
|
|
77
|
+
organizationStore.autoSelectFor(currentContext.value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
isEnabled,
|
|
82
|
+
allowConsolidated,
|
|
83
|
+
required,
|
|
84
|
+
currentContext,
|
|
85
|
+
available,
|
|
86
|
+
current,
|
|
87
|
+
currentSlug,
|
|
88
|
+
needsPicker,
|
|
89
|
+
blocked,
|
|
90
|
+
consolidated,
|
|
91
|
+
switchTo,
|
|
92
|
+
autoSelect,
|
|
93
|
+
}
|
|
94
|
+
}
|
package/layouts/backoffice.vue
CHANGED
|
@@ -26,6 +26,10 @@ const { docked } = useDockedPreviews()
|
|
|
26
26
|
const route = useRoute()
|
|
27
27
|
const appConfig = useAppConfig()
|
|
28
28
|
|
|
29
|
+
// Organizaciones — picker se muestra si el feature está activo y el user
|
|
30
|
+
// tiene 2+ orgs sin elegir todavía. Bloquea el contenido detrás.
|
|
31
|
+
const { needsPicker: needsOrgPicker } = useOrganization()
|
|
32
|
+
|
|
29
33
|
const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innertia' })
|
|
30
34
|
const menuItems = computed<MenuItem[]>(() => (appConfig.innertia?.menu ?? []) as MenuItem[])
|
|
31
35
|
const menuApps = computed<MenuItem[]>(() => (appConfig.innertia?.menuApps ?? []) as MenuItem[])
|
|
@@ -107,6 +111,7 @@ const homeRoute = computed(() => menuItems.value[0]?.route ?? '/')
|
|
|
107
111
|
<icons.IconMoon v-else class="size-4 shrink-0" />
|
|
108
112
|
</button>
|
|
109
113
|
|
|
114
|
+
<OrganizationSwitcher />
|
|
110
115
|
<slot name="topbar-actions" />
|
|
111
116
|
|
|
112
117
|
<div class="hidden sm:block w-px h-6 bg-navbar-divider/20 mx-1" />
|
|
@@ -205,5 +210,8 @@ const homeRoute = computed(() => menuItems.value[0]?.route ?? '/')
|
|
|
205
210
|
|
|
206
211
|
<!-- Preview dock (previews minimizados de tablas) -->
|
|
207
212
|
<AppPreviewDock />
|
|
213
|
+
|
|
214
|
+
<!-- Organization picker — overlay full-screen cuando aplica -->
|
|
215
|
+
<OrganizationPicker v-if="needsOrgPicker" />
|
|
208
216
|
</div>
|
|
209
217
|
</template>
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registra el interceptor del header X-Organization.
|
|
3
|
+
*
|
|
4
|
+
* Se envía cuando:
|
|
5
|
+
* - `appConfig.innertia.organizations.enabled === true`
|
|
6
|
+
* - El user tiene una org activa para el contexto actual
|
|
7
|
+
*
|
|
8
|
+
* También inyecta `X-Consolidated: true` si el store tiene `consolidated === true`
|
|
9
|
+
* (toggle global, ej. desde un OrganizationSwitcher). Para llamadas one-off
|
|
10
|
+
* consolidadas en código, usar `useApiConsolidatedOrganizations()` que inyecta
|
|
11
|
+
* el header por-request sin afectar el toggle global.
|
|
12
|
+
*
|
|
13
|
+
* Solo activo cuando `mode === 'saas'` (orgs viven dentro de un tenant).
|
|
14
|
+
*/
|
|
15
|
+
export default defineNuxtPlugin(() => {
|
|
16
|
+
const { hasTenant } = useInnertiaMode()
|
|
17
|
+
if (!hasTenant()) return
|
|
18
|
+
|
|
19
|
+
const appConfig = useAppConfig()
|
|
20
|
+
if (!appConfig.innertia?.organizations?.enabled) return
|
|
21
|
+
|
|
22
|
+
const { add } = useRequestInterceptors()
|
|
23
|
+
const organizationStore = useOrganizationStore()
|
|
24
|
+
const { currentContext } = useOrganization()
|
|
25
|
+
|
|
26
|
+
add((headers: Record<string, string>) => {
|
|
27
|
+
const ctx = currentContext.value
|
|
28
|
+
if (!ctx) return
|
|
29
|
+
|
|
30
|
+
const slug = organizationStore.currentFor(ctx)
|
|
31
|
+
if (slug) headers['X-Organization'] = slug
|
|
32
|
+
|
|
33
|
+
if (organizationStore.consolidated) {
|
|
34
|
+
headers['X-Consolidated'] = 'true'
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Store de organizaciones (sub-tenant scoping).
|
|
5
|
+
*
|
|
6
|
+
* Model:
|
|
7
|
+
* - `available` es un Record<context, Organization[]> — lo que el backend devuelve
|
|
8
|
+
* en auth/me. Cada contexto puede tener su propia lista de orgs accesibles.
|
|
9
|
+
* - `currentByContext` es Record<context, string> — slug de la org elegida por
|
|
10
|
+
* contexto. Persiste en cookie 30d para retomar al volver.
|
|
11
|
+
*
|
|
12
|
+
* Org shape: { id, key (slug), name }
|
|
13
|
+
*
|
|
14
|
+
* Persistencia:
|
|
15
|
+
* - `currentByContext` en cookie 'innertia_org_by_ctx' (30d, SSR-leíble)
|
|
16
|
+
* - `available` NO se persiste — viene fresco de auth/me en cada boot
|
|
17
|
+
*/
|
|
18
|
+
export const useOrganizationStore = defineStore('organization', {
|
|
19
|
+
state: () => ({
|
|
20
|
+
/** @type {Record<string, Array<{ id: number, key: string, name: string }>>} */
|
|
21
|
+
available: {},
|
|
22
|
+
/** @type {Record<string, string>} */
|
|
23
|
+
currentByContext: {},
|
|
24
|
+
/** @type {boolean} — toggle global de vista consolidada (X-Consolidated header) */
|
|
25
|
+
consolidated: false,
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
persist: [
|
|
29
|
+
{
|
|
30
|
+
key: 'innertia_org_by_ctx',
|
|
31
|
+
pick: ['currentByContext'],
|
|
32
|
+
storage: piniaPluginPersistedstate.cookies,
|
|
33
|
+
cookieOptions: { maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
getters: {
|
|
38
|
+
/** Orgs accesibles para un contexto dado. */
|
|
39
|
+
availableFor: (state) => (context) => state.available[context] ?? [],
|
|
40
|
+
|
|
41
|
+
/** Slug de la org actual para un contexto. */
|
|
42
|
+
currentFor: (state) => (context) => state.currentByContext[context] ?? null,
|
|
43
|
+
|
|
44
|
+
/** Org actual completa (object) para un contexto. */
|
|
45
|
+
currentObjectFor() {
|
|
46
|
+
return (context) => {
|
|
47
|
+
const slug = this.currentFor(context)
|
|
48
|
+
if (!slug) return null
|
|
49
|
+
return this.availableFor(context).find(o => o.key === slug) ?? null
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** ¿Necesita mostrar picker para este contexto? */
|
|
54
|
+
needsPickerFor() {
|
|
55
|
+
return (context) => {
|
|
56
|
+
const list = this.availableFor(context)
|
|
57
|
+
const current = this.currentFor(context)
|
|
58
|
+
if (list.length === 0) return false // sin acceso — caso aparte
|
|
59
|
+
if (list.length === 1) return false // auto-select, no necesita picker
|
|
60
|
+
if (current && list.some(o => o.key === current)) return false
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
actions: {
|
|
67
|
+
/** Reemplaza la lista de orgs por contexto (típicamente llamado desde fetchMe). */
|
|
68
|
+
setAvailable(byContext) {
|
|
69
|
+
this.available = byContext ?? {}
|
|
70
|
+
// Limpia currentByContext de slugs que ya no son accesibles
|
|
71
|
+
const cleaned = {}
|
|
72
|
+
for (const [ctx, slug] of Object.entries(this.currentByContext)) {
|
|
73
|
+
const list = this.available[ctx] ?? []
|
|
74
|
+
if (list.some(o => o.key === slug)) cleaned[ctx] = slug
|
|
75
|
+
}
|
|
76
|
+
this.currentByContext = cleaned
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** Setea la org actual para un contexto. */
|
|
80
|
+
setCurrent(context, slug) {
|
|
81
|
+
this.currentByContext = { ...this.currentByContext, [context]: slug }
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Auto-select: si hay 1 sola org en el contexto y no está seteada, la selecciona. */
|
|
85
|
+
autoSelectFor(context) {
|
|
86
|
+
const list = this.availableFor(context)
|
|
87
|
+
const current = this.currentFor(context)
|
|
88
|
+
if (list.length === 1 && current !== list[0].key) {
|
|
89
|
+
this.setCurrent(context, list[0].key)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** Toggle vista consolidada. */
|
|
94
|
+
toggleConsolidated() {
|
|
95
|
+
this.consolidated = !this.consolidated
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
setConsolidated(value) {
|
|
99
|
+
this.consolidated = !!value
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/** Cleanup en logout. */
|
|
103
|
+
reset() {
|
|
104
|
+
this.available = {}
|
|
105
|
+
this.currentByContext = {}
|
|
106
|
+
this.consolidated = false
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
})
|