admin page

This commit is contained in:
2026-06-05 12:46:05 +03:00
parent 6a399ea7ca
commit 0e92966a5d
35 changed files with 4498 additions and 414 deletions

View File

@@ -0,0 +1,103 @@
import type {
AdminLoginRequest,
AdminLoginResponse,
AdminMeResponse,
CreateOrganizationRequest,
Organization,
OrganizationListResponse,
} from '../model/types'
const ADMIN_API_URL = 'https://app.admin.elcsa.ru/api'
// In-memory admin access token — deliberately separate from the user `tokenStore`
// so the two independent auth systems never collide. No CSRF on the admin API.
let adminToken: string | null = null
export const adminTokenStore = {
get: () => adminToken,
set: (token: string) => { adminToken = token },
clear: () => { adminToken = null },
}
async function doAdminRequest<T>(
path: string,
options: RequestInit,
allowRetry: boolean,
): Promise<T> {
const bearer = adminTokenStore.get()
const res = await fetch(`${ADMIN_API_URL}${path}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...options.headers,
},
})
if (res.status === 401 && allowRetry) {
try {
await refreshAdminToken()
return doAdminRequest<T>(path, options, false)
} catch {
adminTokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json().catch(() => null)
if (!res.ok) throw data
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).
export async function refreshAdminToken(): Promise<string> {
const res = await fetch(`${ADMIN_API_URL}/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!res.ok) throw new Error('Unauthorized')
const data = await res.json()
if (data.access_token) adminTokenStore.set(data.access_token)
return (data.access_token ?? true) as string
}
export async function adminLogin(payload: AdminLoginRequest): Promise<AdminLoginResponse> {
const data = await doAdminRequest<AdminLoginResponse>(
'/v1/auth/login',
{ method: 'POST', body: JSON.stringify(payload) },
false,
)
if (data.access_token) adminTokenStore.set(data.access_token)
return data
}
export function getAdminMe(): Promise<AdminMeResponse> {
return doAdminRequest<AdminMeResponse>('/v1/auth/me', {}, true)
}
export async function adminLogout(): Promise<void> {
try {
await doAdminRequest<unknown>('/v1/auth/logout', { method: 'POST' }, false)
} finally {
adminTokenStore.clear()
}
}
export function getOrganizations(limit = 50, offset = 0): Promise<OrganizationListResponse> {
return doAdminRequest<OrganizationListResponse>(
`/v1/organizations?limit=${limit}&offset=${offset}`,
{},
true,
)
}
export function createOrganization(payload: CreateOrganizationRequest): Promise<Organization> {
return doAdminRequest<Organization>(
'/v1/organizations',
{ method: 'POST', body: JSON.stringify(payload) },
true,
)
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { refreshAdminToken } from '../api/adminApi'
export const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } {
const { data, isLoading, isError } = useQuery({
queryKey: ADMIN_AUTH_QUERY_KEY,
queryFn: refreshAdminToken,
retry: false,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
})
return { isAuthenticated: !!data && !isError, isLoading }
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { adminLogin } from '../api/adminApi'
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
export function useAdminLogin() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: adminLogin,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
},
})
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { adminLogout } from '../api/adminApi'
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
export function useAdminLogout() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: adminLogout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
},
})
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createOrganization } from '../api/adminApi'
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
export function useCreateOrganization() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createOrganization,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
},
})
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getOrganizations } from '../api/adminApi'
export const ORGANIZATIONS_QUERY_KEY = ['admin-organizations']
export function useOrganizations() {
return useQuery({
queryKey: ORGANIZATIONS_QUERY_KEY,
queryFn: () => getOrganizations(),
})
}

View File

@@ -0,0 +1,23 @@
export {
adminLogin,
adminLogout,
getAdminMe,
getOrganizations,
createOrganization,
refreshAdminToken,
adminTokenStore,
} from './api/adminApi'
export type {
AdminLoginRequest,
AdminLoginResponse,
AdminMeResponse,
Organization,
OrganizationListResponse,
CreateOrganizationRequest,
BankDetails,
} from './model/types'
export { useAdminAuth, ADMIN_AUTH_QUERY_KEY } from './hooks/useAdminAuth'
export { useAdminLogin } from './hooks/useAdminLogin'
export { useAdminLogout } from './hooks/useAdminLogout'
export { useOrganizations, ORGANIZATIONS_QUERY_KEY } from './hooks/useOrganizations'
export { useCreateOrganization } from './hooks/useCreateOrganization'

View File

@@ -0,0 +1,67 @@
export interface AdminLoginRequest {
login: string
password: string
}
export interface AdminLoginResponse {
access_token: string
token_type: string
id: string
login: string
first_name: string | null
last_name: string | null
role: string
}
export interface AdminMeResponse {
id: string
login: string
first_name: string | null
last_name: string | null
role: string
}
export type BankDetails = Record<string, unknown>
export interface Organization {
id: string
user_id: string
name: string
short_name: string | null
inn: string
ogrn: string | null
kpp: string | null
legal_address: string | null
actual_address: string | null
bank_details: BankDetails | null
contact_person: string | null
contact_phone: string | null
status: string
kyc_verified: boolean
kyc_verified_at: string | null
has_wallets: boolean
created_by: string | null
created_at: string | null
updated_at: string | null
}
export interface OrganizationListResponse {
items: Organization[]
total: number
}
export interface CreateOrganizationRequest {
email: string
password: string
name: string
inn: string
short_name?: string | null
ogrn?: string | null
kpp?: string | null
legal_address?: string | null
actual_address?: string | null
bank_details?: BankDetails | null
contact_person?: string | null
contact_phone?: string | null
status?: string
}