@innertia-solutions/innertia-nuxt 0.1.11 → 0.1.12

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/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@innertia-solutions/innertia-nuxt` are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### BREAKING CHANGES
8
+
9
+ #### apps → contexts rename
10
+
11
+ Aligns frontend with innertia-laravel which already renamed the concept.
12
+
13
+ **Config (`nuxt.config.ts` in your product):**
14
+ ```diff
15
+ - innertia.apps: { backoffice: { ... }, technician: { ... } }
16
+ + innertia.contexts: { backoffice: { ... }, technician: { ... } }
17
+ ```
18
+
19
+ **Types:**
20
+ ```diff
21
+ - import type { AppDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
22
+ + import type { ContextDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
23
+
24
+ - import type { AppMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
25
+ + import type { ContextMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
26
+ ```
27
+
28
+ **Composables:**
29
+ ```diff
30
+ - const { current, all, accessible, canAccess } = useApp()
31
+ + const { current, all, accessible, canAccess } = useContext()
32
+ ```
33
+
34
+ `useApp()` has been removed. Use `useContext()` for all context/app logic. `useContext()` now also exposes: `contexts` (dict), `currentContext`, `availableContexts`, `switchContext()`, `confirmSwitch()`, `hasAccessToContext()`.
35
+
36
+ **Login body field:**
37
+
38
+ `performLogin()` now sends `context` instead of `app` in the POST body — matching what innertia-laravel now expects.
package/app.config.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  //
3
3
  // appConfig: {
4
4
  // innertia: {
5
- // apps: { backoffice: {...}, technician: {...} },
5
+ // contexts: { backoffice: {...}, technician: {...} },
6
6
  // mobile: { breakpoint: 1024 },
7
7
  // },
8
8
  // }
9
9
  //
10
- // Si el producto no declara `apps`, el feature de contextos queda inactivo
10
+ // Si el producto no declara `contexts`, el feature de contextos queda inactivo
11
11
  // (middleware no redirige, picker no se muestra).
12
12
  export default defineAppConfig({
13
13
  innertia: {
@@ -121,11 +121,11 @@ export default defineAppConfig({
121
121
  },
122
122
 
123
123
  /**
124
- * Declaración de "apps" (contextos) del producto.
125
- * Cada app define un prefijo de URL que mapea a un contexto del backend
124
+ * Declaración de contextos del producto.
125
+ * Cada contexto define un prefijo de URL que mapea a un contexto del backend
126
126
  * (matching con `availableContexts` que devuelve `auth/me`).
127
127
  */
128
- apps: {} as Record<string, AppDefinition>,
128
+ contexts: {} as Record<string, ContextDefinition>,
129
129
 
130
130
  /**
131
131
  * Política mobile global. Se aplica si una `app` no tiene `mobile.mode` propio.
@@ -144,7 +144,7 @@ export default defineAppConfig({
144
144
 
145
145
  export type InnertiaMode = 'saas' | 'app'
146
146
 
147
- export type AppMobileMode = 'allow' | 'block' | 'redirect'
147
+ export type ContextMobileMode = 'allow' | 'block' | 'redirect'
148
148
 
149
149
  /** Color: nombre de paleta Tailwind o scale custom 50→950. */
150
150
  export type ColorScale = Partial<Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>>
@@ -175,8 +175,8 @@ export interface MenuItem {
175
175
  pattern?: string
176
176
  }
177
177
 
178
- export interface AppDefinition {
179
- /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
178
+ export interface ContextDefinition {
179
+ /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del contexto. */
180
180
  path: string
181
181
  /** Clave del contexto que matchea con `availableContexts` del backend. */
182
182
  context: string
@@ -186,13 +186,13 @@ export interface AppDefinition {
186
186
  description?: string
187
187
  /** Nombre del icono de @tabler/icons-vue (sin "Icon"). */
188
188
  icon?: string
189
- /** Ruta de login del app. */
189
+ /** Ruta de login del contexto. */
190
190
  loginPath: string
191
191
  /** Ruta home (post-login). */
192
192
  home: string
193
193
  /** Política de uso en mobile. */
194
194
  mobile: {
195
- mode: AppMobileMode
195
+ mode: ContextMobileMode
196
196
  /** Solo si mode === 'redirect' — adónde mandar desde mobile. */
197
197
  redirectTo?: string
198
198
  }
@@ -99,7 +99,7 @@ async function handleOauth(provider: OAuthProvider) {
99
99
 
100
100
  // ── Cross-links a otros contextos mobile-friendly (opcional) ───────────────
101
101
  const { isMobile } = useMobileGuard()
102
- const { all } = useApp()
102
+ const { all } = useContext()
103
103
  const otherMobileApps = computed(() => {
104
104
  if (!props.showCrossLinks) return []
105
105
  return all.value.filter((a: any) => a.path !== `/${props.context}` && a.mobile?.mode === 'allow')
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { IconDeviceDesktop, IconArrowRight, IconCopy, IconCheck } from '@tabler/icons-vue'
3
- import type { AppDefinition } from '~/configs/apps'
3
+ import type { ContextDefinition } from '~/configs/apps'
4
4
 
5
5
  const props = defineProps<{
6
- /** App al que se intentó acceder (para el copy contextual). */
7
- blockedApp?: AppDefinition | null
8
- /** App mobile-friendly al que el usuario puede continuar (opcional). */
9
- fallbackApp?: AppDefinition | null
6
+ /** Context al que se intentó acceder (para el copy contextual). */
7
+ blockedApp?: ContextDefinition | null
8
+ /** Context mobile-friendly al que el usuario puede continuar (opcional). */
9
+ fallbackApp?: ContextDefinition | null
10
10
  }>()
11
11
 
12
12
  const route = useRoute()
@@ -1,18 +1,18 @@
1
1
  <script setup lang="ts">
2
2
  import * as TablerIcons from '@tabler/icons-vue'
3
3
  import { IconArrowRight } from '@tabler/icons-vue'
4
- import type { AppDefinition } from '~/configs/apps'
4
+ import type { ContextDefinition } from '~/configs/apps'
5
5
 
6
6
  const props = defineProps<{
7
- /** Apps a mostrar como opciones de login. */
8
- options: AppDefinition[]
7
+ /** Contexts a mostrar como opciones de login. */
8
+ options: ContextDefinition[]
9
9
  /** Título personalizable. */
10
10
  title?: string
11
11
  }>()
12
12
 
13
13
  const emit = defineEmits<{
14
- /** Emitido cuando el usuario elige un app — el padre se encarga de navegar y recordar la elección. */
15
- pick: [app: AppDefinition]
14
+ /** Emitido cuando el usuario elige un context — el padre se encarga de navegar y recordar la elección. */
15
+ pick: [context: ContextDefinition]
16
16
  }>()
17
17
 
18
18
  const heading = computed(() => props.title ?? '¿Cómo quieres ingresar?')
@@ -46,25 +46,25 @@ function resolveIcon(name?: string) {
46
46
  <!-- Opciones -->
47
47
  <div class="space-y-3">
48
48
  <button
49
- v-for="app in options"
50
- :key="app.path"
49
+ v-for="context in options"
50
+ :key="context.path"
51
51
  type="button"
52
52
  class="group w-full flex items-center gap-4 rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-4 text-left hover:border-violet-300 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/10 transition-colors"
53
- @click="emit('pick', app)"
53
+ @click="emit('pick', context)"
54
54
  >
55
55
  <div class="shrink-0 size-11 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center group-hover:bg-violet-200 dark:group-hover:bg-violet-900/50 transition-colors">
56
56
  <component
57
- v-if="resolveIcon(app.icon)"
58
- :is="resolveIcon(app.icon)"
57
+ v-if="resolveIcon(context.icon)"
58
+ :is="resolveIcon(context.icon)"
59
59
  class="size-5 text-violet-600 dark:text-violet-400"
60
60
  />
61
61
  </div>
62
62
  <div class="flex-1 min-w-0">
63
63
  <div class="text-base font-semibold text-slate-900 dark:text-white">
64
- {{ app.label }}
64
+ {{ context.label }}
65
65
  </div>
66
- <div v-if="app.description" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">
67
- {{ app.description }}
66
+ <div v-if="context.description" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">
67
+ {{ context.description }}
68
68
  </div>
69
69
  </div>
70
70
  <IconArrowRight class="shrink-0 size-5 text-slate-400 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors" />
@@ -10,12 +10,12 @@
10
10
  */
11
11
  import { IconBuildingSkyscraper, IconArrowRight } from '@tabler/icons-vue'
12
12
 
13
- const { current: currentApp } = useApp()
13
+ const { current: currentContext } = useContext()
14
14
  const { available, switchTo } = useOrganization()
15
15
 
16
16
  const title = computed(() =>
17
- currentApp.value
18
- ? `Elige una organización para continuar en ${currentApp.value.label}`
17
+ currentContext.value
18
+ ? `Elige una organización para continuar en ${currentContext.value.label}`
19
19
  : 'Elige una organización'
20
20
  )
21
21
 
@@ -14,7 +14,7 @@ export function useAuth() {
14
14
  */
15
15
  async function performLogin(context, email, password, remember = false) {
16
16
  authStore.rememberUser = remember
17
- const data = await api.post(`${context}/auth/login`, { email, password, app: context })
17
+ const data = await api.post(`${context}/auth/login`, { email, password, context })
18
18
  authStore.saveToken(data.token ?? data.access_token)
19
19
  authStore.setCurrentContext(context)
20
20
  queryClient.clear()
@@ -1,11 +1,46 @@
1
- // useAuthStore, useApi, useAuth auto-imported
1
+ // useAuthStore, useApi, useAuth, useRoute, useAppConfig auto-imported
2
2
  import { computed } from 'vue'
3
+ import type { ContextDefinition } from '../app.config'
3
4
 
4
5
  export function useContext() {
6
+ const route = useRoute()
5
7
  const authStore = useAuthStore()
8
+ const appConfig = useAppConfig()
6
9
  const api = useApi()
7
10
  const { fetchMe } = useAuth()
8
11
 
12
+ // ── Config-level: definiciones declaradas en appConfig.innertia.contexts ───
13
+
14
+ /** Diccionario de contextos declarados por el producto. */
15
+ const contexts = computed<Record<string, ContextDefinition>>(() =>
16
+ (appConfig.innertia?.contexts ?? {}) as Record<string, ContextDefinition>
17
+ )
18
+
19
+ /** Todos los contextos declarados, en orden de declaración. */
20
+ const all = computed<ContextDefinition[]>(() => Object.values(contexts.value))
21
+
22
+ /** Contexto actual según URL. `null` si la URL no cae en ningún contexto declarado. */
23
+ const current = computed<ContextDefinition | null>(() => {
24
+ return all.value.find(ctx =>
25
+ route.path === ctx.path || route.path.startsWith(ctx.path + '/')
26
+ ) ?? null
27
+ })
28
+
29
+ /** Contextos a los que el usuario autenticado tiene acceso (filtrado por `availableContexts`). */
30
+ const accessible = computed<ContextDefinition[]>(() => {
31
+ const ctxs = (authStore.availableContexts ?? []) as string[]
32
+ return all.value.filter(ctx => ctxs.includes(ctx.context))
33
+ })
34
+
35
+ /** Helper: ¿el usuario puede acceder a este contexto (por clave de config)? */
36
+ function canAccess(key: string): boolean {
37
+ const ctx = contexts.value[key]
38
+ if (!ctx) return false
39
+ return ((authStore.availableContexts ?? []) as string[]).includes(ctx.context)
40
+ }
41
+
42
+ // ── Runtime: contexto activo + switching ───────────────────────────────────
43
+
9
44
  const currentContext = computed(() => authStore.currentContext)
10
45
  const availableContexts = computed(() => authStore.availableContexts)
11
46
 
@@ -15,7 +50,7 @@ export function useContext() {
15
50
  * { success: false, reason: 'no_permission' } — user cannot switch
16
51
  * { success: true, requiresConfirmation: true } — show confirmation UI
17
52
  */
18
- async function switchContext(targetContext) {
53
+ async function switchContext(targetContext: string) {
19
54
  const data = await api.get(`auth/context/${targetContext}/check`)
20
55
  if (!data.hasAccess) {
21
56
  return { success: false, reason: 'no_permission' }
@@ -27,7 +62,7 @@ export function useContext() {
27
62
  * Execute the context switch after user confirmation.
28
63
  * Updates store and reloads permissions via fetchMe.
29
64
  */
30
- async function confirmSwitch(targetContext) {
65
+ async function confirmSwitch(targetContext: string) {
31
66
  authStore.setCurrentContext(targetContext)
32
67
  await fetchMe()
33
68
  return { success: true }
@@ -36,9 +71,22 @@ export function useContext() {
36
71
  /**
37
72
  * Quick synchronous check — is this context in the available list?
38
73
  */
39
- function hasAccessToContext(context) {
74
+ function hasAccessToContext(context: string) {
40
75
  return authStore.availableContexts.includes(context)
41
76
  }
42
77
 
43
- return { currentContext, availableContexts, switchContext, confirmSwitch, hasAccessToContext }
78
+ return {
79
+ // config-level
80
+ contexts,
81
+ all,
82
+ current,
83
+ accessible,
84
+ canAccess,
85
+ // runtime
86
+ currentContext,
87
+ availableContexts,
88
+ switchContext,
89
+ confirmSwitch,
90
+ hasAccessToContext,
91
+ }
44
92
  }
@@ -1,61 +1,61 @@
1
1
  import { useMediaQuery } from '@vueuse/core'
2
- import type { AppDefinition } from '../app.config'
2
+ import type { ContextDefinition } from '../app.config'
3
3
 
4
- const PICKER_CHOICE_COOKIE = 'innertia_mobile_app_choice'
4
+ const PICKER_CHOICE_COOKIE = 'innertia_mobile_context_choice'
5
5
  const PICKER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 días
6
6
 
7
7
  /**
8
8
  * Detección reactiva de viewport mobile + lógica de landing.
9
9
  *
10
10
  * Reglas para `landing` (qué mostrar cuando el usuario entra a `/` en mobile):
11
- * - 0 apps mobile-friendly → blocker "abre en escritorio"
12
- * - 1 app mobile-friendly → redirect directo al login de ese app
13
- * - 2+ apps mobile-friendly → picker (con cookie para recordar última elección)
11
+ * - 0 contextos mobile-friendly → blocker "abre en escritorio"
12
+ * - 1 contexto mobile-friendly → redirect directo al login de ese contexto
13
+ * - 2+ contextos mobile-friendly → picker (con cookie para recordar última elección)
14
14
  *
15
15
  * El breakpoint se lee desde `appConfig.innertia.mobile.breakpoint` (default 1024).
16
16
  */
17
17
  export function useMobileGuard() {
18
- const { all, current, accessible } = useApp()
18
+ const { all, current, accessible } = useContext()
19
19
  const authStore = useAuthStore()
20
20
  const appConfig = useAppConfig()
21
21
 
22
22
  const breakpoint: number = appConfig.innertia?.mobile?.breakpoint ?? 1024
23
23
  const isMobile = useMediaQuery(`(max-width: ${breakpoint - 1}px)`)
24
24
 
25
- /** Apps con mode 'allow' en mobile. */
26
- const mobileApps = computed<AppDefinition[]>(() =>
25
+ /** Contextos con mode 'allow' en mobile. */
26
+ const mobileContexts = computed<ContextDefinition[]>(() =>
27
27
  all.value.filter(a => a.mobile?.mode === 'allow')
28
28
  )
29
29
 
30
- /** Apps mobile-friendly + accesibles para el usuario autenticado. */
31
- const mobileAccessibleApps = computed<AppDefinition[]>(() =>
30
+ /** Contextos mobile-friendly + accesibles para el usuario autenticado. */
31
+ const mobileAccessibleContexts = computed<ContextDefinition[]>(() =>
32
32
  accessible.value.filter(a => a.mobile?.mode === 'allow')
33
33
  )
34
34
 
35
- /** ¿El app actual está bloqueado en mobile? */
36
- const isCurrentAppBlocked = computed<boolean>(() => {
35
+ /** ¿El contexto actual está bloqueado en mobile? */
36
+ const isCurrentContextBlocked = computed<boolean>(() => {
37
37
  if (!isMobile.value) return false
38
38
  if (!current.value) return false
39
39
  return current.value.mobile?.mode === 'block'
40
40
  })
41
41
 
42
42
  /**
43
- * Para el blocker: si el usuario está autenticado y tiene OTRO app mobile-friendly,
43
+ * Para el blocker: si el usuario está autenticado y tiene OTRO contexto mobile-friendly,
44
44
  * lo ofrecemos como fallback ("continuar en X").
45
45
  */
46
- const mobileFallbackApp = computed<AppDefinition | null>(() => {
46
+ const mobileFallbackContext = computed<ContextDefinition | null>(() => {
47
47
  if (!authStore.isAuthenticated()) return null
48
- return mobileAccessibleApps.value[0] ?? null
48
+ return mobileAccessibleContexts.value[0] ?? null
49
49
  })
50
50
 
51
51
  // ── Cookie helpers para recordar última elección del picker ─────────────────
52
- function rememberPickerChoice(appKey: string) {
52
+ function rememberPickerChoice(contextKey: string) {
53
53
  if (!(appConfig.innertia?.mobile?.rememberChoice ?? true)) return
54
54
  const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE, {
55
55
  maxAge: PICKER_COOKIE_MAX_AGE,
56
56
  sameSite: 'lax',
57
57
  })
58
- cookie.value = appKey
58
+ cookie.value = contextKey
59
59
  }
60
60
 
61
61
  function getRememberedPickerChoice(): string | null {
@@ -70,10 +70,10 @@ export function useMobileGuard() {
70
70
 
71
71
  return {
72
72
  isMobile,
73
- mobileApps,
74
- mobileAccessibleApps,
75
- isCurrentAppBlocked,
76
- mobileFallbackApp,
73
+ mobileContexts,
74
+ mobileAccessibleContexts,
75
+ isCurrentContextBlocked,
76
+ mobileFallbackContext,
77
77
  rememberPickerChoice,
78
78
  getRememberedPickerChoice,
79
79
  clearRememberedPickerChoice,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Composable de organizaciones (sub-tenant scoping).
3
3
  *
4
- * Lee el contexto actual del usuario (via useApp) y expone su org activa + lista
4
+ * Lee el contexto actual del usuario (via useContext) y expone su org activa + lista
5
5
  * de accesibles para ese contexto. Cambiar de contexto rota la org automáticamente.
6
6
  *
7
7
  * Uso típico:
@@ -10,7 +10,7 @@
10
10
  * <OrganizationSwitcher v-if="available.length > 1" />
11
11
  */
12
12
  export function useOrganization() {
13
- const { current: currentApp } = useApp()
13
+ const { current: currentApp } = useContext()
14
14
  const organizationStore = useOrganizationStore()
15
15
  const appConfig = useAppConfig()
16
16
  const { fetchMe } = useAuth()
@@ -19,7 +19,7 @@ export function useOrganization() {
19
19
  const allowConsolidated = computed(() => !!appConfig.innertia?.organizations?.allowConsolidated)
20
20
  const required = computed(() => !!appConfig.innertia?.organizations?.required)
21
21
 
22
- /** Contexto actual (key) — desde useApp. */
22
+ /** Contexto actual (key) — desde useContext. */
23
23
  const currentContext = computed<string | null>(() => currentApp.value?.context ?? null)
24
24
 
25
25
  /** Lista de orgs accesibles para el contexto actual. */
@@ -0,0 +1,630 @@
1
+ # innertia-nuxt: apps → contexts Rename — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Rename the "apps" concept to "contexts" throughout innertia-nuxt, aligning the frontend library with the already-renamed innertia-laravel backend.
6
+
7
+ **Architecture:** Pure rename — zero behavior changes. Config key `innertia.apps` → `innertia.contexts`, types `AppDefinition`/`AppMobileMode` → `ContextDefinition`/`ContextMobileMode`, composable `useApp()` merged into `useContext()`, middleware file renamed, login body field corrected. No backward-compat aliases.
8
+
9
+ **Tech Stack:** Nuxt 4, Vue 3, TypeScript, Pinia. No test suite in this repo — verification is via `grep` for stale references.
10
+
11
+ **Working directory:** `/Users/guillermofarias/Innertia/Dev/workspace/libraries/innertia-nuxt`
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-05-27-nuxt-apps-to-contexts-rename-design.md`
14
+
15
+ ---
16
+
17
+ ## File Map
18
+
19
+ | File | Action |
20
+ |------|--------|
21
+ | `app.config.ts` | Rename type `AppDefinition` → `ContextDefinition`, `AppMobileMode` → `ContextMobileMode`, config key `innertia.apps` → `innertia.contexts` |
22
+ | `composables/useContext.js` | Absorb all logic from `useApp.ts`; now also reads from `innertia.contexts` and returns `current`, `all`, `accessible`, `canAccess`, `contexts` |
23
+ | `composables/useApp.ts` | **Delete** |
24
+ | `composables/useAuth.js` | Fix login body: `{ app: context }` → `{ context }` |
25
+ | `middleware/03.apps.global.ts` | **Rename** to `03.contexts.global.ts`; update import + internal variable references |
26
+ | `composables/useOrganization.ts` | `useApp()` → `useContext()` |
27
+ | `composables/useMobileGuard.ts` | `useApp()` → `useContext()`, `AppDefinition` → `ContextDefinition`, cookie key renamed |
28
+ | `CHANGELOG.md` | Create — document BREAKING CHANGES |
29
+
30
+ ---
31
+
32
+ ### Task 1: `app.config.ts` — rename types and config key
33
+
34
+ **Files:**
35
+ - Modify: `app.config.ts`
36
+
37
+ - [ ] **Step 1: Replace `innertia.apps` config key with `innertia.contexts`**
38
+
39
+ Find line (currently ~128):
40
+ ```ts
41
+ apps: {} as Record<string, AppDefinition>,
42
+ ```
43
+ Replace with:
44
+ ```ts
45
+ /**
46
+ * Declaración de contextos del producto.
47
+ * Cada contexto define un prefijo de URL que mapea a un contexto del backend
48
+ * (matching con `availableContexts` que devuelve `auth/me`).
49
+ */
50
+ contexts: {} as Record<string, ContextDefinition>,
51
+ ```
52
+
53
+ Also update the comment block just above it (currently ~122–127):
54
+ ```ts
55
+ /**
56
+ * Declaración de "apps" (contextos) del producto.
57
+ * Cada app define un prefijo de URL que mapea a un contexto del backend
58
+ * (matching con `availableContexts` que devuelve `auth/me`).
59
+ */
60
+ apps: {} as Record<string, AppDefinition>,
61
+ ```
62
+
63
+ - [ ] **Step 2: Rename `AppMobileMode` type**
64
+
65
+ Find line (~147):
66
+ ```ts
67
+ export type AppMobileMode = 'allow' | 'block' | 'redirect'
68
+ ```
69
+ Replace with:
70
+ ```ts
71
+ export type ContextMobileMode = 'allow' | 'block' | 'redirect'
72
+ ```
73
+
74
+ - [ ] **Step 3: Rename `AppDefinition` interface and update its internal reference to `AppMobileMode`**
75
+
76
+ Find (~178–199):
77
+ ```ts
78
+ export interface AppDefinition {
79
+ /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
80
+ path: string
81
+ /** Clave del contexto que matchea con `availableContexts` del backend. */
82
+ context: string
83
+ /** Label visible en pickers, dropdowns, etc. */
84
+ label: string
85
+ /** Descripción opcional para el picker mobile. */
86
+ description?: string
87
+ /** Nombre del icono de @tabler/icons-vue (sin "Icon"). */
88
+ icon?: string
89
+ /** Ruta de login del app. */
90
+ loginPath: string
91
+ /** Ruta home (post-login). */
92
+ home: string
93
+ /** Política de uso en mobile. */
94
+ mobile: {
95
+ mode: AppMobileMode
96
+ /** Solo si mode === 'redirect' — adónde mandar desde mobile. */
97
+ redirectTo?: string
98
+ }
99
+ }
100
+ ```
101
+ Replace with:
102
+ ```ts
103
+ export interface ContextDefinition {
104
+ /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del contexto. */
105
+ path: string
106
+ /** Clave del contexto que matchea con `availableContexts` del backend. */
107
+ context: string
108
+ /** Label visible en pickers, dropdowns, etc. */
109
+ label: string
110
+ /** Descripción opcional para el picker mobile. */
111
+ description?: string
112
+ /** Nombre del icono de @tabler/icons-vue (sin "Icon"). */
113
+ icon?: string
114
+ /** Ruta de login del contexto. */
115
+ loginPath: string
116
+ /** Ruta home (post-login). */
117
+ home: string
118
+ /** Política de uso en mobile. */
119
+ mobile: {
120
+ mode: ContextMobileMode
121
+ /** Solo si mode === 'redirect' — adónde mandar desde mobile. */
122
+ redirectTo?: string
123
+ }
124
+ }
125
+ ```
126
+
127
+ - [ ] **Step 4: Verify no remaining `AppDefinition` or `AppMobileMode` references in `app.config.ts`**
128
+
129
+ Run:
130
+ ```bash
131
+ grep -n "AppDefinition\|AppMobileMode" app.config.ts
132
+ ```
133
+ Expected: no output (zero matches).
134
+
135
+ - [ ] **Step 5: Commit**
136
+
137
+ ```bash
138
+ git add app.config.ts
139
+ git commit -m "refactor(contexts): rename AppDefinition→ContextDefinition, AppMobileMode→ContextMobileMode, innertia.apps→innertia.contexts in app.config"
140
+ ```
141
+
142
+ ---
143
+
144
+ ### Task 2: `useContext.js` — absorb `useApp` logic
145
+
146
+ **Files:**
147
+ - Modify: `composables/useContext.js`
148
+
149
+ The current `useContext.js` handles runtime context switching. We extend it to also expose the config-level definitions (previously in `useApp.ts`), reading from `appConfig.innertia.contexts`.
150
+
151
+ - [ ] **Step 1: Replace the full contents of `composables/useContext.js`**
152
+
153
+ ```js
154
+ // useAuthStore, useApi, useAuth auto-imported
155
+ import { computed } from 'vue'
156
+ import type { ContextDefinition } from '../app.config'
157
+
158
+ export function useContext() {
159
+ const route = useRoute()
160
+ const authStore = useAuthStore()
161
+ const appConfig = useAppConfig()
162
+ const api = useApi()
163
+ const { fetchMe } = useAuth()
164
+
165
+ // ── Config-level: definiciones declaradas en appConfig.innertia.contexts ───
166
+
167
+ /** Diccionario de contextos declarados por el producto. */
168
+ const contexts = computed<Record<string, ContextDefinition>>(() =>
169
+ (appConfig.innertia?.contexts ?? {}) as Record<string, ContextDefinition>
170
+ )
171
+
172
+ /** Todos los contextos declarados, en orden de declaración. */
173
+ const all = computed<ContextDefinition[]>(() => Object.values(contexts.value))
174
+
175
+ /** Contexto actual según URL. `null` si la URL no cae en ningún contexto declarado. */
176
+ const current = computed<ContextDefinition | null>(() => {
177
+ return all.value.find(ctx =>
178
+ route.path === ctx.path || route.path.startsWith(ctx.path + '/')
179
+ ) ?? null
180
+ })
181
+
182
+ /** Contextos a los que el usuario autenticado tiene acceso (filtrado por `availableContexts`). */
183
+ const accessible = computed<ContextDefinition[]>(() => {
184
+ const ctxs = (authStore.availableContexts ?? []) as string[]
185
+ return all.value.filter(ctx => ctxs.includes(ctx.context))
186
+ })
187
+
188
+ /** Helper: ¿el usuario puede acceder a este contexto (por clave de config)? */
189
+ function canAccess(key: string): boolean {
190
+ const ctx = contexts.value[key]
191
+ if (!ctx) return false
192
+ return ((authStore.availableContexts ?? []) as string[]).includes(ctx.context)
193
+ }
194
+
195
+ // ── Runtime: contexto activo + switching ───────────────────────────────────
196
+
197
+ const currentContext = computed(() => authStore.currentContext)
198
+ const availableContexts = computed(() => authStore.availableContexts)
199
+
200
+ /**
201
+ * Check whether user has permission to switch to targetContext.
202
+ * Returns:
203
+ * { success: false, reason: 'no_permission' } — user cannot switch
204
+ * { success: true, requiresConfirmation: true } — show confirmation UI
205
+ */
206
+ async function switchContext(targetContext) {
207
+ const data = await api.get(`auth/context/${targetContext}/check`)
208
+ if (!data.hasAccess) {
209
+ return { success: false, reason: 'no_permission' }
210
+ }
211
+ return { success: true, requiresConfirmation: true }
212
+ }
213
+
214
+ /**
215
+ * Execute the context switch after user confirmation.
216
+ * Updates store and reloads permissions via fetchMe.
217
+ */
218
+ async function confirmSwitch(targetContext) {
219
+ authStore.setCurrentContext(targetContext)
220
+ await fetchMe()
221
+ return { success: true }
222
+ }
223
+
224
+ /**
225
+ * Quick synchronous check — is this context in the available list?
226
+ */
227
+ function hasAccessToContext(context) {
228
+ return authStore.availableContexts.includes(context)
229
+ }
230
+
231
+ return {
232
+ // config-level
233
+ contexts,
234
+ all,
235
+ current,
236
+ accessible,
237
+ canAccess,
238
+ // runtime
239
+ currentContext,
240
+ availableContexts,
241
+ switchContext,
242
+ confirmSwitch,
243
+ hasAccessToContext,
244
+ }
245
+ }
246
+ ```
247
+
248
+ - [ ] **Step 2: Verify the file saved correctly**
249
+
250
+ ```bash
251
+ grep -n "innertia.contexts\|ContextDefinition\|contexts\|current\|accessible" composables/useContext.js | head -20
252
+ ```
253
+ Expected: lines showing `innertia.contexts`, `ContextDefinition`, and all the returned properties.
254
+
255
+ - [ ] **Step 3: Commit**
256
+
257
+ ```bash
258
+ git add composables/useContext.js
259
+ git commit -m "refactor(contexts): useContext absorbs useApp — exposes current, all, accessible, canAccess, contexts from innertia.contexts config"
260
+ ```
261
+
262
+ ---
263
+
264
+ ### Task 3: Delete `useApp.ts`
265
+
266
+ **Files:**
267
+ - Delete: `composables/useApp.ts`
268
+
269
+ - [ ] **Step 1: Delete the file**
270
+
271
+ ```bash
272
+ git rm composables/useApp.ts
273
+ ```
274
+
275
+ - [ ] **Step 2: Commit**
276
+
277
+ ```bash
278
+ git commit -m "refactor(contexts): remove useApp.ts — functionality merged into useContext"
279
+ ```
280
+
281
+ ---
282
+
283
+ ### Task 4: `useAuth.js` — fix login body field
284
+
285
+ **Files:**
286
+ - Modify: `composables/useAuth.js`
287
+
288
+ The `performLogin` function currently sends `app: context` in the POST body. The backend (innertia-laravel) now expects the field to be named `context`.
289
+
290
+ - [ ] **Step 1: Fix the `performLogin` call**
291
+
292
+ Find line (~17):
293
+ ```js
294
+ const data = await api.post(`${context}/auth/login`, { email, password, app: context })
295
+ ```
296
+ Replace with:
297
+ ```js
298
+ const data = await api.post(`${context}/auth/login`, { email, password, context })
299
+ ```
300
+
301
+ - [ ] **Step 2: Verify**
302
+
303
+ ```bash
304
+ grep -n "app:" composables/useAuth.js
305
+ ```
306
+ Expected: no output (zero matches — the old `app: context` field is gone).
307
+
308
+ - [ ] **Step 3: Commit**
309
+
310
+ ```bash
311
+ git add composables/useAuth.js
312
+ git commit -m "fix(contexts): send context field instead of app in performLogin body"
313
+ ```
314
+
315
+ ---
316
+
317
+ ### Task 5: Rename and update middleware
318
+
319
+ **Files:**
320
+ - Delete: `middleware/03.apps.global.ts`
321
+ - Create: `middleware/03.contexts.global.ts`
322
+
323
+ - [ ] **Step 1: Create `middleware/03.contexts.global.ts` with updated content**
324
+
325
+ ```ts
326
+ /**
327
+ * Middleware global que se ejecuta en cada navegación.
328
+ *
329
+ * Lee la declaración de contextos desde `appConfig.innertia.contexts`. Si el
330
+ * producto no declara contextos, este middleware no hace nada (feature inactivo).
331
+ *
332
+ * Cuando hay contextos declarados, resuelve qué contexto está intentando acceder
333
+ * el usuario según el prefijo de URL y:
334
+ * 1. Si la URL no cae en ningún contexto declarado → pasa (deja seguir)
335
+ * 2. Si la URL es la ruta de login del contexto → pasa (sin chequeo de permisos)
336
+ * 3. Si el usuario no está autenticado → deja que el middleware `auth` redirija
337
+ * 4. Si `availableContexts` está vacío (SSR sin hidratar) → intenta fetchMe()
338
+ * y si falla, pasa sin redirigir (mejor renderizar que redirigir mal)
339
+ * 5. Si el usuario está autenticado pero NO tiene acceso al contexto →
340
+ * redirige al primer contexto accesible (o a su loginPath si no tiene ninguno)
341
+ * 6. Si todo OK pero `currentContext !== targetContext.context` → sincroniza el
342
+ * contexto en el authStore y recarga permisos vía fetchMe()
343
+ *
344
+ * Esto es lo que permite el "cambio de contexto implícito por URL" sin botón.
345
+ *
346
+ * Numeración 03. para correr DESPUÉS de los middlewares saas
347
+ * (01.detect-subdomain, 02.validate-tenant).
348
+ */
349
+ import type { ContextDefinition } from '../app.config'
350
+
351
+ export default defineNuxtRouteMiddleware(async (to) => {
352
+ const appConfig = useAppConfig()
353
+ const contexts = (appConfig.innertia?.contexts ?? {}) as Record<string, ContextDefinition>
354
+ const list = Object.values(contexts)
355
+
356
+ // Si el producto no declaró contextos, el feature está inactivo
357
+ if (list.length === 0) return
358
+
359
+ // ¿La ruta cae bajo algún contexto?
360
+ const targetContext = list.find(ctx =>
361
+ to.path === ctx.path || to.path.startsWith(ctx.path + '/')
362
+ )
363
+
364
+ if (!targetContext) return // ruta pública / fuera de contextos
365
+
366
+ // La ruta de login del propio contexto no requiere permisos
367
+ if (to.path === targetContext.loginPath) return
368
+
369
+ const authStore = useAuthStore()
370
+
371
+ // Si no está autenticado, deja que el middleware `auth` de la página haga el redirect
372
+ if (!authStore.isAuthenticated()) return
373
+
374
+ // ── Hidratación defensiva ─────────────────────────────────────────────────
375
+ // `availableContexts` se persiste en localStorage, que NO se lee en SSR.
376
+ // El plugin auth-init hace fetchMe en SSR pero puede fallar silenciosamente
377
+ // (ej. tenant no resuelto). Si llegamos con token válido pero array vacío,
378
+ // intentamos cargar los contextos antes de decidir el redirect.
379
+ if ((authStore.availableContexts ?? []).length === 0) {
380
+ try {
381
+ const { fetchMe } = useAuth()
382
+ await fetchMe()
383
+ } catch {
384
+ // no podemos resolver — pasamos: mejor renderizar la página que redirigir mal
385
+ return
386
+ }
387
+ }
388
+
389
+ const userContexts = (authStore.availableContexts ?? []) as string[]
390
+
391
+ // Si tras hidratar seguimos sin contextos, NO redirigimos.
392
+ if (userContexts.length === 0) return
393
+
394
+ // ¿Tiene acceso al contexto?
395
+ if (!userContexts.includes(targetContext.context)) {
396
+ const fallback = list.find(ctx => userContexts.includes(ctx.context))
397
+ if (fallback) {
398
+ return navigateTo(fallback.home, { replace: true })
399
+ }
400
+ return navigateTo(list[0]?.loginPath ?? '/', { replace: true })
401
+ }
402
+
403
+ // Sincronizar currentContext silenciosamente si cambia
404
+ if (authStore.currentContext !== targetContext.context) {
405
+ authStore.setCurrentContext(targetContext.context)
406
+ try {
407
+ const { fetchMe } = useAuth()
408
+ await fetchMe()
409
+ } catch {
410
+ // best-effort
411
+ }
412
+ }
413
+ })
414
+ ```
415
+
416
+ - [ ] **Step 2: Remove the old middleware file**
417
+
418
+ ```bash
419
+ git rm middleware/03.apps.global.ts
420
+ ```
421
+
422
+ - [ ] **Step 3: Stage and commit**
423
+
424
+ ```bash
425
+ git add middleware/03.contexts.global.ts
426
+ git commit -m "refactor(contexts): rename 03.apps.global→03.contexts.global, innertia.apps→innertia.contexts, AppDefinition→ContextDefinition"
427
+ ```
428
+
429
+ ---
430
+
431
+ ### Task 6: `useOrganization.ts` + `useMobileGuard.ts` — swap `useApp()` for `useContext()`
432
+
433
+ **Files:**
434
+ - Modify: `composables/useOrganization.ts`
435
+ - Modify: `composables/useMobileGuard.ts`
436
+
437
+ - [ ] **Step 1: Update `useOrganization.ts` — change `useApp()` to `useContext()`**
438
+
439
+ Find line 13:
440
+ ```ts
441
+ const { current: currentApp } = useApp()
442
+ ```
443
+ Replace with:
444
+ ```ts
445
+ const { current: currentApp } = useContext()
446
+ ```
447
+
448
+ (The local alias `currentApp` is kept — it's used on line 23 as `currentApp.value?.context`. No other changes needed in this file.)
449
+
450
+ - [ ] **Step 2: Verify `useOrganization.ts` — no remaining `useApp` references**
451
+
452
+ ```bash
453
+ grep -n "useApp" composables/useOrganization.ts
454
+ ```
455
+ Expected: no output.
456
+
457
+ - [ ] **Step 3: Update `useMobileGuard.ts` — full replacement**
458
+
459
+ Replace the full file with:
460
+
461
+ ```ts
462
+ import { useMediaQuery } from '@vueuse/core'
463
+ import type { ContextDefinition } from '../app.config'
464
+
465
+ const PICKER_CHOICE_COOKIE = 'innertia_mobile_context_choice'
466
+ const PICKER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 días
467
+
468
+ /**
469
+ * Detección reactiva de viewport mobile + lógica de landing.
470
+ *
471
+ * Reglas para `landing` (qué mostrar cuando el usuario entra a `/` en mobile):
472
+ * - 0 contextos mobile-friendly → blocker "abre en escritorio"
473
+ * - 1 contexto mobile-friendly → redirect directo al login de ese contexto
474
+ * - 2+ contextos mobile-friendly → picker (con cookie para recordar última elección)
475
+ *
476
+ * El breakpoint se lee desde `appConfig.innertia.mobile.breakpoint` (default 1024).
477
+ */
478
+ export function useMobileGuard() {
479
+ const { all, current, accessible } = useContext()
480
+ const authStore = useAuthStore()
481
+ const appConfig = useAppConfig()
482
+
483
+ const breakpoint: number = appConfig.innertia?.mobile?.breakpoint ?? 1024
484
+ const isMobile = useMediaQuery(`(max-width: ${breakpoint - 1}px)`)
485
+
486
+ /** Contextos con mode 'allow' en mobile. */
487
+ const mobileApps = computed<ContextDefinition[]>(() =>
488
+ all.value.filter(a => a.mobile?.mode === 'allow')
489
+ )
490
+
491
+ /** Contextos mobile-friendly + accesibles para el usuario autenticado. */
492
+ const mobileAccessibleApps = computed<ContextDefinition[]>(() =>
493
+ accessible.value.filter(a => a.mobile?.mode === 'allow')
494
+ )
495
+
496
+ /** ¿El contexto actual está bloqueado en mobile? */
497
+ const isCurrentAppBlocked = computed<boolean>(() => {
498
+ if (!isMobile.value) return false
499
+ if (!current.value) return false
500
+ return current.value.mobile?.mode === 'block'
501
+ })
502
+
503
+ /**
504
+ * Para el blocker: si el usuario está autenticado y tiene OTRO contexto mobile-friendly,
505
+ * lo ofrecemos como fallback ("continuar en X").
506
+ */
507
+ const mobileFallbackApp = computed<ContextDefinition | null>(() => {
508
+ if (!authStore.isAuthenticated()) return null
509
+ return mobileAccessibleApps.value[0] ?? null
510
+ })
511
+
512
+ // ── Cookie helpers para recordar última elección del picker ─────────────────
513
+ function rememberPickerChoice(contextKey: string) {
514
+ if (!(appConfig.innertia?.mobile?.rememberChoice ?? true)) return
515
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE, {
516
+ maxAge: PICKER_COOKIE_MAX_AGE,
517
+ sameSite: 'lax',
518
+ })
519
+ cookie.value = contextKey
520
+ }
521
+
522
+ function getRememberedPickerChoice(): string | null {
523
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE)
524
+ return cookie.value ?? null
525
+ }
526
+
527
+ function clearRememberedPickerChoice() {
528
+ const cookie = useCookie<string | null>(PICKER_CHOICE_COOKIE)
529
+ cookie.value = null
530
+ }
531
+
532
+ return {
533
+ isMobile,
534
+ mobileApps,
535
+ mobileAccessibleApps,
536
+ isCurrentAppBlocked,
537
+ mobileFallbackApp,
538
+ rememberPickerChoice,
539
+ getRememberedPickerChoice,
540
+ clearRememberedPickerChoice,
541
+ }
542
+ }
543
+ ```
544
+
545
+ - [ ] **Step 4: Verify no remaining `useApp` or `AppDefinition` references in both files**
546
+
547
+ ```bash
548
+ grep -n "useApp\|AppDefinition\|AppMobileMode" composables/useOrganization.ts composables/useMobileGuard.ts
549
+ ```
550
+ Expected: no output.
551
+
552
+ - [ ] **Step 5: Commit**
553
+
554
+ ```bash
555
+ git add composables/useOrganization.ts composables/useMobileGuard.ts
556
+ git commit -m "refactor(contexts): useOrganization + useMobileGuard — useApp()→useContext(), AppDefinition→ContextDefinition"
557
+ ```
558
+
559
+ ---
560
+
561
+ ### Task 7: Docs — CHANGELOG + final verification
562
+
563
+ **Files:**
564
+ - Create: `CHANGELOG.md`
565
+
566
+ - [ ] **Step 1: Create `CHANGELOG.md`**
567
+
568
+ ```bash
569
+ cat > CHANGELOG.md << 'EOF'
570
+ # Changelog
571
+
572
+ All notable changes to `@innertia-solutions/innertia-nuxt` are documented here.
573
+
574
+ ## [Unreleased]
575
+
576
+ ### BREAKING CHANGES
577
+
578
+ #### apps → contexts rename
579
+
580
+ Aligns frontend with innertia-laravel which already renamed the concept.
581
+
582
+ **Config (`app.config.ts` / `nuxt.config.ts` in your product):**
583
+ ```diff
584
+ - innertia.apps: { backoffice: { ... }, technician: { ... } }
585
+ + innertia.contexts: { backoffice: { ... }, technician: { ... } }
586
+ ```
587
+
588
+ **Types:**
589
+ ```diff
590
+ - import type { AppDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
591
+ + import type { ContextDefinition } from '@innertia-solutions/innertia-nuxt/app.config'
592
+
593
+ - import type { AppMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
594
+ + import type { ContextMobileMode } from '@innertia-solutions/innertia-nuxt/app.config'
595
+ ```
596
+
597
+ **Composables:**
598
+ ```diff
599
+ - const { current, all, accessible, canAccess } = useApp()
600
+ + const { current, all, accessible, canAccess } = useContext()
601
+ # useContext() now also returns: contexts (dict), currentContext, availableContexts,
602
+ # switchContext(), confirmSwitch(), hasAccessToContext()
603
+ ```
604
+
605
+ `useApp()` has been removed. Use `useContext()` for all context/app logic.
606
+
607
+ **Login body field:**
608
+
609
+ The `performLogin()` function in `useAuth` now sends `context` instead of `app` in
610
+ the POST body — matching what innertia-laravel now expects.
611
+ EOF
612
+ ```
613
+
614
+ - [ ] **Step 2: Final grep — verify no stale references remain in the entire library**
615
+
616
+ ```bash
617
+ grep -r "innertia\.apps\|AppDefinition\|AppMobileMode\|useApp()\|'app:'\|\"app:\"" \
618
+ --include="*.ts" --include="*.js" --include="*.vue" \
619
+ --exclude-dir=".nuxt" --exclude-dir="node_modules" \
620
+ .
621
+ ```
622
+ Expected: **no output**. If any matches appear, fix them before committing.
623
+
624
+ - [ ] **Step 3: Commit**
625
+
626
+ ```bash
627
+ git add CHANGELOG.md
628
+ git commit -m "docs(contexts): add CHANGELOG with apps→contexts BREAKING CHANGE"
629
+ ```
630
+ EOF
@@ -0,0 +1,113 @@
1
+ # innertia-nuxt: Rename apps → contexts
2
+
3
+ **Fecha:** 2026-05-27
4
+ **Estado:** Aprobado para implementación
5
+ **Tipo:** Refactor — rename puro, sin cambios de comportamiento
6
+ **Scope:** innertia-nuxt library
7
+
8
+ ## Problema
9
+
10
+ El concepto de "apps" (áreas del sistema a las que un usuario tiene acceso) colisiona semánticamente con "apps" en el contexto de frontend (aplicaciones, secciones de front). Renombrar a "contexts" elimina la ambigüedad y alinea la librería con el rename ya realizado en innertia-laravel (donde `user_apps`, `HasApps`, `appKeys()` etc. ya se renombraron a `user_contexts`, `HasContexts`, `contextKeys()`).
11
+
12
+ El backend ya devuelve `availableContexts` y `currentContext` — el frontend ya usaba esos nombres en el store. Este rename hace que la **capa de configuración y composables** sea consistente con la API que ya consume.
13
+
14
+ ## Alcance
15
+
16
+ Rename puro. **Cero cambios de comportamiento.** Todo lo que hoy funciona, sigue funcionando igual — solo cambian los nombres.
17
+
18
+ **Fuera de scope:**
19
+ - `menuApps` en `app.config.ts` y `backoffice.vue` — semánticamente correcto como "secciones del menú", no relacionado con el sistema de user contexts. Se mantiene intacto.
20
+ - Lógica de organizations, mobile guard, o cualquier otra feature.
21
+
22
+ ## Mapa de renombrado
23
+
24
+ ### `app.config.ts` — config key + tipos
25
+
26
+ | Antes | Después |
27
+ |-------|---------|
28
+ | `innertia.apps: {}` | `innertia.contexts: {}` |
29
+ | `AppDefinition` interface | `ContextDefinition` |
30
+ | `AppMobileMode` type | `ContextMobileMode` |
31
+
32
+ `menuApps` **no se toca**.
33
+
34
+ ### `composables/useContext.js` — fusión de useApp + useContext
35
+
36
+ `useApp.ts` se elimina. `useContext.js` absorbe su lógica y expone la unión completa:
37
+
38
+ ```ts
39
+ const {
40
+ // — absorbido de useApp —
41
+ current, // ContextDefinition | null (contexto según URL actual)
42
+ all, // ContextDefinition[] (todos los declarados en config)
43
+ accessible, // ContextDefinition[] (filtrados por availableContexts del user)
44
+ canAccess, // (key: string) => boolean
45
+ contexts, // Record<string, ContextDefinition> (dict raw del config)
46
+
47
+ // — ya existía en useContext —
48
+ currentContext, // string | null
49
+ availableContexts, // string[]
50
+ switchContext, // (target: string) => Promise<{success, reason?}>
51
+ confirmSwitch, // (target: string) => Promise<{success}>
52
+ hasAccessToContext, // (context: string) => boolean
53
+ } = useContext()
54
+ ```
55
+
56
+ **Regla de resolución de la parte absorbida:** lee de `appConfig.innertia.contexts` (antes `appConfig.innertia.apps`). La lógica interna es idéntica a la de `useApp.ts` actual.
57
+
58
+ ### `composables/useApp.ts`
59
+
60
+ **Eliminado.** Toda su funcionalidad vive en `useContext.js`.
61
+
62
+ ### `composables/useAuth.js`
63
+
64
+ Login POST body:
65
+
66
+ ```js
67
+ // Antes
68
+ api.post(`${context}/auth/login`, { email, password, app: context })
69
+
70
+ // Después
71
+ api.post(`${context}/auth/login`, { email, password, context })
72
+ ```
73
+
74
+ El backend (innertia-laravel) ya espera el campo `context`. Este fix cierra la inconsistencia.
75
+
76
+ ### `middleware/03.apps.global.ts` → `middleware/03.contexts.global.ts`
77
+
78
+ - Renombrar el archivo.
79
+ - Internamente: `apps` variables → `contexts`, `AppDefinition` import → `ContextDefinition`, `appConfig.innertia.apps` → `appConfig.innertia.contexts`.
80
+ - La lógica de los 6 escenarios (ruta pública, login path, sin auth, sin contextos, sin acceso, sync) no cambia.
81
+
82
+ ### `composables/useOrganization.ts`
83
+
84
+ - `useApp()` → `useContext()`
85
+ - Las propiedades que consume (`current`, referenciada internamente como `currentApp`) siguen existiendo con el mismo nombre en el composable fusionado.
86
+
87
+ ### `composables/useMobileGuard.ts`
88
+
89
+ - `useApp()` → `useContext()`
90
+ - Propiedades consumidas: `all`, `current`, `accessible` — todas disponibles en el composable fusionado sin cambios de nombre.
91
+
92
+ ### `CHANGELOG.md`
93
+
94
+ Agregar entrada `[Unreleased]` con sección `### BREAKING CHANGES`.
95
+
96
+ ## Archivos a modificar (8)
97
+
98
+ 1. `app.config.ts` — config key + tipos
99
+ 2. `composables/useContext.js` — absorber useApp + actualizar internals
100
+ 3. `composables/useApp.ts` — **eliminar**
101
+ 4. `composables/useAuth.js` — fix body del login
102
+ 5. `middleware/03.apps.global.ts` → **renombrar** a `03.contexts.global.ts` + editar internals
103
+ 6. `composables/useOrganization.ts` — `useApp()` → `useContext()`
104
+ 7. `composables/useMobileGuard.ts` — `useApp()` → `useContext()`
105
+ 8. `CHANGELOG.md` — BREAKING CHANGE entry
106
+
107
+ ## BREAKING CHANGES para productos consumidores
108
+
109
+ - Config `innertia.contexts` en vez de `innertia.apps`
110
+ - Tipo `ContextDefinition` en vez de `AppDefinition`
111
+ - Tipo `ContextMobileMode` en vez de `AppMobileMode`
112
+ - `useApp()` eliminado → usar `useContext()` (que ahora expone también `current`, `all`, `accessible`, `canAccess`, `contexts`)
113
+ - `performLogin` body: campo `context` en vez de `app`
@@ -1,45 +1,45 @@
1
1
  /**
2
2
  * Middleware global que se ejecuta en cada navegación.
3
3
  *
4
- * Lee la declaración de apps desde `appConfig.innertia.apps`. Si el producto
5
- * no declara apps, este middleware no hace nada (feature inactivo).
4
+ * Lee la declaración de contextos desde `appConfig.innertia.contexts`. Si el
5
+ * producto no declara contextos, este middleware no hace nada (feature inactivo).
6
6
  *
7
- * Cuando hay apps declaradas, resuelve qué "app" (contexto) está intentando
8
- * acceder el usuario según el prefijo de URL y:
9
- * 1. Si la URL no cae en ningún app declarado → pasa (deja seguir)
10
- * 2. Si la URL es la ruta de login del app → pasa (sin chequeo de permisos)
7
+ * Cuando hay contextos declarados, resuelve qué contexto está intentando acceder
8
+ * el usuario según el prefijo de URL y:
9
+ * 1. Si la URL no cae en ningún contexto declarado → pasa (deja seguir)
10
+ * 2. Si la URL es la ruta de login del contexto → pasa (sin chequeo de permisos)
11
11
  * 3. Si el usuario no está autenticado → deja que el middleware `auth` redirija
12
12
  * 4. Si `availableContexts` está vacío (SSR sin hidratar) → intenta fetchMe()
13
13
  * y si falla, pasa sin redirigir (mejor renderizar que redirigir mal)
14
- * 5. Si el usuario está autenticado pero NO tiene acceso al contexto del app →
15
- * redirige al primer app accesible (o a su loginPath si no tiene ninguno)
16
- * 6. Si todo OK pero `currentContext !== app.context` → sincroniza el contexto
17
- * en el authStore y recarga permisos vía fetchMe()
14
+ * 5. Si el usuario está autenticado pero NO tiene acceso al contexto →
15
+ * redirige al primer contexto accesible (o a su loginPath si no tiene ninguno)
16
+ * 6. Si todo OK pero `currentContext !== targetContext.context` → sincroniza el
17
+ * contexto en el authStore y recarga permisos vía fetchMe()
18
18
  *
19
19
  * Esto es lo que permite el "cambio de contexto implícito por URL" sin botón.
20
20
  *
21
21
  * Numeración 03. para correr DESPUÉS de los middlewares saas
22
22
  * (01.detect-subdomain, 02.validate-tenant).
23
23
  */
24
- import type { AppDefinition } from '../app.config'
24
+ import type { ContextDefinition } from '../app.config'
25
25
 
26
26
  export default defineNuxtRouteMiddleware(async (to) => {
27
27
  const appConfig = useAppConfig()
28
- const apps = (appConfig.innertia?.apps ?? {}) as Record<string, AppDefinition>
29
- const list = Object.values(apps)
28
+ const contexts = (appConfig.innertia?.contexts ?? {}) as Record<string, ContextDefinition>
29
+ const list = Object.values(contexts)
30
30
 
31
- // Si el producto no declaró apps, el feature está inactivo
31
+ // Si el producto no declaró contextos, el feature está inactivo
32
32
  if (list.length === 0) return
33
33
 
34
- // ¿La ruta cae bajo algún app?
35
- const targetApp = list.find(app =>
36
- to.path === app.path || to.path.startsWith(app.path + '/')
34
+ // ¿La ruta cae bajo algún contexto?
35
+ const targetContext = list.find(ctx =>
36
+ to.path === ctx.path || to.path.startsWith(ctx.path + '/')
37
37
  )
38
38
 
39
- if (!targetApp) return // ruta pública / fuera de apps
39
+ if (!targetContext) return // ruta pública / fuera de contextos
40
40
 
41
- // La ruta de login del propio app no requiere permisos del contexto
42
- if (to.path === targetApp.loginPath) return
41
+ // La ruta de login del propio contexto no requiere permisos
42
+ if (to.path === targetContext.loginPath) return
43
43
 
44
44
  const authStore = useAuthStore()
45
45
 
@@ -66,9 +66,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
66
66
  // Si tras hidratar seguimos sin contextos, NO redirigimos.
67
67
  if (userContexts.length === 0) return
68
68
 
69
- // ¿Tiene acceso al contexto del app?
70
- if (!userContexts.includes(targetApp.context)) {
71
- const fallback = list.find(app => userContexts.includes(app.context))
69
+ // ¿Tiene acceso al contexto?
70
+ if (!userContexts.includes(targetContext.context)) {
71
+ const fallback = list.find(ctx => userContexts.includes(ctx.context))
72
72
  if (fallback) {
73
73
  return navigateTo(fallback.home, { replace: true })
74
74
  }
@@ -76,8 +76,8 @@ export default defineNuxtRouteMiddleware(async (to) => {
76
76
  }
77
77
 
78
78
  // Sincronizar currentContext silenciosamente si cambia
79
- if (authStore.currentContext !== targetApp.context) {
80
- authStore.setCurrentContext(targetApp.context)
79
+ if (authStore.currentContext !== targetContext.context) {
80
+ authStore.setCurrentContext(targetContext.context)
81
81
  try {
82
82
  const { fetchMe } = useAuth()
83
83
  await fetchMe()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -1,46 +0,0 @@
1
- import type { AppDefinition } from '../app.config'
2
-
3
- /**
4
- * Devuelve metadata del app actual + listas filtradas por permisos del usuario.
5
- * Lee la declaración de apps desde `appConfig.innertia.apps` (configurable por
6
- * el producto en su `nuxt.config.ts`).
7
- *
8
- * Uso típico:
9
- * const { current, accessible } = useApp()
10
- * <h1>Estás en: {{ current?.label }}</h1>
11
- */
12
- export function useApp() {
13
- const route = useRoute()
14
- const authStore = useAuthStore()
15
- const appConfig = useAppConfig()
16
-
17
- /** Diccionario de apps declarados por el producto. */
18
- const apps = computed<Record<string, AppDefinition>>(() =>
19
- (appConfig.innertia?.apps ?? {}) as Record<string, AppDefinition>
20
- )
21
-
22
- /** Todos los apps declarados, en orden de declaración. */
23
- const all = computed<AppDefinition[]>(() => Object.values(apps.value))
24
-
25
- /** App actual, determinado por el prefijo de la ruta. `null` si la URL no cae en ningún app. */
26
- const current = computed<AppDefinition | null>(() => {
27
- return all.value.find(app =>
28
- route.path === app.path || route.path.startsWith(app.path + '/')
29
- ) ?? null
30
- })
31
-
32
- /** Apps a los que el usuario autenticado tiene acceso (filtrado por `availableContexts`). */
33
- const accessible = computed<AppDefinition[]>(() => {
34
- const ctxs = (authStore.availableContexts ?? []) as string[]
35
- return all.value.filter(app => ctxs.includes(app.context))
36
- })
37
-
38
- /** Helper: ¿el usuario puede acceder a este app? */
39
- function canAccess(appKey: string): boolean {
40
- const app = apps.value[appKey]
41
- if (!app) return false
42
- return ((authStore.availableContexts ?? []) as string[]).includes(app.context)
43
- }
44
-
45
- return { current, all, accessible, canAccess, apps }
46
- }