@innertia-solutions/innertia-nuxt 0.1.50 → 0.1.53
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/components/Files/Preview.vue +67 -9
- package/components/Files/ProviderConnectButton.vue +22 -0
- package/composables/useAuth.js +7 -7
- package/composables/useGooglePicker.ts +69 -0
- package/composables/useInnertiaMode.ts +1 -1
- package/composables/useLoginDestinations.js +74 -0
- package/composables/useProviderConnection.ts +39 -0
- package/composables/useTenantSelection.ts +35 -0
- package/package.json +1 -1
- package/plugins/api-tenant.client.ts +5 -5
- package/stores/active-tenant.js +27 -0
- package/composables/useGymSelection.ts +0 -35
- package/stores/gym.js +0 -29
|
@@ -274,30 +274,88 @@ function buildSheet(ws, workbook) {
|
|
|
274
274
|
return { name: ws.name, cols, rows, firstRow: 1, imageMap }
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/** Detecta el delimitador más probable de un texto tabular (tab / ; / ,). */
|
|
278
|
+
function detectDelimiter(text) {
|
|
279
|
+
const line = text.split(/\r?\n/).find(l => l.trim()) ?? ''
|
|
280
|
+
const counts = { '\t': (line.match(/\t/g) || []).length, ';': (line.match(/;/g) || []).length, ',': (line.match(/,/g) || []).length }
|
|
281
|
+
const max = Math.max(counts['\t'], counts[';'], counts[','])
|
|
282
|
+
if (max === 0) return null
|
|
283
|
+
return counts['\t'] === max ? '\t' : (counts[';'] === max ? ';' : ',')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Parser genérico de texto delimitado con soporte de comillas. */
|
|
287
|
+
function parseDelimited(text, delim) {
|
|
288
|
+
const rows = []
|
|
289
|
+
let row = [], cell = '', inQ = false
|
|
290
|
+
for (let i = 0; i < text.length; i++) {
|
|
291
|
+
const c = text[i]
|
|
292
|
+
if (inQ) {
|
|
293
|
+
if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++ } else inQ = false }
|
|
294
|
+
else cell += c
|
|
295
|
+
} else {
|
|
296
|
+
if (c === '"') inQ = true
|
|
297
|
+
else if (c === delim) { row.push(cell); cell = '' }
|
|
298
|
+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = '' }
|
|
299
|
+
else if (c === '\r') continue
|
|
300
|
+
else cell += c
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (cell.length || row.length) { row.push(cell); rows.push(row) }
|
|
304
|
+
return rows
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Construye una sheet (misma forma que buildSheet) desde texto delimitado. */
|
|
308
|
+
function buildSheetFromDelimited(text, name = 'Hoja 1') {
|
|
309
|
+
const delim = detectDelimiter(text) ?? '\t'
|
|
310
|
+
const raw = parseDelimited(text, delim).filter(r => r.length && !(r.length === 1 && r[0] === ''))
|
|
311
|
+
const colCount = raw.reduce((m, r) => Math.max(m, r.length), 0)
|
|
312
|
+
const cols = []
|
|
313
|
+
for (let c = 0; c < colCount; c++) cols.push(colLetter(c))
|
|
314
|
+
const rows = raw.map((r, ri) => {
|
|
315
|
+
const rowData = []
|
|
316
|
+
for (let c = 0; c < colCount; c++) {
|
|
317
|
+
rowData.push({ v: r[c] ?? '', align: 'left', t: 'string', address: colLetter(c) + (ri + 1) })
|
|
318
|
+
}
|
|
319
|
+
return rowData
|
|
320
|
+
})
|
|
321
|
+
return { name, cols, rows, firstRow: 1, imageMap: {} }
|
|
322
|
+
}
|
|
323
|
+
|
|
277
324
|
const loadXlsx = async () => {
|
|
278
325
|
if (viewerType.value !== 'xlsx') return
|
|
279
326
|
isLoadingXlsx.value = true
|
|
280
327
|
xlsxError.value = null
|
|
281
328
|
xlsxActiveIdx.value = 0
|
|
329
|
+
let buf = null
|
|
282
330
|
try {
|
|
331
|
+
const res = await fetch(src.value)
|
|
332
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
333
|
+
buf = await res.arrayBuffer()
|
|
334
|
+
|
|
283
335
|
const mod = await import('exceljs').catch(() => null)
|
|
284
336
|
const ExcelJS = pickModule(mod, 'Workbook')
|
|
285
337
|
?? (mod?.default?.Workbook ? mod.default : null)
|
|
286
338
|
?? mod
|
|
287
|
-
if (!ExcelJS?.Workbook)
|
|
288
|
-
|
|
289
|
-
throw new Error('Para previsualizar .xlsx instalá la dep: pnpm add exceljs')
|
|
290
|
-
}
|
|
291
|
-
const res = await fetch(src.value)
|
|
292
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
293
|
-
const buf = await res.arrayBuffer()
|
|
339
|
+
if (!ExcelJS?.Workbook) throw new Error('exceljs no disponible')
|
|
340
|
+
|
|
294
341
|
const wb = new ExcelJS.Workbook()
|
|
295
342
|
await wb.xlsx.load(buf)
|
|
296
343
|
const sheets = wb.worksheets.map(ws => buildSheet(ws, wb))
|
|
297
344
|
xlsxWorkbook.value = { sheets }
|
|
298
345
|
} catch (e) {
|
|
299
|
-
|
|
300
|
-
|
|
346
|
+
// Fallback: el archivo no es un .xlsx OOXML válido (típico: un CSV/TSV exportado
|
|
347
|
+
// con extensión .xls, o mime mal detectado). Lo renderizamos como tabla delimitada.
|
|
348
|
+
try {
|
|
349
|
+
const text = new TextDecoder('utf-8').decode(buf ?? new ArrayBuffer(0))
|
|
350
|
+
if (text && text.trim()) {
|
|
351
|
+
xlsxWorkbook.value = { sheets: [buildSheetFromDelimited(text)] }
|
|
352
|
+
} else {
|
|
353
|
+
throw e
|
|
354
|
+
}
|
|
355
|
+
} catch (_) {
|
|
356
|
+
console.error('[FilesPreview] xlsx error', e)
|
|
357
|
+
xlsxError.value = 'No se pudo previsualizar la hoja de cálculo.'
|
|
358
|
+
}
|
|
301
359
|
} finally {
|
|
302
360
|
isLoadingXlsx.value = false
|
|
303
361
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
provider: { type: String, default: 'google_drive' },
|
|
4
|
+
label: { type: String, default: 'Google Drive' },
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const { status, connect, disconnect } = useProviderConnection(props.provider)
|
|
8
|
+
const { data, isLoading } = status()
|
|
9
|
+
|
|
10
|
+
const connected = computed(() => data.value?.connected ?? data.value?.data?.connected ?? false)
|
|
11
|
+
const email = computed(() => data.value?.email ?? data.value?.data?.email ?? null)
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="inline-flex items-center gap-2">
|
|
16
|
+
<template v-if="connected">
|
|
17
|
+
<span class="text-sm text-muted-foreground">{{ label }}: <span class="text-foreground font-medium">{{ email }}</span></span>
|
|
18
|
+
<AppButton text="Desconectar" severity="secondary" size="sm" @click="disconnect" />
|
|
19
|
+
</template>
|
|
20
|
+
<AppButton v-else text="Conectar Google Drive" severity="primary" size="sm" :loading="isLoading" @click="connect" />
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
package/composables/useAuth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// useAuthStore, useApi, useOrganizationStore, useInnertiaMode,
|
|
1
|
+
// useAuthStore, useApi, useOrganizationStore, useInnertiaMode, useTenantSelection, useActiveTenantStore auto-imported
|
|
2
2
|
|
|
3
3
|
export function useAuth() {
|
|
4
4
|
const authStore = useAuthStore()
|
|
@@ -21,12 +21,12 @@ export function useAuth() {
|
|
|
21
21
|
queryClient.clear()
|
|
22
22
|
await fetchMe()
|
|
23
23
|
|
|
24
|
-
// En modo 'open' el tenant se resuelve por selección: cargamos los
|
|
24
|
+
// En modo 'open' el tenant se resuelve por selección: cargamos los tenants del
|
|
25
25
|
// usuario y devolvemos un destino (0/1/2+). saas/app conservan su return.
|
|
26
26
|
if (useInnertiaMode().isOpen()) {
|
|
27
|
-
const { resolveAfterLogin } =
|
|
28
|
-
const
|
|
29
|
-
return { ...(data ?? {}),
|
|
27
|
+
const { resolveAfterLogin } = useTenantSelection()
|
|
28
|
+
const tenantDestination = await resolveAfterLogin() // 'onboarding' | 'ready' | 'picker'
|
|
29
|
+
return { ...(data ?? {}), tenantDestination }
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
return data
|
|
@@ -77,8 +77,8 @@ export function useAuth() {
|
|
|
77
77
|
queryClient.clear()
|
|
78
78
|
authStore.logout()
|
|
79
79
|
organizationStore.reset()
|
|
80
|
-
// En open, limpiar también el
|
|
81
|
-
if (useInnertiaMode().isOpen())
|
|
80
|
+
// En open, limpiar también el tenant seleccionado
|
|
81
|
+
if (useInnertiaMode().isOpen()) useActiveTenantStore().reset()
|
|
82
82
|
await navigateTo(loginPath)
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useGooglePicker — carga la Google API JS y abre el Google Picker. Client-only.
|
|
3
|
+
*
|
|
4
|
+
* Uso:
|
|
5
|
+
* const { openPicker } = useGooglePicker()
|
|
6
|
+
* const picked = await openPicker({ apiKey, token, appId, mode: 'file', mimeTypes })
|
|
7
|
+
* // picked === { id, name, mimeType, url } o null si se cancela
|
|
8
|
+
*/
|
|
9
|
+
export function useGooglePicker() {
|
|
10
|
+
let pickerApiLoaded = false
|
|
11
|
+
|
|
12
|
+
function loadScript(src: string): Promise<void> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
if (document.querySelector(`script[src="${src}"]`)) return resolve()
|
|
15
|
+
const s = document.createElement('script')
|
|
16
|
+
s.src = src
|
|
17
|
+
s.onload = () => resolve()
|
|
18
|
+
s.onerror = reject
|
|
19
|
+
document.head.appendChild(s)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function ensureLoaded() {
|
|
24
|
+
if (pickerApiLoaded) return
|
|
25
|
+
await loadScript('https://apis.google.com/js/api.js')
|
|
26
|
+
await new Promise<void>((res) => (window as any).gapi.load('picker', () => res()))
|
|
27
|
+
pickerApiLoaded = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Abre el Google Picker. opts: { apiKey, token, appId, mode: 'file'|'folder', mimeTypes?: string }
|
|
32
|
+
* Resuelve con { id, name, mimeType, url } del item elegido, o null si se cancela.
|
|
33
|
+
*/
|
|
34
|
+
async function openPicker(opts: {
|
|
35
|
+
apiKey: string
|
|
36
|
+
token: string
|
|
37
|
+
appId: string
|
|
38
|
+
mode: 'file' | 'folder'
|
|
39
|
+
mimeTypes?: string
|
|
40
|
+
}) {
|
|
41
|
+
if (typeof window === 'undefined') return null
|
|
42
|
+
await ensureLoaded()
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const google = (window as any).google
|
|
45
|
+
const view = opts.mode === 'folder'
|
|
46
|
+
? new google.picker.DocsView(google.picker.ViewId.FOLDERS).setSelectFolderEnabled(true).setMimeTypes('application/vnd.google-apps.folder')
|
|
47
|
+
: new google.picker.DocsView(google.picker.ViewId.DOCS)
|
|
48
|
+
if (opts.mimeTypes && opts.mode !== 'folder') view.setMimeTypes(opts.mimeTypes)
|
|
49
|
+
|
|
50
|
+
const picker = new google.picker.PickerBuilder()
|
|
51
|
+
.setAppId(opts.appId)
|
|
52
|
+
.setOAuthToken(opts.token)
|
|
53
|
+
.setDeveloperKey(opts.apiKey)
|
|
54
|
+
.addView(view)
|
|
55
|
+
.setCallback((data: any) => {
|
|
56
|
+
if (data.action === google.picker.Action.PICKED) {
|
|
57
|
+
const doc = data.docs[0]
|
|
58
|
+
resolve({ id: doc.id, name: doc.name, mimeType: doc.mimeType, url: doc.url })
|
|
59
|
+
} else if (data.action === google.picker.Action.CANCEL) {
|
|
60
|
+
resolve(null)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.build()
|
|
64
|
+
picker.setVisible(true)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { openPicker }
|
|
69
|
+
}
|
|
@@ -12,7 +12,7 @@ import type { InnertiaMode } from '../app.config'
|
|
|
12
12
|
* isApp() → true si mode === 'app'
|
|
13
13
|
* isOpen() → true si mode === 'open'
|
|
14
14
|
* hasTenant() → true si mode === 'saas' | 'open' (usan multitenancy;
|
|
15
|
-
* saas resuelve por subdominio, open por
|
|
15
|
+
* saas resuelve por subdominio, open por tenant seleccionado)
|
|
16
16
|
*/
|
|
17
17
|
export function useInnertiaMode() {
|
|
18
18
|
const appConfig = useAppConfig()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// useTenantSelection, useActiveTenantStore, useAppConfig auto-imported.
|
|
2
|
+
//
|
|
3
|
+
// Modo 'open' — login personal unificado.
|
|
4
|
+
//
|
|
5
|
+
// Tras autenticar NO conocemos el tenant ni el rol: la fuente de verdad es
|
|
6
|
+
// `auth/my-gyms`, que devuelve tenants:[{key,name,status,contexts}] — un tenant
|
|
7
|
+
// puede otorgar varios contextos (p.ej. backoffice + student = 2 destinos).
|
|
8
|
+
//
|
|
9
|
+
// Expande esos tenants en una lista plana de "destinos" (tenant × contexto) para
|
|
10
|
+
// rutear post-login:
|
|
11
|
+
// 0 destinos -> onboarding
|
|
12
|
+
// 1 destino -> entrar directo (fija tenant activo + navega a home del contexto)
|
|
13
|
+
// 2+ -> selector
|
|
14
|
+
//
|
|
15
|
+
// Los contextos que participan del login personal se declaran por producto en
|
|
16
|
+
// `appConfig.innertia.personalContexts` (p.ej. ['backoffice','student','coach']).
|
|
17
|
+
// Un contexto con login dedicado (p.ej. kiosco 'attendance') NO debe listarse ahí.
|
|
18
|
+
|
|
19
|
+
export function useLoginDestinations() {
|
|
20
|
+
const { load } = useTenantSelection()
|
|
21
|
+
const store = useActiveTenantStore()
|
|
22
|
+
const appConfig = useAppConfig()
|
|
23
|
+
|
|
24
|
+
const personalContexts = () => appConfig.innertia?.personalContexts ?? []
|
|
25
|
+
const contextDefs = () => appConfig.innertia?.contexts ?? {}
|
|
26
|
+
|
|
27
|
+
/** Home de un contexto (según app.config), con fallback razonable. */
|
|
28
|
+
function homeFor(context) {
|
|
29
|
+
const def = Object.values(contextDefs()).find((c) => c.context === context)
|
|
30
|
+
return def?.home || `/${context}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Label legible de un contexto. */
|
|
34
|
+
function labelFor(context) {
|
|
35
|
+
const def = Object.values(contextDefs()).find((c) => c.context === context)
|
|
36
|
+
return def?.label || context
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Carga los tenants del usuario y los expande a destinos tenant×contexto.
|
|
41
|
+
* Devuelve { destinations, tenants }.
|
|
42
|
+
* destination = { tenantKey, tenantName, tenantStatus, context, contextLabel, home }
|
|
43
|
+
*/
|
|
44
|
+
async function resolve() {
|
|
45
|
+
await load() // rellena store.tenants desde auth/my-gyms
|
|
46
|
+
const tenants = store.tenants || []
|
|
47
|
+
|
|
48
|
+
const destinations = []
|
|
49
|
+
for (const tenant of tenants) {
|
|
50
|
+
const ctxs = (tenant.contexts || []).filter((c) => personalContexts().includes(c))
|
|
51
|
+
for (const context of ctxs) {
|
|
52
|
+
destinations.push({
|
|
53
|
+
tenantKey: tenant.key,
|
|
54
|
+
tenantName: tenant.name || tenant.key,
|
|
55
|
+
tenantStatus: tenant.status,
|
|
56
|
+
context,
|
|
57
|
+
contextLabel: labelFor(context),
|
|
58
|
+
home: homeFor(context),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { destinations, tenants }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fija el tenant + contexto activos y navega a la home del contexto elegido. */
|
|
67
|
+
async function go(destination) {
|
|
68
|
+
store.setActive(destination.tenantKey)
|
|
69
|
+
store.setActiveContext(destination.context)
|
|
70
|
+
await navigateTo(destination.home)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { resolve, go, homeFor, labelFor }
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useProviderConnection — estado y flujo OAuth de un proveedor de archivos (ej. Google Drive).
|
|
3
|
+
*
|
|
4
|
+
* Consume los endpoints de conexión del backend innertia-laravel:
|
|
5
|
+
* - GET files/providers/{provider}/status → { connected, email }
|
|
6
|
+
* - GET files/providers/{provider}/connect?scope=user → 302 al consent de Google (popup)
|
|
7
|
+
* - DELETE files/providers/{provider}/connection?scope=user → { connected: false }
|
|
8
|
+
*
|
|
9
|
+
* El popup del consent se cierra solo (el callback devuelve <script>window.close()</script>);
|
|
10
|
+
* al detectar el cierre invalidamos el status para reflejar la conexión.
|
|
11
|
+
*
|
|
12
|
+
* useApi / useQueryClient / useQuery / useRuntimeConfig se auto-importan en la capa.
|
|
13
|
+
*/
|
|
14
|
+
export function useProviderConnection(provider = 'google_drive') {
|
|
15
|
+
const api = useApi()
|
|
16
|
+
const queryClient = useQueryClient()
|
|
17
|
+
|
|
18
|
+
const status = () => useQuery({
|
|
19
|
+
queryKey: ['provider', provider, 'status'],
|
|
20
|
+
queryFn: () => api.get(`files/providers/${provider}/status`),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['provider', provider, 'status'] })
|
|
24
|
+
|
|
25
|
+
const connect = () => {
|
|
26
|
+
if (typeof window === 'undefined') return
|
|
27
|
+
const cfg = useRuntimeConfig()
|
|
28
|
+
const base = (cfg.public.apiBaseUrl || '/api').replace(/\/$/, '')
|
|
29
|
+
const popup = window.open(`${base}/files/providers/${provider}/connect?scope=user`, 'provider-connect', 'width=520,height=680')
|
|
30
|
+
// cuando el popup se cierra (callback hizo window.close), refrescamos el estado
|
|
31
|
+
const timer = setInterval(() => {
|
|
32
|
+
if (!popup || popup.closed) { clearInterval(timer); invalidate() }
|
|
33
|
+
}, 800)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const disconnect = () => api.delete(`files/providers/${provider}/connection?scope=user`).then(invalidate)
|
|
37
|
+
|
|
38
|
+
return { status, connect, disconnect, invalidate }
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// useApi, useActiveTenantStore auto-imported.
|
|
2
|
+
// Selección de tenant en modo 'open': carga los tenants del usuario y resuelve
|
|
3
|
+
// el destino post-login según cuántos tenga (0 / 1 / 2+).
|
|
4
|
+
|
|
5
|
+
export function useTenantSelection() {
|
|
6
|
+
const api = useApi()
|
|
7
|
+
const store = useActiveTenantStore()
|
|
8
|
+
|
|
9
|
+
/** Carga los tenants del usuario autenticado y los guarda en el store. */
|
|
10
|
+
async function load() {
|
|
11
|
+
// El backend aún expone la ruta gym-flavored: { gyms: [{ key, name, status, contexts }] }
|
|
12
|
+
const data = await api.get('auth/my-gyms')
|
|
13
|
+
store.setTenants(data?.gyms || [])
|
|
14
|
+
return store.tenants
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resuelve a dónde llevar al usuario tras el login.
|
|
19
|
+
* 'onboarding' -> 0 tenants (debe crear/unirse a uno)
|
|
20
|
+
* 'ready' -> 1 tenant (auto-seleccionado) o ya tenia uno activo valido
|
|
21
|
+
* 'picker' -> 2+ tenants sin seleccion valida previa
|
|
22
|
+
*/
|
|
23
|
+
async function resolveAfterLogin() {
|
|
24
|
+
await load()
|
|
25
|
+
if (store.tenants.length === 0) return 'onboarding'
|
|
26
|
+
if (store.tenants.length === 1) { store.setActive(store.tenants[0].key); return 'ready' }
|
|
27
|
+
if (store.activeKey && store.tenants.some((t) => t.key === store.activeKey)) return 'ready'
|
|
28
|
+
return 'picker'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cambia el tenant activo (switcher). */
|
|
32
|
+
function switchTo(key) { store.setActive(key) }
|
|
33
|
+
|
|
34
|
+
return { load, resolveAfterLogin, switchTo }
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug, no por UUID.
|
|
3
3
|
// - saas: slug del tenant resuelto por subdominio (tenantStore); solo se envía
|
|
4
4
|
// cuando el tenant está validado (tenantId != null) para evitar el 'local' de dev.
|
|
5
|
-
// - open: key del
|
|
5
|
+
// - open: key del tenant seleccionado in-app (activeTenant.activeKey).
|
|
6
6
|
// useRequestInterceptors auto-imported desde nuxt-core.
|
|
7
|
-
// useTenantStore /
|
|
7
|
+
// useTenantStore / useActiveTenantStore auto-imported desde stores.
|
|
8
8
|
// Activo en mode === 'saas' | 'open'.
|
|
9
9
|
export default defineNuxtPlugin(() => {
|
|
10
10
|
const { isOpen, hasTenant } = useInnertiaMode()
|
|
@@ -15,10 +15,10 @@ export default defineNuxtPlugin(() => {
|
|
|
15
15
|
const { add } = useRequestInterceptors()
|
|
16
16
|
|
|
17
17
|
add((headers: Record<string, string>) => {
|
|
18
|
-
// open: tenant por
|
|
18
|
+
// open: tenant por selección in-app
|
|
19
19
|
if (isOpen()) {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
20
|
+
const activeTenant = useActiveTenantStore()
|
|
21
|
+
if (activeTenant.activeKey) headers['X-Tenant'] = activeTenant.activeKey
|
|
22
22
|
return
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tenant activo en modo 'open'.
|
|
5
|
+
* A diferencia de saas (tenant por subdominio), open resuelve el tenant por
|
|
6
|
+
* selección in-app: el usuario elige entre sus tenants y esa key viaja como X-Tenant.
|
|
7
|
+
* tenants: [{ key, name, status, contexts }]
|
|
8
|
+
*/
|
|
9
|
+
export const useActiveTenantStore = defineStore('activeTenant', {
|
|
10
|
+
state: () => ({ activeKey: null, activeContext: null, tenants: [] }),
|
|
11
|
+
|
|
12
|
+
getters: {
|
|
13
|
+
active: (s) => s.tenants.find((t) => t.key === s.activeKey) || null,
|
|
14
|
+
hasMultiple: (s) => s.tenants.length > 1,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
actions: {
|
|
18
|
+
setTenants(tenants) { this.tenants = tenants || [] },
|
|
19
|
+
setActive(key) { this.activeKey = key },
|
|
20
|
+
setActiveContext(context) { this.activeContext = context },
|
|
21
|
+
reset() { this.activeKey = null; this.activeContext = null; this.tenants = [] },
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
persist: {
|
|
25
|
+
pick: ['activeKey', 'activeContext'],
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// useApi, useGymStore auto-imported.
|
|
2
|
-
// Selección de gym en modo 'open': carga los gyms del usuario y resuelve
|
|
3
|
-
// el destino post-login según cuántos tenga (0 / 1 / 2+).
|
|
4
|
-
|
|
5
|
-
export function useGymSelection() {
|
|
6
|
-
const api = useApi()
|
|
7
|
-
const gym = useGymStore()
|
|
8
|
-
|
|
9
|
-
/** Carga los gyms del usuario autenticado y los guarda en el store. */
|
|
10
|
-
async function load() {
|
|
11
|
-
// Shape esperado: { gyms: [{ key, name, status, contexts }] }
|
|
12
|
-
const data = await api.get('auth/my-gyms')
|
|
13
|
-
gym.setGyms(data?.gyms || [])
|
|
14
|
-
return gym.gyms
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Resuelve a dónde llevar al usuario tras el login.
|
|
19
|
-
* 'onboarding' → 0 gyms (debe crear/unirse a uno)
|
|
20
|
-
* 'ready' → 1 gym (auto-seleccionado) o ya tenía uno activo válido
|
|
21
|
-
* 'picker' → 2+ gyms sin selección válida previa
|
|
22
|
-
*/
|
|
23
|
-
async function resolveAfterLogin() {
|
|
24
|
-
await load()
|
|
25
|
-
if (gym.gyms.length === 0) return 'onboarding'
|
|
26
|
-
if (gym.gyms.length === 1) { gym.setActive(gym.gyms[0].key); return 'ready' }
|
|
27
|
-
if (gym.activeKey && gym.gyms.some((g) => g.key === gym.activeKey)) return 'ready'
|
|
28
|
-
return 'picker'
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Cambia el gym activo (switcher). */
|
|
32
|
-
function switchTo(key) { gym.setActive(key) }
|
|
33
|
-
|
|
34
|
-
return { load, resolveAfterLogin, switchTo }
|
|
35
|
-
}
|
package/stores/gym.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { defineStore } from 'pinia'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Gym activo en modo 'open'.
|
|
5
|
-
* A diferencia de saas (tenant por subdominio), open resuelve el tenant por
|
|
6
|
-
* selección in-app: el usuario elige entre sus gyms y esa key viaja como X-Tenant.
|
|
7
|
-
* gyms: [{ key, name, status, contexts }]
|
|
8
|
-
*/
|
|
9
|
-
export const useGymStore = defineStore('gym', {
|
|
10
|
-
state: () => ({ activeKey: null, activeContext: null, gyms: [] }),
|
|
11
|
-
|
|
12
|
-
getters: {
|
|
13
|
-
active: (s) => s.gyms.find((g) => g.key === s.activeKey) || null,
|
|
14
|
-
hasMultiple: (s) => s.gyms.length > 1,
|
|
15
|
-
},
|
|
16
|
-
|
|
17
|
-
actions: {
|
|
18
|
-
setGyms(gyms) { this.gyms = gyms || [] },
|
|
19
|
-
setActive(key) { this.activeKey = key },
|
|
20
|
-
// Contexto elegido (backoffice/student/…) — un gym puede otorgar varios.
|
|
21
|
-
setActiveContext(context) { this.activeContext = context },
|
|
22
|
-
reset() { this.activeKey = null; this.activeContext = null; this.gyms = [] },
|
|
23
|
-
},
|
|
24
|
-
|
|
25
|
-
persist: {
|
|
26
|
-
// Key + contexto activos sobreviven el refresh; la lista se recarga del backend.
|
|
27
|
-
pick: ['activeKey', 'activeContext'],
|
|
28
|
-
},
|
|
29
|
-
})
|