diff --git a/app/components/layout/AppTopBar.vue b/app/components/layout/AppTopBar.vue index a1521dc..42d3062 100644 --- a/app/components/layout/AppTopBar.vue +++ b/app/components/layout/AppTopBar.vue @@ -11,6 +11,7 @@ const emit = defineEmits<{ const route = useRoute() const router = useRouter() +const { data: session, status, signOut } = useAuth() const isHome = computed(() => route.path === '/') const colorMode = useColorMode() const isDark = computed({ @@ -25,10 +26,25 @@ const isDark = computed({ const userMenuOpen = ref(false) const userMenuRoot = ref(null) +const user = computed(() => session.value?.user) +const userEmail = computed(() => user.value?.email || 'user@example.com') +const userName = computed(() => user.value?.name || 'User') +const isAuthenticated = computed(() => status.value === 'authenticated') + function closeUserMenu() { userMenuOpen.value = false } +async function handleLogout() { + try { + userMenuOpen.value = false + await signOut({ callbackUrl: '/login', redirect: true }) + } catch (error) { + console.error('Logout failed:', error) + await navigateTo('/login') + } +} + function onDocClick(e: MouseEvent) { const userEl = userMenuRoot.value if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) { @@ -119,6 +135,8 @@ onUnmounted(() => document.removeEventListener('click', onDocClick)) + + @@ -146,6 +164,10 @@ onUnmounted(() => document.removeEventListener('click', onDocClick)) v-show="userMenuOpen" class="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1 shadow-xl ring-1 ring-black/5" > +
+

{{ userName }}

+

{{ userEmail }}

+
document.removeEventListener('click', onDocClick)) Software settings
-
-

Session (mock)

-

broker@demo.com

-
diff --git a/app/components/layout/OrgSelector.vue b/app/components/layout/OrgSelector.vue new file mode 100644 index 0000000..b5ab154 --- /dev/null +++ b/app/components/layout/OrgSelector.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/app/composables/useOrganizationSelection.ts b/app/composables/useOrganizationSelection.ts new file mode 100644 index 0000000..cd6610f --- /dev/null +++ b/app/composables/useOrganizationSelection.ts @@ -0,0 +1,81 @@ +export interface OrganizationInfo { + orgId: string + orgDomain: string + role: string +} + +function extractSubdomain(domain: string): string { + const parts = domain.split('.') + return parts.length > 1 ? parts[0] : domain +} + + +const STORAGE_KEY = 'policy-ui.selected-org-id' + +export function useOrganizationSelection() { + const { data: session } = useAuth() + + const organizations = computed(() => { + const allOrgRoles = (session.value?.user as any)?.allOrgRoles as Record>> | undefined + if (!allOrgRoles) { + return [] + } + + const result: OrganizationInfo[] = [] + for (const roles of Object.values(allOrgRoles)) { + for (const [role, orgMap] of Object.entries(roles)) { + for (const [orgId, orgDomain] of Object.entries(orgMap)) { + if (!result.find(o => o.orgId === orgId)) { + result.push({ orgId, orgDomain: orgDomain as string, role, orgSubDomain: extractSubdomain(orgDomain) }) + } + } + } + } + result.sort((a, b) => a.orgDomain.localeCompare(b.orgDomain)) + return result + }) + + // All unique org IDs the user has access to + const orgIds = computed(() => organizations.value.map(o => o.orgId)) + + // Persisted selected org + const selectedOrgId = ref(null) + + onMounted(() => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored && orgIds.value.includes(stored)) { + selectedOrgId.value = stored + } else if (organizations.value.length > 0 && !selectedOrgId.value) { + const defaultOrgId = organizations.value[0]!.orgId + selectedOrgId.value = defaultOrgId + localStorage.setItem(STORAGE_KEY, defaultOrgId) + } + }) + + watch(orgIds, (ids) => { + if (ids.length > 0) { + const firstId = ids[0]! + if (!ids.includes(selectedOrgId.value ?? '')) { + selectedOrgId.value = firstId + localStorage.setItem(STORAGE_KEY, firstId) + } + } + }) + + const selectOrg = (orgId: string) => { + selectedOrgId.value = orgId + localStorage.setItem(STORAGE_KEY, orgId) + } + + const selectedOrg = computed(() => { + if (!selectedOrgId.value) return undefined + return organizations.value.find(o => o.orgId === selectedOrgId.value) + }) + + return { + organizations, + selectedOrgId, + selectedOrg, + selectOrg, + } +} diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts new file mode 100644 index 0000000..4e120a3 --- /dev/null +++ b/app/middleware/auth.ts @@ -0,0 +1,7 @@ +export default defineNuxtRouteMiddleware((to) => { + const { status } = useAuth() + + if (status.value === 'unauthenticated' && to.path !== '/login') { + return navigateTo('/login') + } +}) \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index 0484e97..f55cc44 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,5 +1,8 @@ + + \ No newline at end of file diff --git a/app/plugins/open-fetch-auth.ts b/app/plugins/open-fetch-auth.ts new file mode 100644 index 0000000..b2f7ebe --- /dev/null +++ b/app/plugins/open-fetch-auth.ts @@ -0,0 +1,49 @@ +const CLIENTS = ['customer', 'policy', 'providers', 'workload', 'document'] as const + +const ORG_STORAGE_KEY = 'policy-ui.selected-org-id' + +const setAuthHeader = (ctx: { options: { headers?: Headers | Record | undefined } }, token: string) => { + const headers = ctx.options.headers + if (headers instanceof Headers) { + if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) + } else { + const h = (headers ?? {}) as Record + if (!h.Authorization && !h.authorization) { + ctx.options.headers = { ...h, Authorization: `Bearer ${token}` } + } + } +} + +const setOrgHeader = (ctx: { options: { headers?: Headers | Record | undefined } }, orgId: string) => { + const headers = ctx.options.headers + if (headers instanceof Headers) { + if (!headers.has('x-organization-id')) headers.set('x-organization-id', orgId) + } else { + const h = (headers ?? {}) as Record + if (!h['x-organization-id']) { + ctx.options.headers = { ...h, 'x-organization-id': orgId } + } + } +} + +export default defineNuxtPlugin({ + name: 'open-fetch-auth', + setup(nuxtApp) { + for (const client of CLIENTS) { + const hook = `openFetch:onRequest:${client}` as const + nuxtApp.hook(hook, (ctx) => { + const { data } = useAuth() + const token = data.value?.user?.accessToken as string | undefined + if (!token) return + setAuthHeader(ctx, token) + + if (import.meta.client) { + const orgId = localStorage.getItem(ORG_STORAGE_KEY) + if (orgId) { + setOrgHeader(ctx, orgId) + } + } + }) + } + } +}) diff --git a/app/plugins/open-fetch-policy-auth.ts b/app/plugins/open-fetch-policy-auth.ts deleted file mode 100644 index dde1e29..0000000 --- a/app/plugins/open-fetch-policy-auth.ts +++ /dev/null @@ -1,20 +0,0 @@ -export default defineNuxtPlugin({ - name: 'open-fetch-policy-auth', - setup(nuxtApp) { - const { policyApiToken } = useRuntimeConfig().public - const token = typeof policyApiToken === 'string' ? policyApiToken : '' - if (!token) return - - nuxtApp.hook('openFetch:onRequest:policy', (ctx) => { - const headers = ctx.options.headers - if (headers instanceof Headers) { - if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) - } else { - const h = (headers ?? {}) as Record - if (!h.Authorization && !h.authorization) { - ctx.options.headers = { ...h, Authorization: `Bearer ${token}` } - } - } - }) - } -}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 016746d..0381e1e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,23 +1,27 @@ const devApiOrigin = 'https://dev.api.corredorconect.com' + export default defineNuxtConfig({ compatibilityDate: '2026-02-25', - /** Dev server URL — open the same port in the browser (see terminal if the port was busy). */ - modules: ['nuxt-open-fetch', '@nuxt/ui', '@nuxt/eslint'], + modules: ['nuxt-open-fetch', '@nuxt/ui', '@nuxt/eslint', '@zitadel/nuxt-auth'], + auth: { + baseURL: '/api/auth', + provider: { + type: 'authjs', + trustHost: true, + defaultProvider: 'zitadel', + }, + sessionRefresh: { + enablePeriodically: false, + enableOnWindowFocus: false, + }, + }, css: ['~/assets/css/main.css'], ui: { colorMode: false }, - runtimeConfig: { - public: { - /** - * Sent as `Authorization: Bearer …` on Policy API requests (required for protected routes per OpenAPI). - * Set in `.env` as `NUXT_PUBLIC_POLICY_API_TOKEN`. - */ - policyApiToken: process.env.NUXT_PUBLIC_POLICY_API_TOKEN ?? '' - } - }, - openFetch: { + runtimeConfig: {}, + openFetch: { clients: { customer: { baseURL: diff --git a/opencode.json b/opencode.json index 91fe55c..6d3524d 100644 --- a/opencode.json +++ b/opencode.json @@ -14,6 +14,34 @@ "@browsermcp/mcp@0.1.3" ], "enabled": true + }, + "stacklit": { + "type": "local", + "command": [ + "npx", + "stacklit", + "serve" + ], + "enabled": true + } + }, + "provider": { + "corredorconect": { + "npm": "@ai-sdk/openai-compatible", + "name": "Corredor Conect", + "options": { + "baseURL": "https://mcp.corredorconect.com/v1", + "headers": { + "Authorization": "Bearer cc-itsjusfdsawrtwtavfdsfsderysectrwurekey12345" + } + }, + "models": { + "Qwen3.6-27B": { + "name": "Qwen3.6-27B", + "tools": true + } + } + } }, "plugin": [ diff --git a/package.json b/package.json index 2a181d8..8304003 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "postinstall": "nuxt prepare" }, "dependencies": { + "@auth/core": "^0.40.0", "@nuxt/eslint": "^1.15.2", "@nuxt/ui": "^4.5.0", + "@zitadel/nuxt-auth": "^1.0.0", "jspdf": "^4.2.0", "nuxt": "^4.3.1", "nuxt-open-fetch": "^0.13.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0f8eb4..2cdac88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: + '@auth/core': + specifier: ^0.40.0 + version: 0.40.0 '@nuxt/eslint': specifier: ^1.15.2 version: 1.15.2(@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3)) '@nuxt/ui': specifier: ^4.5.0 version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6) + '@zitadel/nuxt-auth': + specifier: ^1.0.0 + version: 1.0.0(@auth/core@0.40.0)(@nuxt/schema@4.4.2)(magicast@0.5.2)(radix3@1.1.2)(vue@3.5.32(typescript@5.9.3)) jspdf: specifier: ^4.2.0 version: 4.2.1 @@ -51,6 +57,20 @@ packages: peerDependencies: '@types/json-schema': ^7.0.15 + '@auth/core@0.40.0': + resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1087,6 +1107,9 @@ packages: '@package-json/types@0.0.12': resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -2151,6 +2174,15 @@ packages: peerDependencies: vue: ^3.5.0 + '@zitadel/nuxt-auth@1.0.0': + resolution: {integrity: sha512-kQpUwjvtkvF20+sAop+VrayJJb51Pdc+l3OQ04ZXlTunrkKQFzO13sSwPU+v1u3UmAwYXAkjUDf5zIzMTtHCNg==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@auth/core': '>=0.40.0' + '@nuxt/schema': ^4.0.0 + radix3: ^1.0.0 + vue: ^3.0.0 + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3334,6 +3366,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -3742,6 +3777,9 @@ packages: engines: {node: '>=18'} hasBin: true + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} @@ -4082,6 +4120,14 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5087,6 +5133,14 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 + '@auth/core@0.40.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.3 + oauth4webapi: 3.8.6 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6346,6 +6400,8 @@ snapshots: '@package-json/types@0.0.12': {} + '@panva/hkdf@1.2.1': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -7327,6 +7383,20 @@ snapshots: dependencies: vue: 3.5.32(typescript@5.9.3) + '@zitadel/nuxt-auth@1.0.0(@auth/core@0.40.0)(@nuxt/schema@4.4.2)(magicast@0.5.2)(radix3@1.1.2)(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@auth/core': 0.40.0 + '@nuxt/kit': 4.4.2(magicast@0.5.2) + '@nuxt/schema': 4.4.2 + consola: 3.4.2 + defu: 6.1.7 + h3: 1.15.11 + radix3: 1.1.2 + ufo: 1.6.3 + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - magicast + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -8519,6 +8589,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + js-levenshtein@1.1.6: {} js-tokens@4.0.0: {} @@ -9110,6 +9182,8 @@ snapshots: pathe: 2.0.3 tinyexec: 1.1.1 + oauth4webapi@3.8.6: {} + object-deep-merge@2.0.0: {} obug@2.1.1: {} @@ -9505,6 +9579,12 @@ snapshots: powershell-utils@0.1.0: {} + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} pretty-bytes@7.1.0: {} diff --git a/server/api/auth/[...].ts b/server/api/auth/[...].ts new file mode 100644 index 0000000..7b68700 --- /dev/null +++ b/server/api/auth/[...].ts @@ -0,0 +1,82 @@ +import { NuxtAuthHandler } from '#auth' +import ZitadelProvider from '@auth/core/providers/zitadel' + +export default NuxtAuthHandler({ + secret: process.env.AUTH_SECRET, + providers: [ + ZitadelProvider({ + clientId: process.env.ZITADEL_CLIENT_ID, + issuer: process.env.ZITADEL_DOMAIN, + authorization: { + params: { + scope: 'openid email profile offline_access urn:zitadel:iam:org:project:371479849505653263:aud' + } + } + }) + ], + session: { + strategy: 'jwt', + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + callbacks: { + async jwt({ token, account, user }) { + if (account?.provider === 'zitadel') { + token.accessToken = account.access_token + token.idToken = account.id_token + token.roles = (user as any)?.roles + + /* Extract org roles from ID token claims */ + const allOrgRoles: Record>> = {} + const idTokenClaims = (() => { + try { + const parts = (account.id_token || '').split('.') + if (parts.length === 3) { + const payload = Buffer.from(parts[1], 'base64url').toString('utf8') + return JSON.parse(payload) as Record + } + } catch { /* ignore */ } + return null + })() + if (idTokenClaims) { + for (const key of Object.keys(idTokenClaims)) { + if (key.startsWith('urn:zitadel:iam:org:project:') && key.endsWith(':roles')) { + allOrgRoles[key] = idTokenClaims[key] + } + } + } + /* Also check userinfo response for org role claims */ + for (const key of Object.keys((user as any) || {})) { + if (key.startsWith('urn:zitadel:iam:org:project:') && key.endsWith(':roles')) { + allOrgRoles[key] = (user as any)[key] + } + } + token.allOrgRoles = Object.keys(allOrgRoles).length > 0 ? allOrgRoles : undefined + } + if (user?.id) { + token.sub = user.id + if (user.name || (user as any).profile?.given_name) { + token.name = user.name || ((user as any).profile?.given_name || '') + } + token.email = user.email || '' + token.image = user.image || undefined + } + return token + }, + async session({ session, token }) { + const user = session.user as any + if (user) { + user.name = token.name || undefined + user.email = token.email || undefined + user.image = token.image || undefined + user.roles = token.roles as string[] | undefined + user.accessToken = token.accessToken as string | undefined + user.allOrgRoles = token.allOrgRoles as Record>> | undefined + } + return session + }, + async redirect({ url, baseUrl }) { + if (url === '/login') return '/login' + return url.startsWith(baseUrl) ? url : baseUrl + } + } +}) diff --git a/types/auth.d.ts b/types/auth.d.ts new file mode 100644 index 0000000..b40676e --- /dev/null +++ b/types/auth.d.ts @@ -0,0 +1,23 @@ +import type { DefaultSession } from '@auth/core/types' + +declare module '@auth/core/types' { + interface Session { + user: { + roles?: string[] + accessToken?: string + } & DefaultSession['user'] + } + + interface User { + roles?: string[] + } +} + +declare module '#auth' { + interface Session { + user: { + roles?: string[] + accessToken?: string + } & DefaultSession['user'] + } +} \ No newline at end of file