From 89d032dd69344f0e3420e9f505469337d83d8754 Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 14 Oct 2025 11:34:25 +0200 Subject: [PATCH 1/8] feat(frontend): introduced a working frontend prototype --- 7project/frontend/src/App.tsx | 54 +++--- 7project/frontend/src/api.ts | 100 +++++++++++ 7project/frontend/src/pages/Dashboard.tsx | 155 ++++++++++++++++++ .../frontend/src/pages/LoginRegisterPage.tsx | 71 ++++++++ 4 files changed, 347 insertions(+), 33 deletions(-) create mode 100644 7project/frontend/src/api.ts create mode 100644 7project/frontend/src/pages/Dashboard.tsx create mode 100644 7project/frontend/src/pages/LoginRegisterPage.tsx diff --git a/7project/frontend/src/App.tsx b/7project/frontend/src/App.tsx index b6cce59..93626c2 100644 --- a/7project/frontend/src/App.tsx +++ b/7project/frontend/src/App.tsx @@ -1,39 +1,27 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' -import { BACKEND_URL } from './config' +import { useEffect, useState } from 'react'; +import './App.css'; +import LoginRegisterPage from './pages/LoginRegisterPage'; +import Dashboard from './pages/Dashboard'; +import { logout } from './api'; function App() { - const [count, setCount] = useState(0) + const [hasToken, setHasToken] = useState(!!localStorage.getItem('token')); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === 'token') setHasToken(!!e.newValue); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + if (!hasToken) { + return setHasToken(true)} />; + } return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-

- Backend URL: {BACKEND_URL || '(not configured)'} -

-
-

- Click on the Vite and React logos to learn more -

- - ) + { logout(); setHasToken(false); }} /> + ); } -export default App +export default App; diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts new file mode 100644 index 0000000..ff2d679 --- /dev/null +++ b/7project/frontend/src/api.ts @@ -0,0 +1,100 @@ +import { BACKEND_URL } from './config'; + +export type LoginResponse = { + access_token: string; + token_type: string; +}; + +export type Category = { + id: number; + name: string; + description?: string | null; +}; + +export type Transaction = { + id: number; + amount: number; + description?: string | null; + category_ids: number[]; +}; + +function getBaseUrl() { + const base = BACKEND_URL?.replace(/\/$/, '') || ''; + return base || ''; +} + +function authHeaders() { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function login(email: string, password: string): Promise { + const body = new URLSearchParams(); + body.set('username', email); + body.set('password', password); + + const res = await fetch(`${getBaseUrl()}/auth/jwt/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Login failed'); + } + const data: LoginResponse = await res.json(); + localStorage.setItem('token', data.access_token); +} + +export async function register(email: string, password: string, first_name?: string, last_name?: string): Promise { + const res = await fetch(`${getBaseUrl()}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, first_name, last_name }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Registration failed'); + } +} + +export async function getCategories(): Promise { + const res = await fetch(`${getBaseUrl()}/categories/`, { + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + }); + if (!res.ok) throw new Error('Failed to load categories'); + return res.json(); +} + +export type CreateTransactionInput = { + amount: number; + description?: string; + category_ids?: number[]; +}; + +export async function createTransaction(input: CreateTransactionInput): Promise { + const res = await fetch(`${getBaseUrl()}/transactions/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to create transaction'); + } + return res.json(); +} + +export async function getTransactions(): Promise { + const res = await fetch(`${getBaseUrl()}/transactions/`, { + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + }); + if (!res.ok) throw new Error('Failed to load transactions'); + return res.json(); +} + +export function logout() { + localStorage.removeItem('token'); +} diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..f606fdb --- /dev/null +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from 'react'; +import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; + +function formatAmount(n: number) { + return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); +} + +export default function Dashboard({ onLogout }: { onLogout: () => void }) { + const [transactions, setTransactions] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // New transaction form state + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState(''); + const [selectedCategoryId, setSelectedCategoryId] = useState(''); + + // Filters + const [minAmount, setMinAmount] = useState(''); + const [maxAmount, setMaxAmount] = useState(''); + const [filterCategoryId, setFilterCategoryId] = useState(''); + const [searchText, setSearchText] = useState(''); + + async function loadAll() { + setLoading(true); + setError(null); + try { + const [txs, cats] = await Promise.all([getTransactions(), getCategories()]); + setTransactions(txs); + setCategories(cats); + } catch (err: any) { + setError(err?.message || 'Failed to load data'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadAll(); + }, []); + + const last10 = useMemo(() => { + const sorted = [...transactions].sort((a, b) => b.id - a.id); + return sorted.slice(0, 10); + }, [transactions]); + + const filtered = useMemo(() => { + let arr = last10; + const min = minAmount !== '' ? Number(minAmount) : undefined; + const max = maxAmount !== '' ? Number(maxAmount) : undefined; + if (min !== undefined) arr = arr.filter(t => t.amount >= min); + if (max !== undefined) arr = arr.filter(t => t.amount <= max); + if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number)); + if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase())); + return arr; + }, [last10, minAmount, maxAmount, filterCategoryId, searchText]); + + function categoryNameById(id: number) { + return categories.find(c => c.id === id)?.name || `#${id}`; + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!amount) return; + const payload = { + amount: Number(amount), + description: description || undefined, + category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined, + }; + try { + const created = await createTransaction(payload); + setTransactions(prev => [created, ...prev]); + // reset form + setAmount(''); + setDescription(''); + setSelectedCategoryId(''); + } catch (err: any) { + alert(err?.message || 'Failed to create transaction'); + } + } + + return ( +
+
+

Dashboard

+ +
+ +
+

Add Transaction

+
+ setAmount(e.target.value)} required /> + setDescription(e.target.value)} /> + + +
+
+ +
+

Filters

+
+ setMinAmount(e.target.value)} /> + setMaxAmount(e.target.value)} /> + + setSearchText(e.target.value)} /> +
+
+ +
+

Latest Transactions (last 10)

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
No transactions
+ ) : ( + + + + + + + + + + + {filtered.map(t => ( + + + + + + + ))} + +
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''} + {t.category_ids.map(id => categoryNameById(id)).join(', ')} +
+ )} +
+
+ ); +} diff --git a/7project/frontend/src/pages/LoginRegisterPage.tsx b/7project/frontend/src/pages/LoginRegisterPage.tsx new file mode 100644 index 0000000..e265167 --- /dev/null +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { login, register } from '../api'; + +export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) { + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + try { + if (mode === 'login') { + await login(email, password); + onLoggedIn(); + } else { + await register(email, password, firstName || undefined, lastName || undefined); + // After register, prompt login automatically + await login(email, password); + onLoggedIn(); + } + } catch (err: any) { + setError(err?.message || 'Operation failed'); + } finally { + setLoading(false); + } + } + + return ( +
+

{mode === 'login' ? 'Login' : 'Register'}

+
+ + +
+
+
+ +
+
+ +
+ {mode === 'register' && ( + <> +
+ +
+
+ +
+ + )} + {error &&
{error}
} + +
+
+ ); +} From eb087e457c3b2c1dfafa9cd5a79b90a20e74f843 Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 15 Oct 2025 10:06:22 +0200 Subject: [PATCH 2/8] feat(frontend): improved and centered UI --- 7project/frontend/src/App.css | 43 +---- 7project/frontend/src/api.ts | 2 +- 7project/frontend/src/main.tsx | 1 + 7project/frontend/src/pages/Dashboard.tsx | 155 +++++++++--------- .../frontend/src/pages/LoginRegisterPage.tsx | 78 +++++---- 5 files changed, 127 insertions(+), 152 deletions(-) diff --git a/7project/frontend/src/App.css b/7project/frontend/src/App.css index b9d355d..c776d1c 100644 --- a/7project/frontend/src/App.css +++ b/7project/frontend/src/App.css @@ -1,42 +1 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} +/* App-level styles moved to ui.css for a cleaner layout. */ diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts index ff2d679..95933d4 100644 --- a/7project/frontend/src/api.ts +++ b/7project/frontend/src/api.ts @@ -88,7 +88,7 @@ export async function createTransaction(input: CreateTransactionInput): Promise< } export async function getTransactions(): Promise { - const res = await fetch(`${getBaseUrl()}/transactions/`, { + const res = await fetch(`${getBaseUrl()}/transactions/`, { headers: { 'Content-Type': 'application/json', ...authHeaders() }, }); if (!res.ok) throw new Error('Failed to load transactions'); diff --git a/7project/frontend/src/main.tsx b/7project/frontend/src/main.tsx index bef5202..d8e0921 100644 --- a/7project/frontend/src/main.tsx +++ b/7project/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import './ui.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index f606fdb..2c6b05f 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -36,9 +36,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { } } - useEffect(() => { - loadAll(); - }, []); + useEffect(() => { loadAll(); }, []); const last10 = useMemo(() => { const sorted = [...transactions].sort((a, b) => b.id - a.id); @@ -56,9 +54,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { return arr; }, [last10, minAmount, maxAmount, filterCategoryId, searchText]); - function categoryNameById(id: number) { - return categories.find(c => c.id === id)?.name || `#${id}`; - } + function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; } async function handleCreate(e: React.FormEvent) { e.preventDefault(); @@ -71,85 +67,90 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { try { const created = await createTransaction(payload); setTransactions(prev => [created, ...prev]); - // reset form - setAmount(''); - setDescription(''); - setSelectedCategoryId(''); + setAmount(''); setDescription(''); setSelectedCategoryId(''); } catch (err: any) { alert(err?.message || 'Failed to create transaction'); } } return ( -
-
-

Dashboard

- -
- -
-

Add Transaction

-
- setAmount(e.target.value)} required /> - setDescription(e.target.value)} /> - - -
-
- -
-

Filters

-
- setMinAmount(e.target.value)} /> - setMaxAmount(e.target.value)} /> - - setSearchText(e.target.value)} /> +
+ +
+
+

Dashboard

+
+ Signed in + +
-
+
+
+

Add Transaction

+
+ setAmount(e.target.value)} required /> + setDescription(e.target.value)} /> + + +
+
-
-

Latest Transactions (last 10)

- {loading ? ( -
Loading…
- ) : error ? ( -
{error}
- ) : filtered.length === 0 ? ( -
No transactions
- ) : ( - - - - - - - - - - - {filtered.map(t => ( - - - - - - - ))} - -
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''} - {t.category_ids.map(id => categoryNameById(id)).join(', ')} -
- )} -
+
+

Filters

+
+ setMinAmount(e.target.value)} /> + setMaxAmount(e.target.value)} /> + + setSearchText(e.target.value)} /> +
+
+ +
+

Latest Transactions (last 10)

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
No transactions
+ ) : ( + + + + + + + + + + + {filtered.map(t => ( + + + + + + + ))} + +
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''}{t.category_ids.map(id => categoryNameById(id)).join(', ')}
+ )} +
+
+
); } diff --git a/7project/frontend/src/pages/LoginRegisterPage.tsx b/7project/frontend/src/pages/LoginRegisterPage.tsx index e265167..ce333d0 100644 --- a/7project/frontend/src/pages/LoginRegisterPage.tsx +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { login, register } from '../api'; + export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) { const [mode, setMode] = useState<'login' | 'register'>('login'); const [email, setEmail] = useState(''); @@ -31,41 +32,54 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo } } + // Add this useEffect hook + useEffect(() => { + // When the component mounts, add a class to the body + document.body.classList.add('auth-page'); + + // When the component unmounts, remove the class + return () => { + document.body.classList.remove('auth-page'); + }; + }, []); // The empty array ensures this runs only once + + // The JSX no longer needs the wrapper div return ( -
-

{mode === 'login' ? 'Login' : 'Register'}

-
- - +
+
+

{mode === 'login' ? 'Welcome back' : 'Create your account'}

+
+ + +
-
-
- -
-
- -
- {mode === 'register' && ( - <> -
- + +
+ + setEmail(e.target.value)} /> +
+
+ + setPassword(e.target.value)} /> +
+ {mode === 'register' && ( +
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
-
- -
- - )} - {error &&
{error}
} - + )} + {error &&
{error}
} +
+ +
); } + From f208e739861ec35b912ef358169689329bd68e6c Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 15 Oct 2025 11:00:47 +0200 Subject: [PATCH 3/8] feat(frontend): added account and appearance tabs --- 7project/frontend/src/App.tsx | 12 ++ 7project/frontend/src/api.ts | 43 ++++++ 7project/frontend/src/main.tsx | 3 + 7project/frontend/src/pages/Dashboard.tsx | 134 ++++++++++-------- .../frontend/src/pages/LoginRegisterPage.tsx | 15 +- 5 files changed, 146 insertions(+), 61 deletions(-) diff --git a/7project/frontend/src/App.tsx b/7project/frontend/src/App.tsx index 93626c2..3f3cec6 100644 --- a/7project/frontend/src/App.tsx +++ b/7project/frontend/src/App.tsx @@ -8,6 +8,18 @@ function App() { const [hasToken, setHasToken] = useState(!!localStorage.getItem('token')); 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) => { if (e.key === 'token') setHasToken(!!e.newValue); }; diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts index 95933d4..2b7a2cb 100644 --- a/7project/frontend/src/api.ts +++ b/7project/frontend/src/api.ts @@ -95,6 +95,49 @@ export async function getTransactions(): Promise { 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 { + 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> & { password?: string }; +export async function updateMe(input: UpdateMeInput): Promise { + 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 { + 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() { localStorage.removeItem('token'); } diff --git a/7project/frontend/src/main.tsx b/7project/frontend/src/main.tsx index d8e0921..9d33e35 100644 --- a/7project/frontend/src/main.tsx +++ b/7project/frontend/src/main.tsx @@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client' import './index.css' import './ui.css' import App from './App.tsx' +import { applyAppearanceFromStorage } from './appearance' + +applyAppearanceFromStorage() createRoot(document.getElementById('root')!).render( diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 2c6b05f..4f78f76 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; +import AccountPage from './AccountPage'; +import AppearancePage from './AppearancePage'; function formatAmount(n: number) { return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); } export default function Dashboard({ onLogout }: { onLogout: () => void }) { + const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home'); const [transactions, setTransactions] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); @@ -78,77 +81,90 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
-

Dashboard

+

{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}

Signed in
-
-

Add Transaction

-
- setAmount(e.target.value)} required /> - setDescription(e.target.value)} /> - - -
-
+ {current === 'home' && ( + <> +
+

Add Transaction

+
+ setAmount(e.target.value)} required /> + setDescription(e.target.value)} /> + + +
+
-
-

Filters

-
- setMinAmount(e.target.value)} /> - setMaxAmount(e.target.value)} /> - - setSearchText(e.target.value)} /> -
-
+
+

Filters

+
+ setMinAmount(e.target.value)} /> + setMaxAmount(e.target.value)} /> + + setSearchText(e.target.value)} /> +
+
-
-

Latest Transactions (last 10)

- {loading ? ( -
Loading…
- ) : error ? ( -
{error}
- ) : filtered.length === 0 ? ( -
No transactions
- ) : ( - - - - - - - - - - - {filtered.map(t => ( - - - - - - - ))} - -
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''}{t.category_ids.map(id => categoryNameById(id)).join(', ')}
- )} -
+
+

Latest Transactions (last 10)

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
No transactions
+ ) : ( + + + + + + + + + + + {filtered.map(t => ( + + + + + + + ))} + +
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''}{t.category_ids.map(id => categoryNameById(id)).join(', ')}
+ )} +
+ + )} + + {current === 'account' && ( + // lazy import avoided for simplicity + + )} + + {current === 'appearance' && ( + + )}
diff --git a/7project/frontend/src/pages/LoginRegisterPage.tsx b/7project/frontend/src/pages/LoginRegisterPage.tsx index ce333d0..50896fe 100644 --- a/7project/frontend/src/pages/LoginRegisterPage.tsx +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -1,6 +1,12 @@ import { useState, useEffect } from 'react'; 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 }) { const [mode, setMode] = useState<'login' | 'register'>('login'); @@ -75,8 +81,13 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
)} {error &&
{error}
} -
- +
+
Or continue with
+
+ MojeID + BankID + +
From c21af2732eef14994bc0fdc9d65c7bb438fb939f Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 15 Oct 2025 11:11:04 +0200 Subject: [PATCH 4/8] feat(backend): implemented self delete for users --- 7project/backend/app/api/auth.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/7project/backend/app/api/auth.py b/7project/backend/app/api/auth.py index 576e80e..075600c 100644 --- a/7project/backend/app/api/auth.py +++ b/7project/backend/app/api/auth.py @@ -1,10 +1,28 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, status +from fastapi_users import models +from fastapi_users.manager import BaseUserManager from app.schemas.user import UserCreate, UserRead, UserUpdate from app.services.user_service import auth_backend, fastapi_users router = APIRouter() +@router.delete( + "/users/me", + status_code=status.HTTP_204_NO_CONTENT, + tags=["users"], + summary="Delete current user", + response_description="The user has been successfully deleted.", +) +async def delete_me( + user: models.UserProtocol = Depends(fastapi_users.current_user(active=True)), + user_manager: BaseUserManager = Depends(fastapi_users.get_user_manager), +): + """ + Delete the currently authenticated user. + """ + await user_manager.delete(user) + # Keep existing paths as-is under /auth/* and /users/* router.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] From 3a7580c315d8357c173c3d1f49f5f5c9e9243002 Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 15 Oct 2025 15:08:18 +0200 Subject: [PATCH 5/8] feat(backend): added missing untracked files --- 7project/frontend/src/appearance.ts | 39 +++++++++ 7project/frontend/src/pages/AccountPage.tsx | 87 +++++++++++++++++++ .../frontend/src/pages/AppearancePage.tsx | 49 +++++++++++ 7project/frontend/src/ui.css | 85 ++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 7project/frontend/src/appearance.ts create mode 100644 7project/frontend/src/pages/AccountPage.tsx create mode 100644 7project/frontend/src/pages/AppearancePage.tsx create mode 100644 7project/frontend/src/ui.css diff --git a/7project/frontend/src/appearance.ts b/7project/frontend/src/appearance.ts new file mode 100644 index 0000000..2b14b99 --- /dev/null +++ b/7project/frontend/src/appearance.ts @@ -0,0 +1,39 @@ +export type Theme = 'system' | 'light' | 'dark'; +export type FontSize = 'small' | 'medium' | 'large'; + +const THEME_KEY = 'app_theme'; +const FONT_KEY = 'app_font_size'; + +export function applyTheme(theme: Theme) { + const root = document.documentElement; + const body = document.body; + const effective = theme === 'system' ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; + body.setAttribute('data-theme', effective); +} + +export function applyFontSize(size: FontSize) { + const root = document.documentElement; + const map: Record = { + small: '14px', + medium: '16px', + large: '18px', + }; + root.style.fontSize = map[size]; +} + +export function saveAppearance(theme: Theme, size: FontSize) { + localStorage.setItem(THEME_KEY, theme); + localStorage.setItem(FONT_KEY, size); +} + +export function loadAppearance(): { theme: Theme; size: FontSize } { + const theme = (localStorage.getItem(THEME_KEY) as Theme) || 'light'; + const size = (localStorage.getItem(FONT_KEY) as FontSize) || 'medium'; + return { theme, size }; +} + +export function applyAppearanceFromStorage() { + const { theme, size } = loadAppearance(); + applyTheme(theme); + applyFontSize(size); +} diff --git a/7project/frontend/src/pages/AccountPage.tsx b/7project/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..c31804f --- /dev/null +++ b/7project/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { deleteMe, getMe, type UpdateMeInput, type User, updateMe } from '../api'; + +export default function AccountPage({ onDeleted }: { onDeleted: () => void }) { + const [user, setUser] = useState(null); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const u = await getMe(); + setUser(u); + setFirstName(u.first_name || ''); + setLastName(u.last_name || ''); + } catch (e: any) { + setError(e?.message || 'Failed to load account'); + } finally { + setLoading(false); + } + })(); + }, []); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const payload: UpdateMeInput = { first_name: firstName || null as any, last_name: lastName || null as any }; + const updated = await updateMe(payload); + setUser(updated); + } catch (e: any) { + setError(e?.message || 'Failed to update'); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!confirm('Are you sure you want to delete your account? This cannot be undone.')) return; + try { + await deleteMe(); + onDeleted(); + } catch (e: any) { + alert(e?.message || 'Failed to delete account'); + } + } + + return ( +
+

Account

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : !user ? ( +
Not signed in
+ ) : ( +
+
Email: {user.email}
+
+
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+
+ +
+
+
+
+ +
+
+ )} +
+ ); +} diff --git a/7project/frontend/src/pages/AppearancePage.tsx b/7project/frontend/src/pages/AppearancePage.tsx new file mode 100644 index 0000000..3aeaa1f --- /dev/null +++ b/7project/frontend/src/pages/AppearancePage.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { applyFontSize, applyTheme, loadAppearance, saveAppearance, type FontSize, type Theme } from '../appearance'; + +export default function AppearancePage() { + const [theme, setTheme] = useState('light'); + const [size, setSize] = useState('medium'); + + useEffect(() => { + const { theme, size } = loadAppearance(); + setTheme(theme); + setSize(size); + }, []); + + function onThemeChange(next: Theme) { + setTheme(next); + applyTheme(next); + saveAppearance(next, size); + } + + function onSizeChange(next: FontSize) { + setSize(next); + applyFontSize(next); + saveAppearance(theme, next); + } + + return ( +
+

Appearance

+
+
+
Theme
+
+ + + +
+
+
+
Font size
+
+ + + +
+
+
+
+ ); +} diff --git a/7project/frontend/src/ui.css b/7project/frontend/src/ui.css new file mode 100644 index 0000000..ec6092f --- /dev/null +++ b/7project/frontend/src/ui.css @@ -0,0 +1,85 @@ +:root { + --bg: #f7f7fb; + --panel: #ffffff; + --text: #9aa3b2; + --muted: #6b7280; + --primary: #6f49fe; + --primary-600: #5a37fb; + --border: #e5e7eb; + --radius: 12px; + --shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08); + + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--text); +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; } + +body { background: var(--bg); margin: 0; display: block; } + +/* Dark theme variables */ +body[data-theme="dark"] { + --bg: #161a2b; + --panel: #283046; + --text: #283046; + --muted: #cbd5e1; + --primary: #8b7bff; + --primary-600: #7b69ff; + --border: #283046; +} + +/* Layout */ +.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; } +.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; } +.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; } +.nav { margin-top: 12px; display: grid; gap: 4px; } +.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; } +.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; } + +.content { display: flex; flex-direction: column; height: 100%; } +.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); } +.topbar .user { color: var(--muted); } +.page { padding: 24px; max-width: 1100px; margin: auto; } + +/* Cards */ +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; } +.card h3 { margin: 0 0 12px; } + +/* Forms */ +.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); } +.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); } +.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); } +.form-row > * { min-width: 140px; } +.actions { display: flex; align-items: center; gap: 8px; } + +/* Buttons */ +.btn { border: 1px solid var(--border); background: #fff; color: var(--text); padding: 10px 14px; border-radius: 10px; cursor: pointer; } +.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } +.btn.primary:hover { background: var(--primary-600); } +.btn.ghost { background: transparent; color: var(--muted); } + +/* Tables */ +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); } +.table th { text-align: left; color: var(--muted); font-weight: 600; } +.table td.amount { text-align: right; font-variant-numeric: tabular-nums; } + +/* Segmented control */ +.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); } +.segmented button { border: 0; background: transparent; padding: 8px 12px; border-radius: 8px; color: var(--muted); cursor: pointer; } +.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow); } + +/* Auth layout */ +body.auth-page #root { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; +} + +/* Utility */ +.muted { color: var(--muted); } +.space-y > * + * { margin-top: 12px; } From 1f5d6f127faa1bfbd6f7348a9a61609f22493518 Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 15 Oct 2025 15:21:10 +0200 Subject: [PATCH 6/8] feat(backend): fixed build errors regarding token in headers --- 7project/frontend/src/api.ts | 28 ++++++++++++++++++++-------- 7project/frontend/src/appearance.ts | 1 - 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts index 2b7a2cb..450c342 100644 --- a/7project/frontend/src/api.ts +++ b/7project/frontend/src/api.ts @@ -23,9 +23,21 @@ function getBaseUrl() { return base || ''; } -function authHeaders() { +function getHeaders(contentType: 'json' | 'form' | 'none' = 'json'): Record { const token = localStorage.getItem('token'); - return token ? { Authorization: `Bearer ${token}` } : {}; + const headers: Record = {}; + + if (contentType === 'json') { + headers['Content-Type'] = 'application/json'; + } else if (contentType === 'form') { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; } export async function login(email: string, password: string): Promise { @@ -62,7 +74,7 @@ export async function register(email: string, password: string, first_name?: str export async function getCategories(): Promise { const res = await fetch(`${getBaseUrl()}/categories/`, { - headers: { 'Content-Type': 'application/json', ...authHeaders() }, + headers: getHeaders(), }); if (!res.ok) throw new Error('Failed to load categories'); return res.json(); @@ -77,7 +89,7 @@ export type CreateTransactionInput = { export async function createTransaction(input: CreateTransactionInput): Promise { const res = await fetch(`${getBaseUrl()}/transactions/create`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, + headers: getHeaders(), body: JSON.stringify(input), }); if (!res.ok) { @@ -89,7 +101,7 @@ export async function createTransaction(input: CreateTransactionInput): Promise< export async function getTransactions(): Promise { const res = await fetch(`${getBaseUrl()}/transactions/`, { - headers: { 'Content-Type': 'application/json', ...authHeaders() }, + headers: getHeaders(), }); if (!res.ok) throw new Error('Failed to load transactions'); return res.json(); @@ -107,7 +119,7 @@ export type User = { export async function getMe(): Promise { const res = await fetch(`${getBaseUrl()}/users/me`, { - headers: { 'Content-Type': 'application/json', ...authHeaders() }, + headers: getHeaders(), }); if (!res.ok) throw new Error('Failed to load user'); return res.json(); @@ -117,7 +129,7 @@ export type UpdateMeInput = Partial> & { export async function updateMe(input: UpdateMeInput): Promise { const res = await fetch(`${getBaseUrl()}/users/me`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, + headers: getHeaders(), body: JSON.stringify(input), }); if (!res.ok) { @@ -130,7 +142,7 @@ export async function updateMe(input: UpdateMeInput): Promise { export async function deleteMe(): Promise { const res = await fetch(`${getBaseUrl()}/users/me`, { method: 'DELETE', - headers: { ...authHeaders() }, + headers: getHeaders(), }); if (!res.ok) { const text = await res.text(); diff --git a/7project/frontend/src/appearance.ts b/7project/frontend/src/appearance.ts index 2b14b99..eaed1c4 100644 --- a/7project/frontend/src/appearance.ts +++ b/7project/frontend/src/appearance.ts @@ -5,7 +5,6 @@ const THEME_KEY = 'app_theme'; const FONT_KEY = 'app_font_size'; export function applyTheme(theme: Theme) { - const root = document.documentElement; const body = document.body; const effective = theme === 'system' ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; body.setAttribute('data-theme', effective); From 2f275ef605180e72977448290fabdf02d873d5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Trkan?= Date: Fri, 17 Oct 2025 12:58:11 +0200 Subject: [PATCH 7/8] fix(infrastructure): add frontend URL to CORS --- 7project/backend/app/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 85e18e2..87ea332 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -1,3 +1,5 @@ +import os + from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -17,6 +19,7 @@ fastApi.add_middleware( allow_origins=[ "http://localhost:5173", "http://127.0.0.1:5173", + os.getenv("FRONTEND_DOMAIN_SCHEME", "") ], allow_credentials=True, allow_methods=["*"], From 8974561308e3db0dfb9f3a3f8fc360a28178b2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Trkan?= Date: Fri, 17 Oct 2025 15:14:10 +0200 Subject: [PATCH 8/8] add debug logging --- 7project/backend/app/app.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 87ea332..926a779 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -1,7 +1,10 @@ +import logging import os +from datetime import datetime from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.requests import Request from app.models.user import User @@ -30,6 +33,28 @@ fastApi.include_router(auth_router) fastApi.include_router(categories_router) fastApi.include_router(transactions_router) +logging.basicConfig(filename='app.log', level=logging.INFO, format='%(asctime)s %(message)s') +@fastApi.middleware("http") +async def log_traffic(request: Request, call_next): + start_time = datetime.now() + response = await call_next(request) + process_time = (datetime.now() - start_time).total_seconds() + client_host = request.client.host + log_params = { + "request_method": request.method, + "request_url": str(request.url), + "request_size": request.headers.get("content-length"), + "request_headers": dict(request.headers), + "request_body": await request.body(), + "response_status": response.status_code, + "response_size": response.headers.get("content-length"), + "response_headers": dict(response.headers), + "process_time": process_time, + "client_host": client_host + } + logging.info(str(log_params)) + return response + fastApi.include_router( fastapi_users.get_oauth_router( get_oauth_provider("MojeID"),