This commit is contained in:
2026-06-09 21:24:49 +03:00
parent 6ab0f8c137
commit b3752585ca
6 changed files with 70 additions and 24 deletions

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-D1qd5k5N.js"></script>
<script type="module" crossorigin src="/assets/index-D4qEPrTa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CneFMUxK.css">
</head>
<body>

View File

@@ -2,6 +2,7 @@ import type {
AdminLoginRequest,
AdminLoginResponse,
AdminMeResponse,
AdminRefreshResponse,
CreateOrganizationRequest,
CreateWalletsResponse,
WalletResponse,
@@ -25,6 +26,22 @@ export const adminTokenStore = {
clear: () => { adminToken = null },
}
// The refresh token is body-based (sent in the `/v1/auth/refresh` request body),
// so it must persist across reloads to restore a session. localStorage-backed.
const ADMIN_REFRESH_KEY = 'admin_refresh_token'
export const adminRefreshStore = {
get: (): string | null => {
try { return localStorage.getItem(ADMIN_REFRESH_KEY) } catch { return null }
},
set: (token: string) => {
try { localStorage.setItem(ADMIN_REFRESH_KEY, token) } catch { /* ignore */ }
},
clear: () => {
try { localStorage.removeItem(ADMIN_REFRESH_KEY) } catch { /* ignore */ }
},
}
async function doAdminRequest<T>(
path: string,
options: RequestInit,
@@ -60,17 +77,27 @@ async function doAdminRequest<T>(
return data as T
}
// Refresh by analogy with the main auth service: HttpOnly refresh cookie -> fresh access token.
// Exact path is isolated here — adjust if the backend differs (e.g. /v1/jwt/refresh).
// Body-based refresh: the API expects the refresh token in the request body and
// returns a fresh access + refresh token pair (the refresh token rotates).
export async function refreshAdminToken(): Promise<string> {
const refreshToken = adminRefreshStore.get()
if (!refreshToken) throw new Error('Unauthorized')
const res = await fetch(`${ADMIN_API_URL}/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!res.ok) throw new Error('Unauthorized')
const data = await res.json()
if (!res.ok) {
adminRefreshStore.clear()
adminTokenStore.clear()
throw new Error('Unauthorized')
}
const data = (await res.json()) as AdminRefreshResponse
if (data.access_token) adminTokenStore.set(data.access_token)
return (data.access_token ?? true) as string
if (data.refresh_token) adminRefreshStore.set(data.refresh_token)
return data.access_token
}
export async function adminLogin(payload: AdminLoginRequest): Promise<AdminLoginResponse> {
@@ -80,6 +107,7 @@ export async function adminLogin(payload: AdminLoginRequest): Promise<AdminLogin
false,
)
if (data.access_token) adminTokenStore.set(data.access_token)
if (data.refresh_token) adminRefreshStore.set(data.refresh_token)
return data
}
@@ -92,6 +120,7 @@ export async function adminLogout(): Promise<void> {
await doAdminRequest<unknown>('/v1/auth/logout', { method: 'POST' }, false)
} finally {
adminTokenStore.clear()
adminRefreshStore.clear()
}
}

View File

@@ -1,12 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { refreshAdminToken } from '../api/adminApi'
import { getAdminMe } from '../api/adminApi'
export const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } {
// `getAdminMe` is the real "is the session valid" check. It runs through
// `doAdminRequest` with retry, so a 401 transparently triggers a body-based
// refresh (using the persisted refresh token) and replays the request.
const { data, isLoading, isError } = useQuery({
queryKey: ADMIN_AUTH_QUERY_KEY,
queryFn: refreshAdminToken,
queryFn: getAdminMe,
retry: false,
staleTime: Infinity,
gcTime: Infinity,

View File

@@ -7,9 +7,16 @@ export function useAdminLogin() {
return useMutation({
mutationFn: adminLogin,
onSuccess: (data) => {
// The token is already stored by adminLogin; write it straight into the
// gate's query cache so we flip to "authenticated" without re-hitting /refresh.
queryClient.setQueryData(ADMIN_AUTH_QUERY_KEY, data.access_token)
// Tokens are already stored by adminLogin. Seed the gate's cache with the
// AdminMeResponse-shaped profile (login response carries all these fields)
// so we flip to "authenticated" without an extra /auth/me round-trip.
queryClient.setQueryData(ADMIN_AUTH_QUERY_KEY, {
id: data.id,
login: data.login,
first_name: data.first_name,
last_name: data.last_name,
role: data.role,
})
},
})
}

View File

@@ -5,6 +5,7 @@ export interface AdminLoginRequest {
export interface AdminLoginResponse {
access_token: string
refresh_token: string
token_type: string
id: string
login: string
@@ -13,6 +14,12 @@ export interface AdminLoginResponse {
role: string
}
export interface AdminRefreshResponse {
access_token: string
refresh_token: string
token_type?: string
}
export interface AdminMeResponse {
id: string
login: string