fix(auth): org-scoped team tokens and header search order
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cargo": "Cargo",
|
||||
"product": "What",
|
||||
"quantity": "How much",
|
||||
"find": "Find",
|
||||
"manager_navigation": "Manager navigation",
|
||||
"orders": "Orders",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"from": "Откуда",
|
||||
"to": "Куда",
|
||||
"cargo": "Груз",
|
||||
"product": "Что",
|
||||
"quantity": "Сколько",
|
||||
"find": "Найти",
|
||||
"manager_navigation": "Навигация менеджера",
|
||||
"orders": "Заказы",
|
||||
|
||||
@@ -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>> = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user