feat(accounts): доработать управление аккаунтами хостеров
Docker / build (push) Has been cancelled

Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 16:53:51 +07:00
co-authored by Cursor
parent 06f5b27e53
commit c80090ed08
21 changed files with 844 additions and 84 deletions
@@ -0,0 +1,95 @@
import { SearchIcon, XIcon } from 'lucide-react'
import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { SelectField } from '@/components/select-field'
import {
type AccountFiltersState,
buildDefaultAccountFilters,
hasActiveAccountFilters,
} from '@/components/account-filters'
import { billingModeLabel } from '@/lib/format'
import type { Provider } from '@/types/entities'
interface AccountFiltersToolbarProps {
filters: AccountFiltersState
onChange: (next: AccountFiltersState) => void
providers: Provider[]
}
export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) {
const active = hasActiveAccountFilters(filters)
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Поиск по названию или логину"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
</div>
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
...filters,
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
})
}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
{active ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange(buildDefaultAccountFilters())}
>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.syncableOnly}
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
/>
<span>Готовы к синку</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.issuesOnly}
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
/>
<span>С проблемами</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.lowBalanceOnly}
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
/>
<span>Низкий баланс</span>
</label>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import type { ProviderAccount, Provider } from '@/types/entities'
import {
getAccountHealthFlags,
isAccountSyncable,
type AccountHealthContext,
} from '@/lib/account-health'
export interface AccountFiltersState {
search: string
providerIds: string[]
billingMode: '' | 'daily' | 'monthly'
syncableOnly: boolean
issuesOnly: boolean
lowBalanceOnly: boolean
}
export function buildDefaultAccountFilters(): AccountFiltersState {
return {
search: '',
providerIds: [],
billingMode: '',
syncableOnly: false,
issuesOnly: false,
lowBalanceOnly: false,
}
}
export function hasActiveAccountFilters(filters: AccountFiltersState): boolean {
return Boolean(
filters.search.trim() ||
filters.providerIds.length ||
filters.billingMode ||
filters.syncableOnly ||
filters.issuesOnly ||
filters.lowBalanceOnly,
)
}
export function applyAccountFilters(
accounts: ProviderAccount[],
filters: AccountFiltersState,
providers: Provider[],
healthCtx: AccountHealthContext,
): ProviderAccount[] {
const q = filters.search.trim().toLowerCase()
return accounts.filter((account) => {
if (q) {
const hay = `${account.name} ${account.apiLogin ?? ''}`.toLowerCase()
if (!hay.includes(q)) return false
}
if (filters.providerIds.length && !filters.providerIds.includes(account.providerId)) return false
if (filters.billingMode && (account.billingMode ?? 'monthly') !== filters.billingMode) return false
if (filters.syncableOnly && !isAccountSyncable(account, providers)) return false
const flags = getAccountHealthFlags(account, healthCtx)
if (filters.issuesOnly && flags.length === 0) return false
if (filters.lowBalanceOnly && !flags.includes('low-balance')) return false
return true
})
}
@@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query'
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
import { toast } from 'sonner'
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
@@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
name: '',
login: '',
apiCredentials: '',
apiLogin: '',
apiPassword: '',
billingMode: 'monthly',
balanceAlertBelow: '',
notes: '',
@@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({
const providerId = watch('providerId')
const provider = providers.find((p) => p.id === providerId)
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
const creds = watch('apiCredentials')?.trim() ?? ''
const canTest = Boolean(apiBaseUrl && creds)
const apiLogin = watch('apiLogin')?.trim() ?? ''
const apiPassword = watch('apiPassword')?.trim() ?? ''
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && apiCredentials)
return (
<>
@@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Логин" htmlFor="acc-login">
<Input id="acc-login" {...register('login')} />
<FormField label="Логин API" htmlFor="acc-login">
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
</FormField>
<FormField
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
htmlFor="acc-creds"
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
htmlFor="acc-password"
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
>
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
</FormField>
<div className="flex flex-wrap gap-2">
<LoadingButton
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
size="sm"
disabled={!canTest}
loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
>
<PlugIcon data-icon="inline-start" />
Проверить подключение