fix(auth): org-scoped team tokens and header search order

This commit is contained in:
Ruslan Bakiev
2026-04-21 14:09:51 +07:00
parent e1e6993f35
commit 39712613ae
8 changed files with 157 additions and 76 deletions

View File

@@ -43,16 +43,36 @@ const LANDING_SEARCH_TOP_STOP = 16
const LANDING_SEARCH_BOTTOM_GAP = 30
const logisticsSearch = reactive({
from: '',
to: '',
cargo: '',
destination: '',
product: '',
quantity: '',
})
function syncSearchFromRoute() {
hydrateFromQuery(route.query)
logisticsSearch.from = typeof route.query.from === 'string' ? route.query.from : isCalcPage.value ? calcDraft.value.from : ''
logisticsSearch.to = typeof route.query.to === 'string' ? route.query.to : isCalcPage.value ? calcDraft.value.to : ''
logisticsSearch.cargo = typeof route.query.cargo === 'string' ? route.query.cargo : isCalcPage.value ? calcDraft.value.cargo : ''
logisticsSearch.destination = typeof route.query.hubName === 'string'
? route.query.hubName
: typeof route.query.to === 'string'
? route.query.to
: isCalcPage.value
? calcDraft.value.to
: ''
logisticsSearch.product = typeof route.query.productName === 'string'
? route.query.productName
: typeof route.query.from === 'string'
? route.query.from
: isCalcPage.value
? calcDraft.value.from
: ''
logisticsSearch.quantity = typeof route.query.qty === 'string'
? route.query.qty
: typeof route.query.quantity === 'string'
? route.query.quantity
: typeof route.query.cargo === 'string'
? route.query.cargo
: isCalcPage.value
? calcDraft.value.cargo
: ''
}
function inferCountryIso(value: string, fallback: string) {
@@ -69,10 +89,8 @@ function isoToFlag(iso: string) {
return String.fromCodePoint(...[...normalized].map(char => 127397 + char.charCodeAt(0)))
}
const fromIso = computed(() => inferCountryIso(logisticsSearch.from, 'CN'))
const toIso = computed(() => inferCountryIso(logisticsSearch.to, 'RU'))
const fromFlag = computed(() => isoToFlag(fromIso.value))
const toFlag = computed(() => isoToFlag(toIso.value))
const destinationIso = computed(() => inferCountryIso(logisticsSearch.destination, 'RU'))
const destinationFlag = computed(() => isoToFlag(destinationIso.value))
const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value)
// Fullscreen menu
const isMenuOpen = ref(false)
@@ -212,53 +230,60 @@ const headerBackdropClass = computed(() => {
return 'header-glass-backdrop--default'
})
function buildHeaderSearchQuery(from: string, to: string, cargo: string) {
function buildHeaderSearchQuery(destination: string, product: string, quantity: string) {
const currentQuery = route.query || {}
const patch = {
from,
to,
cargo,
from: product,
to: destination,
cargo: quantity,
}
const semanticQuery = {
...(product ? { productName: product } : {}),
...(destination ? { hubName: destination } : {}),
...(quantity ? { qty: quantity, quantity } : {}),
}
if (isCalcPage.value) {
return {
...currentQuery,
...buildCalcQuery(patch),
...semanticQuery,
}
}
return {
...currentQuery,
...(from ? { from } : {}),
...(to ? { to } : {}),
...(cargo ? { cargo } : {}),
...(product ? { from: product } : {}),
...(destination ? { to: destination } : {}),
...(quantity ? { cargo: quantity } : {}),
...semanticQuery,
}
}
async function submitHeaderSearch() {
const from = logisticsSearch.from.trim()
const to = logisticsSearch.to.trim()
const cargo = logisticsSearch.cargo.trim()
const destination = logisticsSearch.destination.trim()
const product = logisticsSearch.product.trim()
const quantity = logisticsSearch.quantity.trim()
await navigateToLocalized({
path: '/catalog',
query: buildHeaderSearchQuery(from, to, cargo),
query: buildHeaderSearchQuery(destination, product, quantity),
})
}
type StepRoute = 'from' | 'to' | 'cargo'
type StepRoute = 'destination' | 'product' | 'quantity'
async function openStep(step: StepRoute) {
const from = logisticsSearch.from.trim()
const to = logisticsSearch.to.trim()
const cargo = logisticsSearch.cargo.trim()
const stepPath = step === 'from'
? '/catalog/product'
: step === 'to'
? '/catalog/destination'
const destination = logisticsSearch.destination.trim()
const product = logisticsSearch.product.trim()
const quantity = logisticsSearch.quantity.trim()
const stepPath = step === 'destination'
? '/catalog/destination'
: step === 'product'
? '/catalog/product'
: '/catalog/quantity'
await navigateToLocalized({
path: stepPath,
query: buildHeaderSearchQuery(from, to, cargo),
query: buildHeaderSearchQuery(destination, product, quantity),
})
}
@@ -297,7 +322,7 @@ async function goToSignIn() {
<button
type="button"
class="btn btn-secondary h-10 min-h-0 w-full rounded-full text-sm font-semibold"
@click="openStep('from')"
@click="openStep('destination')"
>
{{ $t('ui.calculate') }}
</button>
@@ -321,43 +346,46 @@ async function goToSignIn() {
<div :class="searchCapsuleClass">
<form class="flex min-w-0 flex-wrap items-center gap-2 rounded-full" @submit.prevent="submitHeaderSearch">
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<span class="shrink-0 text-base leading-none">{{ fromFlag }}</span>
<span class="shrink-0 text-base leading-none">{{ destinationFlag }}</span>
<input
:value="logisticsSearch.from"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.from')"
readonly
@focus.prevent="openStep('from')"
@click.prevent="openStep('from')"
/>
</label>
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<span class="shrink-0 text-base leading-none">{{ toFlag }}</span>
<input
:value="logisticsSearch.to"
:value="logisticsSearch.destination"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.to')"
readonly
@focus.prevent="openStep('to')"
@click.prevent="openStep('to')"
@focus.prevent="openStep('destination')"
@click.prevent="openStep('destination')"
/>
</label>
<label class="search-arch input flex h-11 min-h-0 min-w-[190px] flex-[1.4] items-center gap-3 rounded-full shadow-none">
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<input
:value="logisticsSearch.cargo"
:value="logisticsSearch.product"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.cargo')"
:placeholder="$t('ui.product')"
readonly
@focus.prevent="openStep('cargo')"
@click.prevent="openStep('cargo')"
@focus.prevent="openStep('product')"
@click.prevent="openStep('product')"
/>
</label>
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a2 2 0 1 1 0-4h4a2 2 0 1 1 0 4h-4Zm0 0v2m4-2v2" />
<path d="M6 5h12M6 19h12" />
</svg>
<input
:value="logisticsSearch.quantity"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.quantity')"
readonly
@focus.prevent="openStep('quantity')"
@click.prevent="openStep('quantity')"
/>
</label>
<button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">{{ $t('ui.find') }}</button>

View File

@@ -1,10 +1,13 @@
export const useActiveTeam = () => {
const activeTeamId = useState<string | null>('activeTeamId', () => null)
const activeLogtoOrgId = useState<string | null>('activeLogtoOrgId', () => null)
const logtoOrgState = useState<string | null>('logto-org-id', () => null)
const setActiveTeam = (teamId: string | null, logtoOrgId?: string | null) => {
activeTeamId.value = teamId
activeLogtoOrgId.value = logtoOrgId ?? null
const nextOrgId = logtoOrgId ?? null
activeLogtoOrgId.value = nextOrgId
logtoOrgState.value = nextOrgId
}
return { activeTeamId, activeLogtoOrgId, setActiveTeam }

View File

@@ -15,8 +15,8 @@ export const useAuth = () => {
* Get access token for a resource.
* Tokens are synced from SSR via useState, auto-refreshes if expired.
*/
const getAccessToken = async (resource: string, _organizationId?: string): Promise<string> => {
return getToken(resource as Parameters<typeof getToken>[0])
const getAccessToken = async (resource: string, organizationId?: string): Promise<string> => {
return getToken(resource as Parameters<typeof getToken>[0], organizationId)
}
const getOrganizationToken = getAccessToken

View File

@@ -27,14 +27,19 @@ const EXPIRY_BUFFER_MS = 60 * 1000
*/
export const useLogtoTokens = () => {
const tokens = useState<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({}))
const tokensOrgId = useState<string | null>('logto-tokens-org-id', () => null)
const idToken = useState<string | null>('logto-id-token', () => null)
const activeOrgId = useState<string | null>('activeLogtoOrgId', () => null)
const orgState = useState<string | null>('logto-org-id', () => null)
const isRefreshing = ref(false)
let refreshPromise: Promise<void> | null = null
let refreshPromiseOrgId: string | null = null
/**
* Get organization ID from Logto user (first organization)
*/
const getOrganizationId = (): string | undefined => {
const resolveOrganizationId = (preferred?: string | null): string | undefined => {
if (preferred) return preferred
if (import.meta.server) {
const nuxtApp = useNuxtApp()
const context = nuxtApp.ssrContext?.event.context as {
@@ -43,8 +48,7 @@ export const useLogtoTokens = () => {
} | undefined
return context?.logtoOrgId || context?.logtoUser?.organizations?.[0]
}
const orgId = useState<string | null>('logto-org-id', () => null)
return orgId.value || undefined
return activeOrgId.value || orgState.value || undefined
}
/**
@@ -54,7 +58,7 @@ export const useLogtoTokens = () => {
if (import.meta.server) {
const nuxtApp = useNuxtApp()
const client = nuxtApp.ssrContext?.event.context.logtoClient
const organizationId = getOrganizationId()
const organizationId = resolveOrganizationId()
if (client) {
const results: Partial<Record<ResourceKey, TokenInfo>> = {}
@@ -78,6 +82,7 @@ export const useLogtoTokens = () => {
)
tokens.value = results
tokensOrgId.value = organizationId || null
// Also fetch ID token for SSR
try {
@@ -124,24 +129,36 @@ export const useLogtoTokens = () => {
/**
* Refresh all tokens from server
*/
const refreshTokens = async (): Promise<void> => {
const refreshTokens = async (organizationId?: string | null): Promise<void> => {
const resolvedOrgId = resolveOrganizationId(organizationId) || null
// Deduplicate concurrent refresh calls
if (refreshPromise) {
return refreshPromise
if (refreshPromiseOrgId === resolvedOrgId) {
return refreshPromise
}
await refreshPromise
}
isRefreshing.value = true
refreshPromiseOrgId = resolvedOrgId
refreshPromise = (async () => {
try {
const response = await $fetch<RefreshResponse>('/api/auth/refresh', {
method: 'POST'
method: 'POST',
body: resolvedOrgId ? { organizationId: resolvedOrgId } : undefined
})
tokens.value = response.tokens
tokensOrgId.value = resolvedOrgId
if (resolvedOrgId) {
orgState.value = resolvedOrgId
}
}
finally {
isRefreshing.value = false
refreshPromise = null
refreshPromiseOrgId = null
}
})()
@@ -152,19 +169,21 @@ export const useLogtoTokens = () => {
* Get access token for a resource URL.
* Auto-refreshes if token is expired.
*/
const getToken = async (resourceUrl: ResourceUrl): Promise<string> => {
const getToken = async (resourceUrl: ResourceUrl, organizationId?: string | null): Promise<string> => {
// Find resource key by URL
const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl)
if (!entry) {
throw new Error(`Unknown resource: ${resourceUrl}`)
}
const key = entry[0] as ResourceKey
const resolvedOrgId = resolveOrganizationId(organizationId) || null
const tokenInfo = tokens.value[key]
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
// If expired, refresh all tokens
if (isTokenExpired(tokenInfo)) {
await refreshTokens()
// If expired (or cached for another org), refresh all tokens
if (isOrgMismatch || isTokenExpired(tokenInfo)) {
await refreshTokens(resolvedOrgId)
}
const refreshedToken = tokens.value[key]
@@ -178,11 +197,13 @@ export const useLogtoTokens = () => {
/**
* Get token by resource key (teams, exchange, orders, kyc)
*/
const getTokenByKey = async (key: ResourceKey): Promise<string> => {
const getTokenByKey = async (key: ResourceKey, organizationId?: string | null): Promise<string> => {
const tokenInfo = tokens.value[key]
const resolvedOrgId = resolveOrganizationId(organizationId) || null
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
if (isTokenExpired(tokenInfo)) {
await refreshTokens()
if (isOrgMismatch || isTokenExpired(tokenInfo)) {
await refreshTokens(resolvedOrgId)
}
const refreshedToken = tokens.value[key]

View File

@@ -1,7 +1,8 @@
export default defineNuxtPlugin(() => {
const originalConsoleError = console.error
const originalConsoleWarn = console.warn
console.error = (...args: unknown[]) => {
const shouldSuppressApolloNoise = (args: unknown[]) => {
const serializedArgs = args
.map((arg) => {
if (typeof arg === 'string') return arg
@@ -14,21 +15,34 @@ export default defineNuxtPlugin(() => {
})
.join(' ')
const hasApolloDevtoolsWarning = (
return (
(
serializedArgs.includes('connectToDevTools')
&& serializedArgs.includes('devtools.enabled')
)
|| (
serializedArgs.includes('go.apollo.dev/c/err')
&& serializedArgs.includes('"message":104')
&& (
serializedArgs.includes('"message":104')
|| serializedArgs.includes('%22message%22%3A104')
)
)
)
}
if (hasApolloDevtoolsWarning) {
console.error = (...args: unknown[]) => {
if (shouldSuppressApolloNoise(args)) {
return
}
originalConsoleError(...args)
}
console.warn = (...args: unknown[]) => {
if (shouldSuppressApolloNoise(args)) {
return
}
originalConsoleWarn(...args)
}
})

View File

@@ -9,6 +9,8 @@
"from": "From",
"to": "To",
"cargo": "Cargo",
"product": "What",
"quantity": "How much",
"find": "Find",
"manager_navigation": "Manager navigation",
"orders": "Orders",

View File

@@ -9,6 +9,8 @@
"from": "Откуда",
"to": "Куда",
"cargo": "Груз",
"product": "Что",
"quantity": "Сколько",
"find": "Найти",
"manager_navigation": "Навигация менеджера",
"orders": "Заказы",

View File

@@ -1,4 +1,4 @@
import { defineEventHandler, createError } from 'h3'
import { defineEventHandler, createError, readBody } from 'h3'
import type LogtoClient from '@logto/node'
const RESOURCES = {
@@ -20,6 +20,10 @@ export interface RefreshResponse {
tokens: Partial<Record<ResourceKey, TokenInfo>>
}
interface RefreshBody {
organizationId?: string
}
function decodeTokenExpiry(token: string): number {
try {
const payload = token.split('.')[1]
@@ -44,13 +48,20 @@ function decodeTokenExpiry(token: string): number {
export default defineEventHandler(async (event): Promise<RefreshResponse> => {
const client = event.context.logtoClient as LogtoClient | undefined
const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
let body: RefreshBody = {}
try {
body = (await readBody<RefreshBody>(event)) || {}
}
catch {
body = {}
}
if (!client) {
throw createError({ statusCode: 401, message: 'Not authenticated' })
}
// Get first organization from Logto user
const organizationId = logtoUser?.organizations?.[0]
// Prefer explicit organizationId from client when switching active team.
const organizationId = body.organizationId || logtoUser?.organizations?.[0]
const tokens: Partial<Record<ResourceKey, TokenInfo>> = {}