feat(api): real login + listening wired to the backend contract
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

Replace the faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.

- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
  expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
  audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
  page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
  append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-08 17:12:44 +03:00
parent bcfb36d53e
commit dacb8b9278
18 changed files with 519 additions and 99 deletions
+31 -21
View File
@@ -1,18 +1,29 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config';
import { useLoginMutation } from '../../api/endpoints/auth';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
} from '../../config/instances';
import type { User } from '../../api/types';
/** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 401) return 'connect.errors.badCredentials';
}
return 'connect.errors.generic';
}
export function ConnectPage() {
const { t } = useTranslation();
@@ -26,6 +37,9 @@ export function ConnectPage() {
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [login, { isLoading }] = useLoginMutation();
const switchTo = (id: string) => {
setActiveInstanceId(id);
@@ -37,25 +51,22 @@ export function ConnectPage() {
setRev((r) => r + 1);
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Point the API layer at this backend *before* logging in — baseQuery reads
// the active instance's URL at request time. Auth tokens then persist under
// that instance's namespace, never bleeding across servers.
setApiBaseUrl(apiUrl);
const fakeUser: User = {
id: 'dev-user',
username: username || 'dev',
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
try {
const { user, tokens } = await login({ username, password }).unwrap();
dispatch(setTokens(tokens));
dispatch(setUser(user));
void navigate('/');
} catch (err) {
setError(resolveLoginError(err));
}
};
const labelStyle: React.CSSProperties = {
@@ -227,15 +238,14 @@ export function ConnectPage() {
required
/>
</div>
<Callout variant="warning">
{t('connect.form.stubNote')}
</Callout>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
{t('connect.form.submit')}
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
</Button>
</form>
</Card>