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,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' } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user