@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 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
+ }
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -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
+ })