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
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:
1 parent
f075863f15
commit
ab092def54
44 files changed
+4070
-203
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
|
||||
type Me = components['schemas']['Me'];
|
||||
type AdminUser = components['schemas']['AdminUser'];
|
||||
type Role = components['schemas']['Role'];
|
||||
|
||||
export interface FakeUser extends AdminUser {
|
||||
password: string;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let seq = 0;
|
||||
|
||||
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
|
||||
seq++;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
|
||||
username,
|
||||
display_name: null,
|
||||
role,
|
||||
locale: 'ru',
|
||||
is_blocked: false,
|
||||
must_change_password: false,
|
||||
must_change_credentials: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_login_at: null,
|
||||
password: `${username}-password`,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
|
||||
* Проверки прав упрощены — точная матрица покрыта тестами Go.
|
||||
*/
|
||||
export function fakeApi(initial: FakeUser[]) {
|
||||
const users = new Map(initial.map((u) => [u.id, { ...u }]));
|
||||
const prefs = new Map<string, components['schemas']['UiPrefs']>();
|
||||
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
||||
let token = 0;
|
||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||
|
||||
const me = (u: FakeUser): Me => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
locale: u.locale,
|
||||
ui_prefs: prefs.get(u.id) ?? {},
|
||||
must_change_password: u.must_change_password,
|
||||
must_change_credentials: u.must_change_credentials,
|
||||
created_at: u.created_at,
|
||||
});
|
||||
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
|
||||
const admin = (u: FakeUser): AdminUser => {
|
||||
const { password, ...rest } = u;
|
||||
void password;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const req = input as Request;
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
const text = await req.text();
|
||||
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
|
||||
calls.push({ method: req.method, path, body });
|
||||
const route = `${req.method} ${path}`;
|
||||
const user = current ? users.get(current) : undefined;
|
||||
|
||||
switch (route) {
|
||||
case 'GET /api/v1/server-info':
|
||||
return json({
|
||||
name: 'pipu-music',
|
||||
version: '0.2.0',
|
||||
api_version: 'v1',
|
||||
supported_locales: ['ru', 'en'],
|
||||
min_android_version_code: 0,
|
||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||
});
|
||||
case 'POST /api/v1/auth/login': {
|
||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||
current = u.id;
|
||||
return tokens(u);
|
||||
}
|
||||
case 'POST /api/v1/auth/refresh':
|
||||
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
|
||||
}
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
switch (route) {
|
||||
case 'POST /api/v1/auth/logout':
|
||||
case 'POST /api/v1/auth/logout-all':
|
||||
current = null;
|
||||
return new Response(null, { status: 204 });
|
||||
case 'GET /api/v1/me':
|
||||
return json(me(user));
|
||||
case 'PATCH /api/v1/me': {
|
||||
if (typeof body?.locale === 'string') user.locale = body.locale;
|
||||
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
|
||||
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
|
||||
return json(me(user));
|
||||
}
|
||||
case 'POST /api/v1/me/bootstrap-credentials': {
|
||||
const pw = String(body?.new_password ?? '');
|
||||
if (pw.length < 12)
|
||||
return problem(422, 'validation.password_too_short', {
|
||||
params: { min: 12 },
|
||||
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
|
||||
});
|
||||
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
|
||||
return tokens(user);
|
||||
}
|
||||
case 'POST /api/v1/me/password':
|
||||
Object.assign(user, { password: body?.new_password, must_change_password: false });
|
||||
return tokens(user);
|
||||
case 'GET /api/v1/me/sessions':
|
||||
return json({ items: [] });
|
||||
case 'GET /api/v1/admin/users': {
|
||||
if (user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
const role = url.searchParams.get('role');
|
||||
const items = [...users.values()]
|
||||
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map(admin);
|
||||
return json({ items, next_cursor: null });
|
||||
}
|
||||
case 'POST /api/v1/admin/users': {
|
||||
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
|
||||
const u = makeUser(String(body?.username), body?.role as Role, {
|
||||
display_name: (body?.display_name as string) ?? null,
|
||||
must_change_password: true,
|
||||
password: 'Temp-Pass-123',
|
||||
});
|
||||
users.set(u.id, u);
|
||||
return json({ user: admin(u), temporary_password: u.password }, 201);
|
||||
}
|
||||
case 'GET /api/v1/admin/audit':
|
||||
return json({ items: [], next_cursor: null });
|
||||
}
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
||||
}
|
||||
Reference in new issue
Block a user