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

View File

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

View File

@@ -1,12 +1,15 @@
import { useQuery } from '@tanstack/react-query' 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 const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } { 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({ const { data, isLoading, isError } = useQuery({
queryKey: ADMIN_AUTH_QUERY_KEY, queryKey: ADMIN_AUTH_QUERY_KEY,
queryFn: refreshAdminToken, queryFn: getAdminMe,
retry: false, retry: false,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: Infinity,

View File

@@ -7,9 +7,16 @@ export function useAdminLogin() {
return useMutation({ return useMutation({
mutationFn: adminLogin, mutationFn: adminLogin,
onSuccess: (data) => { onSuccess: (data) => {
// The token is already stored by adminLogin; write it straight into the // Tokens are already stored by adminLogin. Seed the gate's cache with the
// gate's query cache so we flip to "authenticated" without re-hitting /refresh. // AdminMeResponse-shaped profile (login response carries all these fields)
queryClient.setQueryData(ADMIN_AUTH_QUERY_KEY, data.access_token) // 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 { export interface AdminLoginResponse {
access_token: string access_token: string
refresh_token: string
token_type: string token_type: string
id: string id: string
login: string login: string
@@ -13,6 +14,12 @@ export interface AdminLoginResponse {
role: string role: string
} }
export interface AdminRefreshResponse {
access_token: string
refresh_token: string
token_type?: string
}
export interface AdminMeResponse { export interface AdminMeResponse {
id: string id: string
login: string login: string