feat: auth & admin

This commit is contained in:
2026-06-03 10:41:53 +03:00
parent 612d0f0125
commit 7dc59fb3c4
120 changed files with 4683 additions and 2159 deletions
+186 -22
View File
@@ -1,24 +1,48 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout } from 'modern-sk';
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl, getApiBaseUrl } from '../../config/runtime-config';
import { setApiBaseUrl } from '../../config/runtime-config';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
} from '../../config/instances';
import type { User } from '../../api/types';
export function ConnectPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [apiUrl, setApiUrl] = useState(getApiBaseUrl);
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// STUB: no backend yet. Fake a session so the rest of the app is reachable.
// Replace with the real useLoginMutation() flow once the backend exists.
// Switching to a saved backend reloads the app so every slice re-initialises
// from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const forget = (id: string) => {
removeInstance(id);
setRev((r) => r + 1);
};
// STUB: no backend yet. Register the instance, then fake a session so the rest
// of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl);
setApiBaseUrl(apiUrl); // upsert + activate this backend
const fakeUser: User = {
id: 'dev-user',
@@ -26,21 +50,158 @@ export function ConnectPage() {
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(setTokens({ accessToken: 'dev-token', refreshToken: 'dev-refresh', expiresIn: 3600 }));
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)', padding: '2rem' }}>
<div style={{ width: '100%', maxWidth: '24rem' }}>
<h1 style={{ textAlign: 'center', marginBottom: '2rem', color: 'var(--color-accent)', fontSize: '1.75rem' }}> MCMA</h1>
<div
key={rev}
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
}}
>
<div
style={{
width: '100%',
maxWidth: '26rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1
style={{
textAlign: 'center',
color: 'var(--color-accent)',
fontSize: '1.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
<Icon name="vinyl-record" fill /> MCMA
</h1>
{instances.length > 0 && (
<Card>
<div
style={{
padding: '1.25rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
</span>
{instances.map((inst) => (
<div
key={inst.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
>
<Icon name="trash" />
</button>
</div>
))}
</div>
</Card>
)}
<Card>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1.5rem' }}>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Server URL
</label>
<label style={labelStyle}>Server URL</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
@@ -49,9 +210,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Username
</label>
<label style={labelStyle}>Username</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -61,9 +220,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Password
</label>
<label style={labelStyle}>Password</label>
<TextField
type="password"
value={password}
@@ -73,8 +230,15 @@ export function ConnectPage() {
required
/>
</div>
<Callout variant="warning">Stub mode backend not wired. Connect signs in with a fake admin session.</Callout>
<Button type="submit" variant="primary" style={{ marginTop: '0.5rem' }}>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
>
Connect
</Button>
</form>