/** * Разбор структурированного отказа API. * * Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер * отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что * панель могла сделать, — показать это слово тостом. Оператор, оставивший поле * секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и * не имел ни одного способа узнать причину. * * Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель * выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным * вариантом для кода, которого она ещё не знает, и ответом для клиента без UI. */ /** Числовые коды ответа; синхронизировано с model/constant/code.go. */ export const API_CODE = { success: 20000, systemError: 50000, validationFailed: 50001, unauthorized: 50401, forbidden: 50403, } as const; /** * Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go. * * Перечислены только те, на которые панель реагирует по-разному. Остальные * доезжают до оператора сообщением сервера. */ export const ERR_CODE = { bodyInvalid: "body_invalid", validationFailed: "validation_failed", required: "required", min: "min", max: "max", // Границы числа и границы длины строки различаются кодом, хотя тег // валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» — // разные фразы для оператора. minLength: "min_length", maxLength: "max_length", len: "len", oneOf: "oneof", greaterThan: "gt", ruleViolated: "rule_violated", peerName: "peer_name", credentialFormat: "credential_format", // Правило пароля администратора целиком: длина в символах, длина в байтах // (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому, // что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у // пароля, укладывающегося в границу символов. adminPasswordFormat: "admin_password_format", peerNameTaken: "peer_name_taken", peerNameReserved: "peer_name_reserved", peerBootstrapLocked: "peer_bootstrap_identity_locked", // ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых // подключений записан, активную сессию завершить не удалось. Панель // показывает его предупреждением и обновляет строку — состояние в базе уже // изменилось. peerDisconnectFailed: "peer_disconnect_failed", invalidCredentials: "invalid_credentials", importFileExtension: "import_file_extension", unauthorized: "unauthorized", sessionExpired: "session_expired", tokenInvalid: "token_invalid", accountDisabled: "account_disabled", } as const; export interface ApiFieldError { code: string; field?: string; message: string; params?: Record; } export interface ApiErrorPayload { code: number; message?: string; errors?: ApiFieldError[]; } /** Отказ API как исключение, сохраняющее машиночитаемую причину. */ export class ApiError extends Error { readonly code: number; readonly errors: ApiFieldError[]; constructor(payload: ApiErrorPayload) { super(payload.message || "Error"); this.name = "ApiError"; this.code = payload.code; this.errors = payload.errors ?? []; } /** Причины, привязанные к полям формы. */ fieldErrors(): ApiFieldError[] { return this.errors.filter((item) => !!item.field); } /** Первая причина без привязки к полю — отказ уровня операции. */ operationError(): ApiFieldError | undefined { return this.errors.find((item) => !item.field); } hasCode(code: string): boolean { return this.errors.some((item) => item.code === code); } get requiresSignIn(): boolean { return this.code === API_CODE.unauthorized; } get sessionExpired(): boolean { return ( this.hasCode(ERR_CODE.sessionExpired) || this.hasCode(ERR_CODE.accountDisabled) ); } } export function isApiError(value: unknown): value is ApiError { return value instanceof ApiError; }