diff --git a/dist/index.html b/dist/index.html
index ee26b9a..01dc784 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -5,7 +5,7 @@
Вечерний детектив
-
+
diff --git a/src/api/__tests__/authRetry.test.ts b/src/api/__tests__/authRetry.test.ts
new file mode 100644
index 0000000..d26d38e
--- /dev/null
+++ b/src/api/__tests__/authRetry.test.ts
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+
+import { retryWithAuth } from '../authRetry.ts'
+
+for (const scenario of [
+ { name: '401 retries after refresh', error: 'unauthorized', refresh: true, attempts: 2 },
+ { name: 'failed refresh does not retry', error: 'unauthorized', refresh: false, attempts: 1 },
+ { name: '403 does not refresh', error: 'permission denied', refresh: true, attempts: 1 },
+ { name: '500 does not refresh', error: 'server error', refresh: true, attempts: 1 },
+ {
+ name: 'network failure does not refresh',
+ error: 'Failed to fetch',
+ refresh: true,
+ attempts: 1,
+ },
+]) {
+ test(scenario.name, async () => {
+ let attempts = 0
+ let refreshes = 0
+ const original = new Error(scenario.error)
+ const result = retryWithAuth(
+ async () => {
+ attempts++
+ if (attempts === 1) throw original
+ return 'ok'
+ },
+ async (failedToken) => {
+ assert.equal(failedToken, 'expired')
+ refreshes++
+ return scenario.refresh
+ },
+ () => 'expired',
+ )
+ if (scenario.attempts === 2) assert.equal(await result, 'ok')
+ else await assert.rejects(result, (err) => err === original)
+ assert.equal(attempts, scenario.attempts)
+ assert.equal(refreshes, scenario.error === 'unauthorized' ? 1 : 0)
+ })
+}
+
+test('second 401 is propagated without another refresh', async () => {
+ let attempts = 0
+ let refreshes = 0
+ await assert.rejects(
+ retryWithAuth(
+ async () => {
+ attempts++
+ throw new Error('unauthorized')
+ },
+ async () => {
+ refreshes++
+ return true
+ },
+ () => 'expired',
+ ),
+ /unauthorized/,
+ )
+ assert.equal(attempts, 2)
+ assert.equal(refreshes, 1)
+})
diff --git a/src/api/authRetry.ts b/src/api/authRetry.ts
new file mode 100644
index 0000000..97bcd5e
--- /dev/null
+++ b/src/api/authRetry.ts
@@ -0,0 +1,14 @@
+export async function retryWithAuth(
+ request: () => Promise,
+ refreshTokens: (failedToken?: string | null) => Promise,
+ getToken: () => string | null,
+): Promise {
+ const token = getToken()
+ try {
+ return await request()
+ } catch (err) {
+ if (!(err instanceof Error) || err.message !== 'unauthorized') throw err
+ if (!(await refreshTokens(token))) throw err
+ return request()
+ }
+}
diff --git a/src/api/auth_client.ts b/src/api/auth_client.ts
index 5dba122..8dbfc63 100644
--- a/src/api/auth_client.ts
+++ b/src/api/auth_client.ts
@@ -1,45 +1,27 @@
import { useAuthStore } from '@/stores/auth'
+import { retryWithAuth } from './authRetry'
import {
createEveningDetectiveServerClient,
type EveningDetectiveServer,
} from './generated/crabs/evening_detective_server'
-import { createHttpHandler } from './generated/crabs/evening_detective_server/client'
+import {
+ createHttpHandler,
+ type ExtraHeadersProvider,
+} from './generated/crabs/evening_detective_server/client'
-export function getAuthClient() {
+export function getAuthClient(getExtraHeaders?: ExtraHeadersProvider) {
const authStore = useAuthStore()
- return buildAuthClient(authStore.refreshTokenAction, authStore.getToken)
+ return buildAuthClient(authStore.refreshTokenAction, authStore.getToken, getExtraHeaders)
}
function buildAuthClient(
- refreshTokens: () => Promise,
+ refreshTokens: (failedToken?: string | null) => Promise,
getToken: () => string | null,
+ getExtraHeaders?: ExtraHeadersProvider,
): EveningDetectiveServer {
- return createEveningDetectiveServerClient(
- createAuthHttpHandler(refreshTokens, createHttpHandler(getToken)),
+ const handler = createHttpHandler(getToken, getExtraHeaders)
+ return createEveningDetectiveServerClient((request) =>
+ retryWithAuth(() => handler(request), refreshTokens, getToken),
)
}
-
-function createAuthHttpHandler(
- refreshTokens: () => Promise,
- handler: (request: { path: string; method: string; body: string | null }) => Promise,
-): (request: { path: string; method: string; body: string | null }) => Promise {
- return async (request: { path: string; method: string; body: string | null }) => {
- let response: unknown
- try {
- response = await handler(request)
- } catch {
- await refreshTokens()
- response = await handler(request)
- }
- // Gateway возвращает gRPC-ошибки как JSON даже при HTTP 403/400/500.
- if (response && typeof response === 'object' && 'code' in response && response.code !== 0) {
- throw new Error(
- 'message' in response && typeof response.message === 'string'
- ? response.message
- : 'Ошибка запроса',
- )
- }
- return response
- }
-}
diff --git a/src/api/binary_client.ts b/src/api/binary_client.ts
index eea09c8..ecf5e5b 100644
--- a/src/api/binary_client.ts
+++ b/src/api/binary_client.ts
@@ -1,5 +1,6 @@
import { useAuthStore } from '@/stores/auth'
+import { retryWithAuth } from './authRetry'
import { getURL } from './generated/crabs/evening_detective_server/client'
export type DownloadFileResult = {
@@ -18,33 +19,23 @@ export function getBinaryClient() {
return buildBinaryClient(authStore.refreshTokenAction, authStore.getToken)
}
-function buildBinaryClient(refreshTokens: () => Promise, getToken: () => string | null) {
+function buildBinaryClient(
+ refreshTokens: (failedToken?: string | null) => Promise,
+ getToken: () => string | null,
+) {
async function request(path: string, init: RequestInit): Promise {
- const headers: Record = {}
- const token = getToken()
- if (token) {
- headers['Authorization'] = `Bearer ${token}`
- }
-
- let response = await fetch(`${getURL()}/${path}`, {
- ...init,
- headers: { ...headers, ...(init.headers as Record) },
- })
-
- // Токен протух — обновляем и повторяем запрос один раз.
- if (response.status == 401) {
- await refreshTokens()
- const newToken = getToken()
- if (newToken) {
- headers['Authorization'] = `Bearer ${newToken}`
- }
- response = await fetch(`${getURL()}/${path}`, {
- ...init,
- headers: { ...headers, ...(init.headers as Record) },
- })
- }
-
- return response
+ return retryWithAuth(
+ async () => {
+ const headers = new Headers(init.headers)
+ const token = getToken()
+ if (token) headers.set('Authorization', `Bearer ${token}`)
+ const response = await fetch(`${getURL()}/${path}`, { ...init, headers })
+ if (response.status === 401) throw new Error('unauthorized')
+ return response
+ },
+ refreshTokens,
+ getToken,
+ )
}
async function download(path: string, fallbackName: string): Promise {
diff --git a/src/components/HeaderMenu.vue b/src/components/HeaderMenu.vue
index d8e1b1e..4b1747b 100644
--- a/src/components/HeaderMenu.vue
+++ b/src/components/HeaderMenu.vue
@@ -2,7 +2,7 @@
import { MenuOutlined } from '@vicons/material'
import { Icon } from '@vicons/utils'
import { useMessage } from 'naive-ui'
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
@@ -19,15 +19,17 @@ const permissions = ref([])
async function getPermissions() {
permissions.value = []
+ if (!authStore.user) return
+ const token = authStore.getToken()
try {
const res = await client.GetPermissions({})
- permissions.value = res.permissions || []
+ if (token === authStore.getToken()) permissions.value = res.permissions || []
} catch (err) {
message.error('Не удалось загрузить права доступа')
console.error('Failed to load permissions:', err)
}
}
-getPermissions()
+watch(() => authStore.user, getPermissions, { immediate: true })
function hasPermission(permission: string): boolean {
return permissions.value.includes(permission)
diff --git a/src/stores/__tests__/auth.test.ts b/src/stores/__tests__/auth.test.ts
new file mode 100644
index 0000000..f6784af
--- /dev/null
+++ b/src/stores/__tests__/auth.test.ts
@@ -0,0 +1,289 @@
+import assert from 'node:assert/strict'
+import { after, beforeEach, test } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+import { createPinia, setActivePinia } from 'pinia'
+import { createServer } from 'vite'
+
+const server = await createServer({
+ configFile: false,
+ root: fileURLToPath(new URL('../../..', import.meta.url)),
+ resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } },
+ server: { middlewareMode: true, hmr: false, ws: false },
+})
+const { useAuthStore } = (await server.ssrLoadModule(
+ '/src/stores/auth.ts',
+)) as typeof import('../auth')
+const { getAuthClient } = (await server.ssrLoadModule(
+ '/src/api/auth_client.ts',
+)) as typeof import('../../api/auth_client')
+const { getBinaryClient } = (await server.ssrLoadModule(
+ '/src/api/binary_client.ts',
+)) as typeof import('../../api/binary_client')
+const originalFetch = globalThis.fetch
+const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
+after(async () => {
+ globalThis.fetch = originalFetch
+ if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator)
+ await server.close()
+})
+
+class MemoryStorage {
+ values = new Map()
+ getItem(key: string) {
+ return this.values.get(key) ?? null
+ }
+ setItem(key: string, value: string) {
+ this.values.set(key, value)
+ }
+ removeItem(key: string) {
+ this.values.delete(key)
+ }
+}
+
+let storage: MemoryStorage
+let listeners: Array<(event: { key: string | null; storageArea: MemoryStorage }) => void>
+let lockTail: Promise
+beforeEach(() => {
+ storage = new MemoryStorage()
+ storage.setItem('accessToken', 'expired')
+ storage.setItem('refreshToken', 'refresh-old')
+ listeners = []
+ lockTail = Promise.resolve()
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage })
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ addEventListener: (_name: string, listener: (typeof listeners)[number]) =>
+ listeners.push(listener),
+ },
+ })
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: {
+ locks: {
+ request: (_name: string, action: () => Promise) => {
+ const result = lockTail.then(action)
+ lockTail = result.catch(() => {})
+ return result
+ },
+ },
+ },
+ })
+ setActivePinia(createPinia())
+})
+
+function storeWithTokens() {
+ const store = useAuthStore()
+ store.accessToken = storage.getItem('accessToken')
+ store.refreshToken = storage.getItem('refreshToken')
+ return store
+}
+
+function json(data: unknown, status = 200) {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+for (const withLocks of [true, false]) {
+ test(`simultaneous JSON and binary 401 share one refresh (locks=${withLocks})`, async () => {
+ if (!withLocks)
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {} })
+ const store = storeWithTokens()
+ let refreshes = 0
+ globalThis.fetch = async (url, init) => {
+ if (String(url).endsWith('/refresh')) {
+ refreshes++
+ assert.equal(new Headers(init?.headers).get('Authorization'), null)
+ assert.equal(JSON.parse(String(init?.body)).refreshToken, 'refresh-old')
+ return json({ accessToken: 'fresh', refreshToken: 'refresh-new' })
+ }
+ if (new Headers(init?.headers).get('Authorization') !== 'Bearer fresh') return json({}, 401)
+ if (String(url).endsWith('/archive')) return new Response('zip')
+ return json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } })
+ }
+ await Promise.all([
+ store.fetchUser(),
+ store.fetchUser(),
+ getBinaryClient().downloadScenarioArchive(1),
+ ])
+ assert.equal(refreshes, 1)
+ assert.equal(store.username, 'Пользователь')
+ assert.equal(store.hasRole('admin'), true)
+ assert.equal(storage.getItem('accessToken'), 'fresh')
+ // A delayed 401 for the previous token must not rotate the new token again.
+ assert.equal(await store.refreshTokenAction('expired'), true)
+ assert.equal(refreshes, 1)
+ })
+}
+
+test('reload restores expired tokens, user and roles', async () => {
+ const store = useAuthStore()
+ const fetched = deferred()
+ globalThis.fetch = async (url, init) => {
+ if (String(url).endsWith('/refresh'))
+ return json({ accessToken: 'fresh', refreshToken: 'refresh-new' })
+ if (new Headers(init?.headers).get('Authorization') !== 'Bearer fresh') return json({}, 401)
+ fetched.resolve()
+ return json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } })
+ }
+ store.initFromStorage()
+ await fetched.promise
+ await store.fetchUser()
+ assert.equal(store.username, 'Пользователь')
+ assert.equal(store.hasRole('admin'), true)
+})
+
+for (const failure of ['401', '500', 'network']) {
+ test(`refresh ${failure} ${failure === '401' ? 'clears' : 'preserves'} session`, async () => {
+ const store = storeWithTokens()
+ globalThis.fetch = async () => {
+ if (failure === 'network') throw new TypeError('Failed to fetch')
+ return json({ code: failure === '401' ? 16 : 13, message: 'server error' }, Number(failure))
+ }
+ if (failure === '401') assert.equal(await store.refreshTokenAction(), false)
+ else await assert.rejects(store.refreshTokenAction())
+ assert.equal(storage.getItem('refreshToken'), failure === '401' ? null : 'refresh-old')
+ assert.equal(store.isAuthenticated, failure !== '401')
+ })
+}
+
+test('logout while refresh is in flight prevents restoring the session', async () => {
+ const store = storeWithTokens()
+ const started = deferred()
+ const response = deferred()
+ globalThis.fetch = async () => {
+ started.resolve()
+ return response.promise
+ }
+ const refreshing = store.refreshTokenAction()
+ await started.promise
+ await store.logout()
+ response.resolve(json({ accessToken: 'fresh', refreshToken: 'refresh-new' }))
+ assert.equal(await refreshing, false)
+ assert.equal(store.isAuthenticated, false)
+ assert.equal(storage.getItem('refreshToken'), null)
+})
+
+test('two tabs serialize refresh and reuse the stored result', async () => {
+ const first = storeWithTokens()
+ setActivePinia(createPinia())
+ const second = storeWithTokens()
+ let refreshes = 0
+ globalThis.fetch = async () => {
+ refreshes++
+ return json({ accessToken: 'fresh', refreshToken: 'refresh-new' })
+ }
+ const results = await Promise.all([first.refreshTokenAction(), second.refreshTokenAction()])
+ assert.deepEqual(results, [true, true])
+ assert.equal(refreshes, 1)
+ assert.equal(second.accessToken, 'fresh')
+ assert.equal(second.refreshToken, 'refresh-new')
+})
+
+test('storage events synchronize tokens and logout', async () => {
+ const store = useAuthStore()
+ globalThis.fetch = async () =>
+ json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } })
+ store.initFromStorage()
+ await store.fetchUser()
+ storage.setItem('refreshToken', 'refresh-new')
+ storage.setItem('accessToken', 'fresh')
+ for (const listener of listeners) listener({ key: 'accessToken', storageArea: storage })
+ await store.fetchUser()
+ assert.equal(store.accessToken, 'fresh')
+ assert.equal(store.username, 'Пользователь')
+ storage.removeItem('accessToken')
+ storage.removeItem('refreshToken')
+ for (const listener of listeners) listener({ key: 'accessToken', storageArea: storage })
+ assert.equal(store.isAuthenticated, false)
+ assert.equal(store.user, null)
+})
+
+test('login omits stale authorization and account deletion retains password on retry', async () => {
+ const store = storeWithTokens()
+ let deletions = 0
+ globalThis.fetch = async (url, init) => {
+ const headers = new Headers(init?.headers)
+ const path = String(url)
+ if (path.endsWith('/login')) {
+ assert.equal(headers.get('Authorization'), null)
+ return json({ accessToken: 'expired', refreshToken: 'refresh-old' })
+ }
+ if (path.endsWith('/refresh'))
+ return json({ accessToken: 'fresh', refreshToken: 'refresh-new' })
+ if (init?.method === 'DELETE') {
+ assert.equal(headers.get('X-Password'), 'password')
+ deletions++
+ return json({}, deletions === 1 ? 401 : 200)
+ }
+ return json({ user: { ID: 1, username: 'Пользователь', roles: [] } })
+ }
+ assert.equal(await store.login('test@example.invalid', 'password'), true)
+ assert.equal(await store.deleteAccount('password'), true)
+ assert.equal(deletions, 2)
+ assert.equal(store.isAuthenticated, false)
+})
+
+for (const status of [403, 500]) {
+ test(`JSON ${status} is not refreshed or retried`, async () => {
+ storeWithTokens()
+ let requests = 0
+ globalThis.fetch = async () => {
+ requests++
+ return json({ code: 7, message: 'denied' }, status)
+ }
+ await assert.rejects(getAuthClient().GetMe({}), /denied/)
+ assert.equal(requests, 1)
+ })
+}
+
+for (const operation of ['login', 'fetchUser']) {
+ test(`late ${operation} response cannot restore the user after logout`, async () => {
+ const store = storeWithTokens()
+ const started = deferred()
+ const response = deferred()
+ globalThis.fetch = async () => {
+ started.resolve()
+ return response.promise
+ }
+ const pending =
+ operation === 'login' ? store.login('test@example.invalid', 'password') : store.fetchUser()
+ await started.promise
+ await store.logout()
+ response.resolve(
+ json({
+ accessToken: 'fresh',
+ refreshToken: 'refresh-new',
+ user: { ID: 1, username: 'Old user', roles: ['admin'] },
+ }),
+ )
+ await pending
+ assert.equal(store.isAuthenticated, false)
+ assert.equal(store.user, null)
+ assert.equal(storage.getItem('accessToken'), null)
+ })
+}
+
+test('successful recovery clears the previous temporary error', async () => {
+ const store = storeWithTokens()
+ globalThis.fetch = async () => {
+ throw new TypeError('Failed to fetch')
+ }
+ await store.fetchUser()
+ assert.equal(store.error, 'Failed to fetch')
+ globalThis.fetch = async () => json({ user: { ID: 1, username: 'Recovered', roles: ['admin'] } })
+ await store.fetchUser()
+ assert.equal(store.error, null)
+ assert.equal(store.username, 'Recovered')
+})
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
index 55c0a45..15ca035 100644
--- a/src/stores/auth.ts
+++ b/src/stores/auth.ts
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
+import { getAuthClient } from '@/api/auth_client'
import { type User } from '@/api/generated/crabs/evening_detective_server'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
@@ -12,6 +13,10 @@ export const useAuthStore = defineStore('auth', () => {
const isLoading = ref(false)
const error = ref(null)
+ let refreshPromise: Promise | null = null
+ let sessionVersion = 0
+ let isStorageListening = false
+
// ===== GETTERS =====
const isAuthenticated = computed(() => !!accessToken.value)
const username = computed(() => user.value?.username || '')
@@ -20,17 +25,49 @@ export const useAuthStore = defineStore('auth', () => {
// ===== ACTIONS =====
// Инициализация из localStorage при загрузке
+ function readTokens() {
+ accessToken.value = localStorage.getItem('accessToken')
+ refreshToken.value = localStorage.getItem('refreshToken')
+ }
+
+ function saveTokens(access: string, refresh: string | undefined) {
+ accessToken.value = access
+ refreshToken.value = refresh || null
+ if (refresh) localStorage.setItem('refreshToken', refresh)
+ else localStorage.removeItem('refreshToken')
+ localStorage.setItem('accessToken', access)
+ }
+
function initFromStorage() {
- const token = localStorage.getItem('accessToken')
- const refresh = localStorage.getItem('refreshToken')
-
- if (token) {
- accessToken.value = token
- refreshToken.value = refresh
-
- // Получаем данные пользователя
- fetchUser()
+ readTokens()
+ if (!isStorageListening) {
+ window.addEventListener('storage', (event) => {
+ if (
+ event.storageArea !== localStorage ||
+ (event.key !== 'accessToken' && event.key !== null)
+ )
+ return
+ const sync = () => {
+ sessionVersion++
+ readTokens()
+ user.value = null
+ error.value = null
+ void fetchUser()
+ }
+ // Дождаться уже начатого обновления; выход обрабатывается сразу.
+ if (
+ refreshPromise &&
+ localStorage.getItem('accessToken') &&
+ localStorage.getItem('refreshToken')
+ ) {
+ void refreshPromise.then(sync, sync)
+ } else {
+ sync()
+ }
+ })
+ isStorageListening = true
}
+ void fetchUser()
}
function getToken(): string | null {
@@ -41,18 +78,17 @@ export const useAuthStore = defineStore('auth', () => {
async function fetchUser() {
if (!accessToken.value) return
+ const version = sessionVersion
try {
- const client = buildClient(getToken)
- const response = await client.GetMe({})
+ const response = await getAuthClient().GetMe({})
- if (response.user) {
+ if (response.user && version === sessionVersion && accessToken.value) {
user.value = response.user
+ error.value = null
}
} catch (err) {
- console.error('Failed to fetch user:', err)
- // Если 401 — токен протух
- if (err instanceof Error && err.message.includes('401')) {
- await logout()
+ if (version === sessionVersion) {
+ error.value = err instanceof Error ? err.message : 'Не удалось восстановить сессию'
}
}
}
@@ -62,20 +98,14 @@ export const useAuthStore = defineStore('auth', () => {
isLoading.value = true
error.value = null
+ const version = ++sessionVersion
try {
- const client = buildClient(getToken)
+ const client = buildClient()
const response = await client.Login({ email, password })
- // Сохраняем токены
- if (response.accessToken) {
- accessToken.value = response.accessToken
- refreshToken.value = response.refreshToken || null
-
- // Сохраняем в localStorage
- localStorage.setItem('accessToken', response.accessToken)
- if (response.refreshToken) {
- localStorage.setItem('refreshToken', response.refreshToken)
- }
+ if (response.accessToken && version === sessionVersion) {
+ user.value = null
+ saveTokens(response.accessToken, response.refreshToken)
// Получаем данные пользователя
await fetchUser()
@@ -93,32 +123,45 @@ export const useAuthStore = defineStore('auth', () => {
}
// Обновление токена
- async function refreshTokenAction() {
- if (!refreshToken.value) return false
-
- try {
- const client = buildClient(getToken)
- const response = await client.Refresh({
- refreshToken: refreshToken.value,
- })
-
- if (!response.accessToken) {
+ function refreshTokenAction(failedToken: string | null = accessToken.value): Promise {
+ if (refreshPromise) return refreshPromise
+ const version = sessionVersion
+ const token = refreshToken.value
+ const refresh = async () => {
+ if (version !== sessionVersion) return false
+ readTokens()
+ if (!accessToken.value || !refreshToken.value) {
+ await logout()
return false
}
-
- // Обновляем токены
- accessToken.value = response.accessToken
- refreshToken.value = response.refreshToken || null
-
- localStorage.setItem('accessToken', response.accessToken)
- if (response.refreshToken) {
- localStorage.setItem('refreshToken', response.refreshToken)
+ if (accessToken.value !== failedToken || refreshToken.value !== token) return true
+ try {
+ const response = await buildClient().Refresh({ refreshToken: token || undefined })
+ if (version !== sessionVersion || localStorage.getItem('refreshToken') !== token)
+ return false
+ if (!response.accessToken || !response.refreshToken) {
+ throw new Error('Сервер не вернул токены сессии')
+ }
+ saveTokens(response.accessToken, response.refreshToken)
+ return true
+ } catch (err) {
+ if (version !== sessionVersion || localStorage.getItem('refreshToken') !== token)
+ return false
+ if (err instanceof Error && err.message === 'unauthorized') {
+ await logout()
+ return false
+ }
+ throw err
}
-
- return true
- } catch {
- return false
}
+ refreshPromise = (
+ navigator.locks
+ ? navigator.locks.request('evening-detective-auth-refresh', refresh)
+ : refresh()
+ ).finally(() => {
+ refreshPromise = null
+ })
+ return refreshPromise
}
// Регистрация
@@ -151,7 +194,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
// Пароль передаётся HTTP-заголовком X-Password, а не в URL или body
- const client = buildClient(getToken, () => ({ 'X-Password': password }))
+ const client = getAuthClient(() => ({ 'X-Password': password }))
await client.DeleteAccount({})
await logout()
@@ -184,13 +227,14 @@ export const useAuthStore = defineStore('auth', () => {
// Выход
async function logout() {
+ sessionVersion++
accessToken.value = null
refreshToken.value = null
user.value = null
error.value = null
- localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
+ localStorage.removeItem('accessToken')
}
// Проверка ролей