generated from VLADIMIR/template_frontend
fix
This commit is contained in:
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Вечерний детектив</title>
|
||||
<script type="module" crossorigin src="/assets/index-D4Ouwe08.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CHmzhHGh.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-BfLkiLXr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/light-CQqBJDIF.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/light-BrwQlAEt.js">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function retryWithAuth<T>(
|
||||
request: () => Promise<T>,
|
||||
refreshTokens: (failedToken?: string | null) => Promise<boolean>,
|
||||
getToken: () => string | null,
|
||||
): Promise<T> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+12
-30
@@ -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<boolean>,
|
||||
refreshTokens: (failedToken?: string | null) => Promise<boolean>,
|
||||
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<boolean>,
|
||||
handler: (request: { path: string; method: string; body: string | null }) => Promise<unknown>,
|
||||
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+17
-26
@@ -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<boolean>, getToken: () => string | null) {
|
||||
function buildBinaryClient(
|
||||
refreshTokens: (failedToken?: string | null) => Promise<boolean>,
|
||||
getToken: () => string | null,
|
||||
) {
|
||||
async function request(path: string, init: RequestInit): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${getURL()}/${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||
})
|
||||
|
||||
// Токен протух — обновляем и повторяем запрос один раз.
|
||||
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<string, string>) },
|
||||
})
|
||||
}
|
||||
|
||||
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<DownloadFileResult> {
|
||||
|
||||
@@ -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<string[]>([])
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<unknown>
|
||||
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<boolean>) => {
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((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<void>()
|
||||
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<void>()
|
||||
const response = deferred<Response>()
|
||||
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<void>()
|
||||
const response = deferred<Response>()
|
||||
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')
|
||||
})
|
||||
+95
-51
@@ -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<string | null>(null)
|
||||
|
||||
let refreshPromise: Promise<boolean> | 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<boolean> {
|
||||
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')
|
||||
}
|
||||
|
||||
// Проверка ролей
|
||||
|
||||
Reference in New Issue
Block a user