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,77 +81,90 @@ 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">
<section className="card"> {current === 'home' && (
<h3>Add Transaction</h3> <>
<form onSubmit={handleCreate} className="form-row"> <section className="card">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required /> <h3>Add Transaction</h3>
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} /> <form onSubmit={handleCreate} className="form-row">
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}> <input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<option value="">No category</option> <input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))} <select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
</select> <option value="">No category</option>
<button className="btn primary" type="submit">Add</button> {categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</form> </select>
</section> <button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card"> <section className="card">
<h3>Filters</h3> <h3>Filters</h3>
<div className="form-row"> <div className="form-row">
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} /> <input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} /> <input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}> <select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">All categories</option> <option value="">All categories</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))} {categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select> </select>
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} /> <input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
</div> </div>
</section> </section>
<section className="card"> <section className="card">
<h3>Latest Transactions (last 10)</h3> <h3>Latest Transactions (last 10)</h3>
{loading ? ( {loading ? (
<div>Loading</div> <div>Loading</div>
) : error ? ( ) : error ? (
<div style={{ color: 'crimson' }}>{error}</div> <div style={{ color: 'crimson' }}>{error}</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div>No transactions</div> <div>No transactions</div>
) : ( ) : (
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th style={{ textAlign: 'right' }}>Amount</th> <th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th> <th>Description</th>
<th>Categories</th> <th>Categories</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map(t => ( {filtered.map(t => (
<tr key={t.id}> <tr key={t.id}>
<td>{t.id}</td> <td>{t.id}</td>
<td className="amount">{formatAmount(t.amount)}</td> <td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td> <td>{t.description || ''}</td>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td> <td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</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,8 +81,13 @@ 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' }}>
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button> <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>
</div>
</div> </div>
</form> </form>
</div> </div>