@innertia-solutions/innertia-nuxt 0.1.1
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/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- package/stores/toast.js +129 -0
package/error.vue
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: false })
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
error: {
|
|
6
|
+
statusCode: number
|
|
7
|
+
statusMessage?: string
|
|
8
|
+
message?: string
|
|
9
|
+
}
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const config = {
|
|
13
|
+
404: {
|
|
14
|
+
title: 'Página no encontrada',
|
|
15
|
+
description: 'La página que buscas no existe o fue movida.',
|
|
16
|
+
icon: '404',
|
|
17
|
+
},
|
|
18
|
+
403: {
|
|
19
|
+
title: 'Acceso denegado',
|
|
20
|
+
description: 'No tienes permisos para ver esta página.',
|
|
21
|
+
icon: '403',
|
|
22
|
+
},
|
|
23
|
+
500: {
|
|
24
|
+
title: 'Error del servidor',
|
|
25
|
+
description: 'Algo salió mal. Intenta nuevamente en unos momentos.',
|
|
26
|
+
icon: '500',
|
|
27
|
+
},
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
type KnownCode = keyof typeof config
|
|
31
|
+
|
|
32
|
+
const current = computed(() => {
|
|
33
|
+
const code = props.error.statusCode as KnownCode
|
|
34
|
+
return config[code] ?? {
|
|
35
|
+
title: 'Error inesperado',
|
|
36
|
+
description: props.error.statusMessage || props.error.message || 'Ocurrió un error desconocido.',
|
|
37
|
+
icon: String(props.error.statusCode),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function goBack() {
|
|
42
|
+
if (window.history.length > 1) {
|
|
43
|
+
window.history.back()
|
|
44
|
+
} else {
|
|
45
|
+
navigateTo('/')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
|
|
52
|
+
<div class="max-w-md w-full text-center space-y-4">
|
|
53
|
+
<div class="text-7xl font-bold text-slate-200 dark:text-slate-700 select-none">
|
|
54
|
+
{{ current.icon }}
|
|
55
|
+
</div>
|
|
56
|
+
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white">
|
|
57
|
+
{{ current.title }}
|
|
58
|
+
</h1>
|
|
59
|
+
<p class="text-slate-500 dark:text-slate-400">
|
|
60
|
+
{{ current.description }}
|
|
61
|
+
</p>
|
|
62
|
+
<div class="flex items-center justify-center gap-3 pt-2">
|
|
63
|
+
<button
|
|
64
|
+
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
65
|
+
@click="goBack"
|
|
66
|
+
>
|
|
67
|
+
Volver
|
|
68
|
+
</button>
|
|
69
|
+
<NuxtLink
|
|
70
|
+
to="/"
|
|
71
|
+
class="px-4 py-2 rounded-lg bg-slate-800 dark:bg-slate-700 text-white text-sm hover:bg-slate-700 dark:hover:bg-slate-600"
|
|
72
|
+
>
|
|
73
|
+
Ir al inicio
|
|
74
|
+
</NuxtLink>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// useTenantStore auto-imported from saas stores.
|
|
2
|
+
// Server-only: lee el hostname de la request para extraer el subdomain del tenant.
|
|
3
|
+
// Solo activo en mode === 'saas'.
|
|
4
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
5
|
+
// Modo no-saas → no hay tenant que detectar
|
|
6
|
+
if (!useInnertiaMode().hasTenant()) return
|
|
7
|
+
|
|
8
|
+
// Evitar loop infinito: si ya estamos en la página de error de tenant, no redirigir de nuevo.
|
|
9
|
+
if (to.path === '/tenant-error') return
|
|
10
|
+
|
|
11
|
+
if (!import.meta.server) return
|
|
12
|
+
|
|
13
|
+
const config = useRuntimeConfig()
|
|
14
|
+
const requestUrl = useRequestURL()
|
|
15
|
+
const hostname = requestUrl.hostname // ej. "acme.app.com" o "localhost"
|
|
16
|
+
|
|
17
|
+
const parts = hostname.split('.')
|
|
18
|
+
|
|
19
|
+
// Hostname bare (localhost, IP) sin subdominio → usar slug "local" en dev, error en prod
|
|
20
|
+
const isBareLocalhost = hostname === 'localhost' || /^\d+(\.\d+){3}$/.test(hostname)
|
|
21
|
+
|
|
22
|
+
if (isBareLocalhost) {
|
|
23
|
+
return navigateTo('/tenant-error?reason=no-subdomain')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Hostname bare sin subdominio (ej. "tudominio.com") → sin tenant
|
|
27
|
+
if (parts.length < 2 || parts[0] === 'www' || /^\d+$/.test(parts[0])) {
|
|
28
|
+
return navigateTo('/tenant-error?reason=no-subdomain')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const subdomain = parts[0]
|
|
32
|
+
|
|
33
|
+
// Reservado: subdomain admin bypasea el flujo de tenant
|
|
34
|
+
if (subdomain === 'admin') {
|
|
35
|
+
useState('isAdminContext', () => false).value = true
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Guardar slug en useState (SSR-safe) y en tenantStore (Pinia)
|
|
40
|
+
useState<string>('tenantSlug', () => '').value = subdomain
|
|
41
|
+
const tenantStore = useTenantStore()
|
|
42
|
+
tenantStore.setSlug(subdomain)
|
|
43
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// useTenantStore auto-imported.
|
|
2
|
+
// Server-only: valida el tenant via GET /ping con X-Tenant header.
|
|
3
|
+
// Usa la URL interna del backend (apiInternalUrl) para evitar pasar por el proxy de Nitro.
|
|
4
|
+
// Solo activo en mode === 'saas'.
|
|
5
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
6
|
+
// Modo no-saas → no hay tenant que validar
|
|
7
|
+
if (!useInnertiaMode().hasTenant()) return
|
|
8
|
+
|
|
9
|
+
if (!import.meta.server) return
|
|
10
|
+
|
|
11
|
+
// Rutas públicas que no requieren tenant válido
|
|
12
|
+
const publicRoutes = ['/tenant-error', '/404']
|
|
13
|
+
const isPublic =
|
|
14
|
+
publicRoutes.some(r => to.path.startsWith(r)) ||
|
|
15
|
+
to.path.startsWith('/auth/')
|
|
16
|
+
|
|
17
|
+
if (isPublic) return
|
|
18
|
+
|
|
19
|
+
// Skip en contexto admin
|
|
20
|
+
if (useState('isAdminContext', () => false).value) return
|
|
21
|
+
|
|
22
|
+
const tenantSlug = useState<string>('tenantSlug', () => '').value
|
|
23
|
+
|
|
24
|
+
console.log(`[tenant:validate] path=${to.path} slug="${tenantSlug}"`)
|
|
25
|
+
|
|
26
|
+
if (!tenantSlug) {
|
|
27
|
+
console.warn('[tenant:validate] sin slug → /tenant-error?reason=no-subdomain')
|
|
28
|
+
return navigateTo('/tenant-error?reason=no-subdomain')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// URL interna del backend (no pasa por el proxy Nitro — no existe en SSR)
|
|
32
|
+
const config = useRuntimeConfig()
|
|
33
|
+
const internalUrl = (config as any).apiInternalUrl || 'http://api:80'
|
|
34
|
+
const pingUrl = `${internalUrl}/ping`
|
|
35
|
+
|
|
36
|
+
console.log(`[tenant:validate] ping → ${pingUrl} (X-Tenant: ${tenantSlug})`)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const data = await $fetch<{ ok: boolean; tenant: { id: number; status: string; config: any } }>(
|
|
40
|
+
pingUrl,
|
|
41
|
+
{
|
|
42
|
+
headers: { 'X-Tenant': tenantSlug, 'Accept': 'application/json' },
|
|
43
|
+
timeout: 5000,
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
console.log(`[tenant:validate] respuesta:`, JSON.stringify(data))
|
|
48
|
+
|
|
49
|
+
if (!data?.ok) {
|
|
50
|
+
console.warn('[tenant:validate] tenant inactivo → /tenant-error?reason=inactive')
|
|
51
|
+
return navigateTo('/tenant-error?reason=inactive')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tenantStore = useTenantStore()
|
|
55
|
+
tenantStore.setTenant(data.tenant.id, data.tenant.config ?? {})
|
|
56
|
+
console.log(`[tenant:validate] OK — tenant id=${data.tenant.id} status=${data.tenant.status}`)
|
|
57
|
+
} catch (e: any) {
|
|
58
|
+
const status = e?.response?.status
|
|
59
|
+
if (status === 404) {
|
|
60
|
+
console.warn(`[tenant:validate] 404 tenant no encontrado → inactive`)
|
|
61
|
+
return navigateTo('/tenant-error?reason=inactive')
|
|
62
|
+
}
|
|
63
|
+
const reason = e?.message?.includes('timeout') ? 'timeout' : 'unreachable'
|
|
64
|
+
console.error(`[tenant:validate] error → ${reason}`, e?.message)
|
|
65
|
+
return navigateTo(`/tenant-error?reason=${reason}`)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware global que se ejecuta en cada navegación.
|
|
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).
|
|
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)
|
|
11
|
+
* 3. Si el usuario no está autenticado → deja que el middleware `auth` redirija
|
|
12
|
+
* 4. Si `availableContexts` está vacío (SSR sin hidratar) → intenta fetchMe()
|
|
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()
|
|
18
|
+
*
|
|
19
|
+
* Esto es lo que permite el "cambio de contexto implícito por URL" sin botón.
|
|
20
|
+
*
|
|
21
|
+
* Numeración 03. para correr DESPUÉS de los middlewares saas
|
|
22
|
+
* (01.detect-subdomain, 02.validate-tenant).
|
|
23
|
+
*/
|
|
24
|
+
import type { AppDefinition } from '../app.config'
|
|
25
|
+
|
|
26
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
27
|
+
const appConfig = useAppConfig()
|
|
28
|
+
const apps = (appConfig.innertia?.apps ?? {}) as Record<string, AppDefinition>
|
|
29
|
+
const list = Object.values(apps)
|
|
30
|
+
|
|
31
|
+
// Si el producto no declaró apps, el feature está inactivo
|
|
32
|
+
if (list.length === 0) return
|
|
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 + '/')
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (!targetApp) return // ruta pública / fuera de apps
|
|
40
|
+
|
|
41
|
+
// La ruta de login del propio app no requiere permisos del contexto
|
|
42
|
+
if (to.path === targetApp.loginPath) return
|
|
43
|
+
|
|
44
|
+
const authStore = useAuthStore()
|
|
45
|
+
|
|
46
|
+
// Si no está autenticado, deja que el middleware `auth` de la página haga el redirect
|
|
47
|
+
if (!authStore.isAuthenticated()) return
|
|
48
|
+
|
|
49
|
+
// ── Hidratación defensiva ─────────────────────────────────────────────────
|
|
50
|
+
// `availableContexts` se persiste en localStorage, que NO se lee en SSR.
|
|
51
|
+
// El plugin auth-init hace fetchMe en SSR pero puede fallar silenciosamente
|
|
52
|
+
// (ej. tenant no resuelto). Si llegamos con token válido pero array vacío,
|
|
53
|
+
// intentamos cargar los contextos antes de decidir el redirect.
|
|
54
|
+
if ((authStore.availableContexts ?? []).length === 0) {
|
|
55
|
+
try {
|
|
56
|
+
const { fetchMe } = useAuth()
|
|
57
|
+
await fetchMe()
|
|
58
|
+
} catch {
|
|
59
|
+
// no podemos resolver — pasamos: mejor renderizar la página que redirigir mal
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const userContexts = (authStore.availableContexts ?? []) as string[]
|
|
65
|
+
|
|
66
|
+
// Si tras hidratar seguimos sin contextos, NO redirigimos.
|
|
67
|
+
if (userContexts.length === 0) return
|
|
68
|
+
|
|
69
|
+
// ¿Tiene acceso al contexto del app?
|
|
70
|
+
if (!userContexts.includes(targetApp.context)) {
|
|
71
|
+
const fallback = list.find(app => userContexts.includes(app.context))
|
|
72
|
+
if (fallback) {
|
|
73
|
+
return navigateTo(fallback.home, { replace: true })
|
|
74
|
+
}
|
|
75
|
+
return navigateTo(list[0]?.loginPath ?? '/', { replace: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sincronizar currentContext silenciosamente si cambia
|
|
79
|
+
if (authStore.currentContext !== targetApp.context) {
|
|
80
|
+
authStore.setCurrentContext(targetApp.context)
|
|
81
|
+
try {
|
|
82
|
+
const { fetchMe } = useAuth()
|
|
83
|
+
await fetchMe()
|
|
84
|
+
} catch {
|
|
85
|
+
// best-effort
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Redirect unauthenticated users to login.
|
|
2
|
+
// useAuthStore auto-imported from nuxt-app stores.
|
|
3
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
if (!authStore.isAuthenticated()) {
|
|
7
|
+
return navigateTo(config.public.loginPath || '/login')
|
|
8
|
+
}
|
|
9
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Redirect already-authenticated users away from guest-only pages (login, register).
|
|
2
|
+
// useAuthStore auto-imported from nuxt-app stores.
|
|
3
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
if (authStore.isAuthenticated()) {
|
|
7
|
+
return navigateTo(config.public.homePath || '/')
|
|
8
|
+
}
|
|
9
|
+
})
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
2
|
+
|
|
3
|
+
// @innertia-solutions/innertia-nuxt — capa base unificada.
|
|
4
|
+
// Provee: core (utilities, pusher, seo) + app (auth, context, vue-query)
|
|
5
|
+
// + saas (multitenancy) + spark (tema visual, components, layouts)
|
|
6
|
+
export default defineNuxtConfig({
|
|
7
|
+
modules: [
|
|
8
|
+
'@nuxtjs/seo',
|
|
9
|
+
'@pinia/nuxt',
|
|
10
|
+
'pinia-plugin-persistedstate/nuxt', // requerido para persistir auth, dockedPreviews, etc.
|
|
11
|
+
],
|
|
12
|
+
css: ['@innertia-solutions/innertia-nuxt/spark.css'],
|
|
13
|
+
components: [
|
|
14
|
+
{ path: './components', pathPrefix: true, prefix: '' },
|
|
15
|
+
],
|
|
16
|
+
imports: {
|
|
17
|
+
dirs: ['stores', 'composables'],
|
|
18
|
+
presets: [
|
|
19
|
+
{
|
|
20
|
+
from: '@tanstack/vue-query',
|
|
21
|
+
imports: ['useQuery', 'useMutation', 'useQueryClient', 'useInfiniteQuery'],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
runtimeConfig: {
|
|
26
|
+
public: {
|
|
27
|
+
loginPath: '/backoffice/login',
|
|
28
|
+
homePath: '/backoffice',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
appConfig: {
|
|
32
|
+
spark: {
|
|
33
|
+
theme: 'default', // default | harvest | retro | ocean | autumn | moon | bubblegum | olive | cashmere
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
vite: {
|
|
37
|
+
plugins: [tailwindcss()],
|
|
38
|
+
optimizeDeps: {
|
|
39
|
+
include: ['pusher-js'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@innertia-solutions/innertia-nuxt",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nuxt",
|
|
7
|
+
"vue",
|
|
8
|
+
"pinia",
|
|
9
|
+
"saas",
|
|
10
|
+
"multitenancy",
|
|
11
|
+
"innertia"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/innertia-solutions/innertia-nuxt"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"main": "./nuxt.config.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./nuxt.config.ts",
|
|
24
|
+
"./spark.css": "./spark.css"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"nuxt": ">=4.0.0",
|
|
28
|
+
"vue": ">=3.5.0",
|
|
29
|
+
"pinia": ">=3.0.0",
|
|
30
|
+
"@pinia/nuxt": ">=0.11.0",
|
|
31
|
+
"pinia-plugin-persistedstate": ">=4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@nuxtjs/seo": "^3.4.0",
|
|
35
|
+
"@pinia/nuxt": "^0.11.3",
|
|
36
|
+
"@tabler/icons-vue": "^3.44.0",
|
|
37
|
+
"@tailwindcss/aspect-ratio": "^0.4.2",
|
|
38
|
+
"@tailwindcss/forms": "^0.5.10",
|
|
39
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
40
|
+
"@tanstack/vue-query": "^5.100.10",
|
|
41
|
+
"@tanstack/vue-table": "^8.21.3",
|
|
42
|
+
"@vueuse/core": "^14.3.0",
|
|
43
|
+
"date-fns": "^4.1.0",
|
|
44
|
+
"date-fns-tz": "^3.2.0",
|
|
45
|
+
"dayjs": "^1.11.0",
|
|
46
|
+
"pinia-plugin-persistedstate": "^4.7.1",
|
|
47
|
+
"preline": "^3.2.3",
|
|
48
|
+
"pusher-js": "^8.5.0",
|
|
49
|
+
"tailwindcss": "^4.0.0",
|
|
50
|
+
"uuid": "^13.0.0",
|
|
51
|
+
"vanilla-calendar-pro": "^3.1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"nuxt": "^4.4.2",
|
|
55
|
+
"vue": "^3.5.0",
|
|
56
|
+
"@pinia/nuxt": "^0.11.3",
|
|
57
|
+
"pinia": "^3.0.4",
|
|
58
|
+
"pinia-plugin-persistedstate": "^4.7.1"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: false })
|
|
3
|
+
|
|
4
|
+
const route = useRoute()
|
|
5
|
+
const reason = computed(() => route.query.reason as string | undefined)
|
|
6
|
+
|
|
7
|
+
const messages = {
|
|
8
|
+
'no-subdomain': {
|
|
9
|
+
title: 'Acceso no válido',
|
|
10
|
+
description: 'No se encontró un espacio de trabajo en esta dirección. Verifica que estés usando la URL correcta.',
|
|
11
|
+
},
|
|
12
|
+
'inactive': {
|
|
13
|
+
title: 'Espacio de trabajo inactivo',
|
|
14
|
+
description: 'Este espacio de trabajo ha sido desactivado. Contacta al administrador.',
|
|
15
|
+
},
|
|
16
|
+
'timeout': {
|
|
17
|
+
title: 'Sin respuesta del servidor',
|
|
18
|
+
description: 'El servidor tardó demasiado en responder. Intenta nuevamente en unos momentos.',
|
|
19
|
+
},
|
|
20
|
+
'unreachable': {
|
|
21
|
+
title: 'Servicio no disponible',
|
|
22
|
+
description: 'No se pudo conectar con el servidor. Verifica tu conexión e intenta nuevamente.',
|
|
23
|
+
},
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
const fallback = { title: 'Error inesperado', description: 'Ocurrió un problema al cargar tu espacio de trabajo.' }
|
|
27
|
+
const current = computed(() => messages[reason.value as keyof typeof messages] ?? fallback)
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
|
|
32
|
+
<div class="max-w-md w-full text-center space-y-4">
|
|
33
|
+
<div class="text-6xl font-bold text-slate-300 dark:text-slate-700 select-none">
|
|
34
|
+
:(
|
|
35
|
+
</div>
|
|
36
|
+
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white">
|
|
37
|
+
{{ current.title }}
|
|
38
|
+
</h1>
|
|
39
|
+
<p class="text-slate-500 dark:text-slate-400">
|
|
40
|
+
{{ current.description }}
|
|
41
|
+
</p>
|
|
42
|
+
<button
|
|
43
|
+
class="mt-4 px-4 py-2 rounded-lg bg-slate-800 text-white text-sm hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
|
44
|
+
@click="$router.go(0)"
|
|
45
|
+
>
|
|
46
|
+
Reintentar
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Registers the Authorization header interceptor into the shared registry.
|
|
2
|
+
// Universal: useCookie reads request cookies in SSR and document.cookie on client.
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const { add } = useRequestInterceptors()
|
|
5
|
+
|
|
6
|
+
add((headers: Record<string, string>, options: Record<string, unknown>) => {
|
|
7
|
+
if (options.useToken !== false) {
|
|
8
|
+
const token = useAuthStore().getToken()
|
|
9
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Registra el interceptor del header X-Tenant.
|
|
2
|
+
// El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug,
|
|
3
|
+
// no por UUID. Solo se envía cuando el tenant está validado (tenantId != null)
|
|
4
|
+
// para evitar enviar el slug 'local' de dev que no existe en la DB.
|
|
5
|
+
// useRequestInterceptors auto-imported desde nuxt-core.
|
|
6
|
+
// useTenantStore auto-imported desde saas stores.
|
|
7
|
+
// Solo activo en mode === 'saas'.
|
|
8
|
+
export default defineNuxtPlugin(() => {
|
|
9
|
+
// Modo no-saas → no inyectar header de tenant
|
|
10
|
+
if (!useInnertiaMode().hasTenant()) return
|
|
11
|
+
|
|
12
|
+
const { add } = useRequestInterceptors()
|
|
13
|
+
const tenantStore = useTenantStore()
|
|
14
|
+
|
|
15
|
+
add((headers: Record<string, string>) => {
|
|
16
|
+
// Solo enviar si el tenant fue validado (tiene id) y tiene slug
|
|
17
|
+
if (tenantStore.tenantId && tenantStore.tenantSlug) {
|
|
18
|
+
headers['X-Tenant'] = tenantStore.tenantSlug
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Reads the hs_theme cookie (set by SwitchColorTheme + applyAppearance) and applies
|
|
2
|
+
// the dark class to <html> during SSR so there's no flash on first paint.
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const cookie = useCookie('hs_theme')
|
|
5
|
+
if (cookie.value === 'dark') {
|
|
6
|
+
useHead({ htmlAttrs: { class: 'dark' } })
|
|
7
|
+
}
|
|
8
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Universal plugin: runs on both server (SSR) and client.
|
|
2
|
+
// On SSR: token is read from cookie → fetches user from API → pinia state is serialized
|
|
3
|
+
// into the Nuxt payload and transferred to the client → no hydration mismatch.
|
|
4
|
+
// Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
|
|
5
|
+
// Universal plugin: runs on both server (SSR) and client.
|
|
6
|
+
// On SSR: token is read from cookie → fetches user from API → pinia state is serialized
|
|
7
|
+
// into the Nuxt payload and transferred to the client → no hydration mismatch.
|
|
8
|
+
// Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
|
|
9
|
+
export default defineNuxtPlugin(async () => {
|
|
10
|
+
const authStore = useAuthStore()
|
|
11
|
+
|
|
12
|
+
// Use getToken() not authStore.token — pinia-plugin-persistedstate may not have
|
|
13
|
+
// hydrated the store yet in SSR, but getToken() reads useCookie() directly.
|
|
14
|
+
if (!authStore.getToken()) return
|
|
15
|
+
|
|
16
|
+
if (!authStore.isAuthenticated()) {
|
|
17
|
+
authStore.logout()
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!authStore.user) {
|
|
22
|
+
try {
|
|
23
|
+
const api = useApi()
|
|
24
|
+
const data = await api.get('auth/me')
|
|
25
|
+
if (data) {
|
|
26
|
+
authStore.saveUser(data.user ?? data)
|
|
27
|
+
authStore.savePermissions(data.permissions ?? [])
|
|
28
|
+
authStore.availableContexts = data.availableContexts ?? []
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
if (import.meta.client) authStore.logout()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export default defineNuxtPlugin(() => {
|
|
2
|
+
if (!process.client) return
|
|
3
|
+
|
|
4
|
+
const isDark = useState<boolean>('isDark', () => false)
|
|
5
|
+
|
|
6
|
+
const updateTheme = () => {
|
|
7
|
+
if (typeof document !== 'undefined') {
|
|
8
|
+
isDark.value = document.documentElement.classList.contains('dark')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let observer: MutationObserver | null = null
|
|
13
|
+
|
|
14
|
+
const initializeTheme = () => {
|
|
15
|
+
updateTheme()
|
|
16
|
+
if (observer) observer.disconnect()
|
|
17
|
+
observer = new MutationObserver(() => updateTheme())
|
|
18
|
+
observer.observe(document.documentElement, {
|
|
19
|
+
attributes: true,
|
|
20
|
+
attributeFilter: ['class'],
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (document.readyState === 'loading') {
|
|
25
|
+
document.addEventListener('DOMContentLoaded', initializeTheme)
|
|
26
|
+
} else {
|
|
27
|
+
nextTick(initializeTheme)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sincroniza el store de previews minimizados entre pestañas del navegador.
|
|
3
|
+
* Escucha los eventos `storage` que dispara localStorage cuando otra pestaña escribe.
|
|
4
|
+
*/
|
|
5
|
+
export default defineNuxtPlugin(() => {
|
|
6
|
+
const store = useDockedPreviewsStore()
|
|
7
|
+
|
|
8
|
+
window.addEventListener('storage', (event) => {
|
|
9
|
+
if (event.key !== 'docked-previews' || !event.newValue) return
|
|
10
|
+
try {
|
|
11
|
+
const persisted = JSON.parse(event.newValue)
|
|
12
|
+
store.hydrate(persisted.items ?? [])
|
|
13
|
+
} catch {
|
|
14
|
+
// JSON inválido — ignorar
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
HSStaticMethods?: { autoInit?: () => void }
|
|
4
|
+
HSSelect?: new (el: HTMLElement) => { destroy?: () => void }
|
|
5
|
+
HSThemeAppearance?: { init?: () => void }
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default defineNuxtPlugin(async () => {
|
|
10
|
+
if (!process.client) return
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await import('preline')
|
|
14
|
+
|
|
15
|
+
const initPreline = () => {
|
|
16
|
+
try { window.HSStaticMethods?.autoInit?.() } catch (_) {}
|
|
17
|
+
try { window.HSThemeAppearance?.init?.() } catch (_) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const performMultipleInits = () => {
|
|
21
|
+
initPreline()
|
|
22
|
+
setTimeout(initPreline, 50)
|
|
23
|
+
setTimeout(initPreline, 200)
|
|
24
|
+
setTimeout(initPreline, 500)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (document.readyState === 'loading') {
|
|
28
|
+
document.addEventListener('DOMContentLoaded', performMultipleInits)
|
|
29
|
+
} else {
|
|
30
|
+
nextTick(performMultipleInits)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const nuxtApp = useNuxtApp()
|
|
34
|
+
nuxtApp.hooks.hookOnce('app:mounted', () => performMultipleInits())
|
|
35
|
+
nuxtApp.hooks.hook('page:finish', () => requestAnimationFrame(performMultipleInits))
|
|
36
|
+
|
|
37
|
+
const observer = new MutationObserver((mutations) => {
|
|
38
|
+
const hasPreline = mutations.some(({ addedNodes }) =>
|
|
39
|
+
Array.from(addedNodes).some((node) => {
|
|
40
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
|
41
|
+
const el = node as Element
|
|
42
|
+
return (
|
|
43
|
+
el.querySelector?.('[data-hs-overlay],[data-hs-dropdown],[data-hs-select]') ||
|
|
44
|
+
el.hasAttribute?.('data-hs-overlay') ||
|
|
45
|
+
el.hasAttribute?.('data-hs-dropdown') ||
|
|
46
|
+
el.hasAttribute?.('data-hs-select') ||
|
|
47
|
+
(typeof el.className === 'string' && el.className.includes('hs-'))
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
if (hasPreline) {
|
|
52
|
+
setTimeout(initPreline, 10)
|
|
53
|
+
setTimeout(initPreline, 100)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (document.readyState === 'loading') {
|
|
58
|
+
document.addEventListener('DOMContentLoaded', () =>
|
|
59
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
60
|
+
)
|
|
61
|
+
} else {
|
|
62
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.warn('[nuxt-core] Error al cargar Preline:', e)
|
|
67
|
+
}
|
|
68
|
+
})
|