feat(auth): add form login with session
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||
import AppShell from './layout/AppShell';
|
||||
import ChartPanel from './features/chart/ChartPanel';
|
||||
@@ -10,6 +10,7 @@ import MarketHeader from './features/market/MarketHeader';
|
||||
import Button from './ui/Button';
|
||||
import TopNav from './layout/TopNav';
|
||||
import AuthStatus from './layout/AuthStatus';
|
||||
import LoginScreen from './layout/LoginScreen';
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const v = (import.meta as any).env?.[name];
|
||||
@@ -35,7 +36,69 @@ function formatQty(v: number | null | undefined, decimals: number): string {
|
||||
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAuthLoading(true);
|
||||
fetch('/whoami', { cache: 'no-store' })
|
||||
.then(async (res) => {
|
||||
const json = (await res.json().catch(() => null)) as WhoamiResponse | null;
|
||||
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||
return u || null;
|
||||
})
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setAuthLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="loginScreen">
|
||||
<div className="loginCard" role="status" aria-label="Ładowanie">
|
||||
Ładowanie…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginScreen onLoggedIn={setUser} />;
|
||||
}
|
||||
|
||||
return <TradeApp user={user} onLogout={() => void logout()} />;
|
||||
}
|
||||
|
||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
|
||||
@@ -156,7 +219,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={<TopNav active="trade" rightEndSlot={<AuthStatus />} />}
|
||||
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||||
top={<TickerBar items={topItems} />}
|
||||
main={
|
||||
<div className="tradeMain">
|
||||
|
||||
@@ -1,53 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
type Props = {
|
||||
user: string;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export default function AuthStatus() {
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch('/whoami', { cache: 'no-store' })
|
||||
.then(async (res) => {
|
||||
const json = (await res.json().catch(() => null)) as WhoamiResponse | null;
|
||||
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||
return u || null;
|
||||
})
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = '/logout';
|
||||
};
|
||||
|
||||
export default function AuthStatus({ user, onLogout }: Props) {
|
||||
return (
|
||||
<div className="authStatus">
|
||||
<div className="authStatus__user" aria-label="Zalogowany użytkownik">
|
||||
<div className="authStatus__userLabel">Zalogowany</div>
|
||||
<div className="authStatus__userName">{loading ? '…' : user || '—'}</div>
|
||||
<div className="authStatus__userName">{user}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={logout}>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={onLogout}>
|
||||
Wyloguj
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
90
apps/visualizer/src/layout/LoginScreen.tsx
Normal file
90
apps/visualizer/src/layout/LoginScreen.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
type Props = {
|
||||
onLoggedIn: (user: string) => void;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
ok?: boolean;
|
||||
user?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export default function LoginScreen({ onLoggedIn }: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as LoginResponse | null;
|
||||
const ok = Boolean(res.ok && json?.ok);
|
||||
if (!ok) throw new Error(json?.error || 'invalid_credentials');
|
||||
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||
if (!u) throw new Error('bad_response');
|
||||
setPassword('');
|
||||
onLoggedIn(u);
|
||||
} catch {
|
||||
setError('Nieprawidłowy login lub hasło.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="loginScreen">
|
||||
<div className="loginCard" role="dialog" aria-label="Logowanie">
|
||||
<div className="loginCard__brand">
|
||||
<div className="loginCard__mark" aria-hidden="true" />
|
||||
<div className="loginCard__title">trade</div>
|
||||
</div>
|
||||
<div className="loginCard__subtitle">Zaloguj się, aby wejść do aplikacji.</div>
|
||||
|
||||
<form className="loginForm" onSubmit={(e) => void submit(e)}>
|
||||
<label className="inlineField">
|
||||
<span className="inlineField__label">Login</span>
|
||||
<input
|
||||
className="inlineField__input loginForm__input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="np. mpabi"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="inlineField">
|
||||
<span className="inlineField__label">Hasło</span>
|
||||
<input
|
||||
className="inlineField__input loginForm__input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <div className="loginForm__error">{error}</div> : null}
|
||||
|
||||
<div className="loginForm__actions">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Logowanie…' : 'Zaloguj'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,72 @@ a:hover {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginScreen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
width: min(420px, 100%);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.loginCard__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loginCard__mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(59, 130, 246, 0.9));
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.loginCard__title {
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.loginCard__subtitle {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loginForm__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loginForm__error {
|
||||
color: var(--neg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loginForm__actions {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.shellHeader {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
|
||||
|
||||
Reference in New Issue
Block a user