feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s

- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:33:46 +03:00
1 parent f075863f15
commit ab092def54
44 files changed
+4070 -203

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
const user = {
id: '00000000-0000-0000-0000-000000000001',
username: 'alice',
display_name: null,
role: 'user' as const,
locale: 'ru',
ui_prefs: {},
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
};
const tokens = (access: string, refresh?: string): AuthTokens => ({
access_token: access,
token_type: 'Bearer',
expires_in: 900,
refresh_token: refresh,
user,
});
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
type Handler = (req: Request) => Response | Promise<Response>;
/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */
function server(routes: Record<string, Handler>) {
const log: { route: string; auth: string | null; body: string }[] = [];
const fetchImpl = async (input: RequestInfo | URL) => {
const req = input as Request;
const route = `${req.method} ${new URL(req.url).pathname}`;
log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() });
const h = routes[route];
if (!h) return problem(404, 'common.not_found');
return h(req);
};
return { fetch: fetchImpl as typeof fetch, log };
}
const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({
value: initial,
async get() {
return this.value;
},
async set(v) {
this.value = v;
},
async clear() {
this.value = null;
},
});
describe('AuthSession', () => {
it('logs in and sends the bearer token', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A1')),
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' });
const states: string[] = [];
auth.subscribe((st) => states.push(st.status));
await auth.login('alice', 'secret');
expect(JSON.parse(s.log[0]!.body)).toEqual({
username: 'alice',
password: 'secret',
client_type: 'web',
device_name: 'Test',
});
const me = await auth.client.GET('/api/v1/me');
expect(me.data?.username).toBe('alice');
expect(states).toEqual(['signed_in']);
});
it('turns problem+json into ApiError with field errors', async () => {
const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const err = await auth.login('alice', 'bad').catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe('auth.invalid_credentials');
expect(auth.state.status).toBe('unknown');
const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [
{ field: 'new_password', code: 'validation.password_too_short' },
]);
expect(e2.fieldCode('new_password')).toBe('validation.password_too_short');
expect(e2.fieldCode('other')).toBeUndefined();
});
it('refreshes once for parallel expired requests and retries them', async () => {
let refreshes = 0;
const s = server({
'POST /api/v1/auth/login': () => json(tokens('old')),
'POST /api/v1/auth/refresh': async () => {
refreshes++;
await new Promise((r) => setTimeout(r, 10));
return json(tokens('new'));
},
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me')));
expect(results.every((r) => r.data?.username === 'alice')).toBe(true);
expect(refreshes).toBe(1);
});
it('does not refresh on other 401 codes and signs out a blocked user', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A')),
'GET /api/v1/me': () => problem(401, 'auth.user_blocked'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const res = await auth.client.GET('/api/v1/me');
expect(res.response.status).toBe(401);
expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false);
expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' });
});
it('signs out when refresh is rejected', async () => {
const s = server({
'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' });
});
it('retries a refresh race once', async () => {
let n = 0;
const s = server({
'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect((await auth.restore()).status).toBe('signed_in');
expect(n).toBe(2);
});
it('keeps the session on network errors during refresh', async () => {
const auth = new AuthSession({
baseUrl: 'http://x',
fetch: async () => {
throw new TypeError('Failed to fetch');
},
clientType: 'web',
});
expect(await auth.refresh()).toBe(false);
expect(auth.state.status).toBe('unknown');
});
it('android keeps the refresh token in the store', async () => {
const store = memoryStore('R0');
const s = server({
'POST /api/v1/auth/refresh': async (req) => {
const body = (await req.json()) as { refresh_token: string };
return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid');
},
'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store });
expect((await auth.restore()).status).toBe('signed_in');
expect(store.value).toBe('R1');
await auth.logout();
expect(store.value).toBeNull();
expect(auth.state.status).toBe('signed_out');
expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' });
expect(await auth.refresh()).toBe(false);
});
it('requires a refresh store for android', () => {
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
});
it('notifies subscribers about profile updates', async () => {
const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) });
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const fn = vi.fn();
auth.subscribe(fn);
await auth.login('alice', 'x');
auth.setUser({ ...user, display_name: 'Alice' });
expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } });
});
});
+219
View File
@@ -0,0 +1,219 @@
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
export type AuthUser = components['schemas']['Me'];
export type AuthTokens = components['schemas']['AuthTokens'];
export type ClientType = components['schemas']['ClientType'];
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly params: Record<string, unknown>;
readonly fields: { field: string; code: string }[];
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
super(code);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.params = params;
this.fields = fields;
}
/** Код ошибки конкретного поля формы, если сервер его указал. */
fieldCode(field: string): string | undefined {
return this.fields.find((f) => f.field === field)?.code;
}
}
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
export function toApiError(error: unknown, response?: Response): ApiError {
if (error instanceof ApiError) return error;
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
}
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network');
}
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
return res.data as T;
}
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
export interface RefreshStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'signed_out'; reason?: string }
| { status: 'signed_in'; user: AuthUser };
export interface AuthSessionOptions {
/** Адрес сервера; пусто — тот же origin (веб). */
baseUrl?: string;
fetch?: typeof globalThis.fetch;
clientType: ClientType;
deviceName?: string;
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
refreshStore?: RefreshStore;
}
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
const RACE_RETRY_MS = 300;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
* параллельные запросы (single-flight), после чего повторяет их.
*/
export class AuthSession {
readonly client: ApiClient;
private readonly raw: ApiClient;
private readonly opts: AuthSessionOptions;
private readonly baseFetch: typeof globalThis.fetch;
private access: string | null = null;
private refreshing: Promise<boolean> | null = null;
private current: AuthState = { status: 'unknown' };
private readonly listeners = new Set<(s: AuthState) => void>();
constructor(opts: AuthSessionOptions) {
if (opts.clientType === 'android' && !opts.refreshStore) {
throw new Error('AuthSession: android client needs a refreshStore');
}
this.opts = opts;
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
}
get state(): AuthState {
return this.current;
}
/** Подписка на смену состояния; возвращает функцию отписки. */
subscribe(fn: (s: AuthState) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private setState(s: AuthState) {
this.current = s;
for (const fn of this.listeners) fn(s);
}
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
async restore(): Promise<AuthState> {
if (!(await this.refresh())) {
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
}
return this.current;
}
async login(username: string, password: string): Promise<AuthUser> {
const res = await this.raw.POST('/api/v1/auth/login', {
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
});
return this.apply(unwrap(res));
}
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
async apply(tokens: AuthTokens): Promise<AuthUser> {
this.access = tokens.access_token;
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
this.setState({ status: 'signed_in', user: tokens.user });
return tokens.user;
}
/** Обновляет профиль в состоянии (после PATCH /me). */
setUser(user: AuthUser) {
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
}
async logout(all = false): Promise<void> {
try {
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
} finally {
await this.signOut();
}
}
private async signOut(reason?: string) {
this.access = null;
await this.opts.refreshStore?.clear();
this.setState({ status: 'signed_out', reason });
}
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
refresh(): Promise<boolean> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(attempt = 0): Promise<boolean> {
let body: { refresh_token?: string } | undefined;
if (this.opts.refreshStore) {
const token = await this.opts.refreshStore.get();
if (!token) return false;
body = { refresh_token: token };
}
let res;
try {
res = await this.raw.POST('/api/v1/auth/refresh', { body });
} catch {
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
}
if (res.data) {
await this.apply(res.data);
return true;
}
const err = toApiError(res.error, res.response);
if (err.code === 'auth.refresh_race' && attempt === 0) {
// Другая вкладка только что обновила cookie — повторяем уже с новой.
await sleep(RACE_RETRY_MS);
return this.doRefresh(attempt + 1);
}
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
return false;
}
private async authFetch(request: Request): Promise<Response> {
const retry = request.clone();
const sent = this.access;
const res = await this.baseFetch(this.withAuth(request));
if (res.status !== 401) return res;
const code = await res
.clone()
.json()
.then((p: { code?: string }) => p.code)
.catch(() => undefined);
if (!code || !RETRYABLE.has(code)) {
if (code === 'auth.user_blocked') await this.signOut(code);
return res;
}
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
const fresh = this.access !== null && this.access !== sent;
if (!fresh && !(await this.refresh())) return res;
return this.baseFetch(this.withAuth(retry));
}
private withAuth(request: Request): Request {
if (!this.access) return request;
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${this.access}`);
return new Request(request, { headers });
}
}
+12
View File
@@ -1 +1,13 @@
export {
ApiError,
AuthSession,
toApiError,
unwrap,
type AuthSessionOptions,
type AuthState,
type AuthTokens,
type AuthUser,
type ClientType,
type RefreshStore,
} from './auth';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+66
View File
@@ -0,0 +1,66 @@
{
"users": {
"title": "Users",
"subtitle": "Create users and moderators, manage roles, reset passwords, block accounts",
"create": "Create user",
"search": "Name or username",
"all": "All",
"col_user": "User",
"col_role": "Role",
"col_status": "Status",
"col_last_login": "Last seen",
"status_active": "Active",
"status_blocked": "Blocked",
"status_must_change": "Must change password",
"never": "Never signed in",
"empty": "Nobody found",
"more": "Show more",
"actions": "Actions for {{username}}",
"reset_password": "Reset password",
"block": "Block",
"unblock": "Unblock",
"make_role": "Make {{role}}",
"delete": "Delete",
"delete_confirm": "Delete {{username}}? Their history and likes will be lost.",
"block_confirm": "Block {{username}}? All their sessions will end.",
"reset_confirm": "Reset {{username}}'s password? All their sessions will end.",
"confirm": "Confirm",
"you": "you"
},
"create": {
"title": "New user",
"username": "Username",
"username_hint": "Latin letters, digits, \".\", \"_\", \"-\"",
"display_name": "Name",
"role": "Role",
"password_note": "The server will generate a password and show it once.",
"submit": "Create"
},
"temp_password": {
"title": "Temporary password",
"text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.",
"done": "Done"
},
"audit": {
"title": "Audit log",
"subtitle": "Actions of administrators and moderators",
"col_when": "When",
"col_who": "Who",
"col_action": "Action",
"col_details": "Details",
"system": "System",
"empty": "No entries yet",
"more": "Show more",
"actions": {
"user.bootstrap_create": "Initial administrator created",
"user.bootstrap_credentials": "Administrator credentials set",
"user.create": "User created",
"user.rename": "Name changed",
"user.role_change": "Role changed",
"user.block": "User blocked",
"user.unblock": "User unblocked",
"user.password_reset": "Password reset",
"user.delete": "User deleted"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Sign in",
"subtitle": "Your music from your own server",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log."
},
"setup": {
"admin_badge": "First administrator sign-in",
"admin_title": "Change the username and password",
"admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.",
"password_badge": "Password reset",
"password_title": "Set a new password",
"password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.",
"new_username": "New username",
"new_password": "New password",
"repeat_password": "Repeat the password",
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"logout": "Sign out"
}
}
+62
View File
@@ -17,5 +17,67 @@
"label": "Language",
"ru": "Русский",
"en": "English"
},
"nav": {
"main": "Navigation",
"home": "Home",
"admin": "Admin",
"users": "Users",
"audit": "Audit log"
},
"profile": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
},
"roles": {
"admin": "Administrator",
"moderator": "Moderator",
"user": "User"
},
"home": {
"greeting": "Hi, {{name}}!",
"wave": "My Wave",
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
},
"player": {
"empty": "Nothing is playing"
},
"settings": {
"title": "Settings",
"account": "Account",
"display_name": "Display name",
"save": "Save",
"saved": "Saved",
"appearance": "Appearance",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"theme_system": "System",
"password": "Password",
"current_password": "Current password",
"new_password": "New password",
"repeat_password": "Repeat the password",
"change_password": "Change password",
"password_changed": "Password changed. Other devices have been signed out.",
"sessions": "Devices",
"session_current": "This device",
"session_last_used": "Active: {{date}}",
"session_end": "Sign out",
"logout_all": "Sign out everywhere"
},
"form": {
"show_password": "Show password",
"hide_password": "Hide password",
"passwords_mismatch": "Passwords don't match",
"required": "Fill in this field",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"copied": "Copied"
},
"loading": "Loading…",
"route_error": {
"reload": "Reload the page"
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request",
"not_found": "Not found",
"method_not_allowed": "This action is not supported"
"method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network"
},
"auth": {
"invalid_credentials": "Wrong username or password",
+66
View File
@@ -0,0 +1,66 @@
{
"users": {
"title": "Пользователи",
"subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка",
"create": "Создать пользователя",
"search": "Имя или логин",
"all": "Все",
"col_user": "Пользователь",
"col_role": "Роль",
"col_status": "Статус",
"col_last_login": "Был в сети",
"status_active": "Активен",
"status_blocked": "Заблокирован",
"status_must_change": "Ждёт смены пароля",
"never": "Ещё не входил",
"empty": "Никого не нашли",
"more": "Показать ещё",
"actions": "Действия с {{username}}",
"reset_password": "Сбросить пароль",
"block": "Заблокировать",
"unblock": "Разблокировать",
"make_role": "Сделать: {{role}}",
"delete": "Удалить",
"delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.",
"block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.",
"reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.",
"confirm": "Подтвердить",
"you": "вы"
},
"create": {
"title": "Новый пользователь",
"username": "Логин",
"username_hint": "Латинские буквы, цифры, «.», «_», «-»",
"display_name": "Имя",
"role": "Роль",
"password_note": "Пароль сгенерирует сервер и покажет один раз.",
"submit": "Создать"
},
"temp_password": {
"title": "Временный пароль",
"text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.",
"done": "Готово"
},
"audit": {
"title": "Журнал",
"subtitle": "Действия администраторов и модераторов",
"col_when": "Когда",
"col_who": "Кто",
"col_action": "Действие",
"col_details": "Подробности",
"system": "Система",
"empty": "Записей пока нет",
"more": "Показать ещё",
"actions": {
"user.bootstrap_create": "Создан стартовый администратор",
"user.bootstrap_credentials": "Заданы учётные данные администратора",
"user.create": "Создан пользователь",
"user.rename": "Изменено имя",
"user.role_change": "Изменена роль",
"user.block": "Пользователь заблокирован",
"user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль",
"user.delete": "Пользователь удалён"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Вход",
"subtitle": "Ваша музыка с вашего сервера",
"username": "Логин",
"password": "Пароль",
"submit": "Войти",
"bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api."
},
"setup": {
"admin_badge": "Первый вход администратора",
"admin_title": "Смените логин и пароль",
"admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.",
"password_badge": "Пароль сброшен",
"password_title": "Задайте новый пароль",
"password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.",
"new_username": "Новый логин",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"logout": "Выйти"
}
}
+62
View File
@@ -17,5 +17,67 @@
"label": "Язык",
"ru": "Русский",
"en": "English"
},
"nav": {
"main": "Навигация",
"home": "Главная",
"admin": "Админка",
"users": "Пользователи",
"audit": "Журнал"
},
"profile": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
},
"roles": {
"admin": "Администратор",
"moderator": "Модератор",
"user": "Пользователь"
},
"home": {
"greeting": "Привет, {{name}}!",
"wave": "Моя волна",
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
},
"player": {
"empty": "Ничего не играет"
},
"settings": {
"title": "Настройки",
"account": "Аккаунт",
"display_name": "Отображаемое имя",
"save": "Сохранить",
"saved": "Сохранено",
"appearance": "Внешний вид",
"theme": "Тема",
"theme_dark": "Тёмная",
"theme_light": "Светлая",
"theme_system": "Как в системе",
"password": "Пароль",
"current_password": "Текущий пароль",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"change_password": "Сменить пароль",
"password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.",
"sessions": "Устройства",
"session_current": "Это устройство",
"session_last_used": "Активность: {{date}}",
"session_end": "Завершить",
"logout_all": "Выйти на всех устройствах"
},
"form": {
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"passwords_mismatch": "Пароли не совпадают",
"required": "Заполните поле",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано"
},
"loading": "Загрузка…",
"route_error": {
"reload": "Обновить страницу"
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос",
"not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается"
"method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть"
},
"auth": {
"invalid_credentials": "Неверный логин или пароль",
+7 -3
View File
@@ -1,7 +1,11 @@
import i18next, { type i18n as I18n } from 'i18next';
import enAdmin from '../locales/en/admin.json';
import enAuth from '../locales/en/auth.json';
import enCommon from '../locales/en/common.json';
import enErrors from '../locales/en/errors.json';
import ruAdmin from '../locales/ru/admin.json';
import ruAuth from '../locales/ru/auth.json';
import ruCommon from '../locales/ru/common.json';
import ruErrors from '../locales/ru/errors.json';
@@ -11,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
export const resources = {
ru: { common: ruCommon, errors: ruErrors },
en: { common: enCommon, errors: enErrors },
ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
} as const;
declare module 'i18next' {
@@ -40,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...SUPPORTED_LOCALES],
defaultNS: 'common',
ns: ['common', 'errors'],
ns: ['common', 'auth', 'admin', 'errors'],
resources,
interpolation: { escapeValue: false },
returnNull: false,