feat(api): real login + listening wired to the backend contract
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user