feat(frontend): added account and appearance tabs

This commit is contained in:
ribardej
2025-10-15 11:00:47 +02:00
parent eb087e457c
commit f208e73986
5 changed files with 146 additions and 61 deletions

View File

@@ -8,6 +8,18 @@ function App() {
const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token')); const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token'));
useEffect(() => { useEffect(() => {
// Handle OAuth callback: /oauth-callback?access_token=...&token_type=...
if (window.location.pathname === '/oauth-callback') {
const params = new URLSearchParams(window.location.search);
const token = params.get('access_token');
if (token) {
localStorage.setItem('token', token);
setHasToken(true);
}
// Clean URL and redirect to home
window.history.replaceState({}, '', '/');
}
const onStorage = (e: StorageEvent) => { const onStorage = (e: StorageEvent) => {
if (e.key === 'token') setHasToken(!!e.newValue); if (e.key === 'token') setHasToken(!!e.newValue);
}; };

View File

@@ -95,6 +95,49 @@ export async function getTransactions(): Promise<Transaction[]> {
return res.json(); return res.json();
} }
export type User = {
id: string;
email: string;
first_name?: string | null;
last_name?: string | null;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
};
export async function getMe(): Promise<User> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
headers: { 'Content-Type': 'application/json', ...authHeaders() },
});
if (!res.ok) throw new Error('Failed to load user');
return res.json();
}
export type UpdateMeInput = Partial<Pick<User, 'first_name' | 'last_name'>> & { password?: string };
export async function updateMe(input: UpdateMeInput): Promise<User> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update user');
}
return res.json();
}
export async function deleteMe(): Promise<void> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
method: 'DELETE',
headers: { ...authHeaders() },
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to delete account');
}
}
export function logout() { export function logout() {
localStorage.removeItem('token'); localStorage.removeItem('token');
} }

View File

@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './ui.css' import './ui.css'
import App from './App.tsx' import App from './App.tsx'
import { applyAppearanceFromStorage } from './appearance'
applyAppearanceFromStorage()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
function formatAmount(n: number) { function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
} }
export default function Dashboard({ onLogout }: { onLogout: () => void }) { export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -78,20 +81,22 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<aside className="sidebar"> <aside className="sidebar">
<div className="logo">7Project</div> <div className="logo">7Project</div>
<nav className="nav"> <nav className="nav">
<button className="active">Home</button> <button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button>Account</button> <button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button>Appearance</button> <button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav> </nav>
</aside> </aside>
<div className="content"> <div className="content">
<div className="topbar"> <div className="topbar">
<h2 style={{ margin: 0 }}>Dashboard</h2> <h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions"> <div className="actions">
<span className="user muted">Signed in</span> <span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button> <button className="btn" onClick={onLogout}>Logout</button>
</div> </div>
</div> </div>
<main className="page space-y"> <main className="page space-y">
{current === 'home' && (
<>
<section className="card"> <section className="card">
<h3>Add Transaction</h3> <h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row"> <form onSubmit={handleCreate} className="form-row">
@@ -149,6 +154,17 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</table> </table>
)} )}
</section> </section>
</>
)}
{current === 'account' && (
// lazy import avoided for simplicity
<AccountPage onDeleted={onLogout} />
)}
{current === 'appearance' && (
<AppearancePage />
)}
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { login, register } from '../api'; import { login, register } from '../api';
import { BACKEND_URL } from '../config';
function oauthUrl(provider: 'mojeid' | 'bankid') {
const base = BACKEND_URL.replace(/\/$/, '');
const redirect = encodeURIComponent(window.location.origin + '/oauth-callback');
return `${base}/auth/${provider}/authorize?redirect_url=${redirect}`;
}
export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) { export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) {
const [mode, setMode] = useState<'login' | 'register'>('login'); const [mode, setMode] = useState<'login' | 'register'>('login');
@@ -75,9 +81,14 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
</div> </div>
)} )}
{error && <div style={{ color: 'crimson' }}>{error}</div>} {error && <div style={{ color: 'crimson' }}>{error}</div>}
<div className="actions" style={{ justifyContent: 'flex-end' }}> <div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted">Or continue with</div>
<div className="actions">
<a className="btn" href={oauthUrl('mojeid')}>MojeID</a>
<a className="btn" href={oauthUrl('bankid')}>BankID</a>
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button> <button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button>
</div> </div>
</div>
</form> </form>
</div> </div>
); );