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"] 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/App.tsx b/7project/frontend/src/App.tsx index b6cce59..3f3cec6 100644 --- a/7project/frontend/src/App.tsx +++ b/7project/frontend/src/App.tsx @@ -1,39 +1,39 @@ -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(() => { + // 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); + }; + 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..2b7a2cb --- /dev/null +++ b/7project/frontend/src/api.ts @@ -0,0 +1,143 @@ +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 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 bef5202..9d33e35 100644 --- a/7project/frontend/src/main.tsx +++ b/7project/frontend/src/main.tsx @@ -1,7 +1,11 @@ import { StrictMode } from 'react' 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 new file mode 100644 index 0000000..4f78f76 --- /dev/null +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,172 @@ +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); + 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]); + setAmount(''); setDescription(''); setSelectedCategoryId(''); + } catch (err: any) { + alert(err?.message || 'Failed to create transaction'); + } + } + + return ( +
+ +
+
+

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

+
+ Signed in + +
+
+
+ {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)} /> +
+
+ +
+

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 new file mode 100644 index 0000000..50896fe --- /dev/null +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -0,0 +1,96 @@ +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'); + 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); + } + } + + // 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' ? 'Welcome back' : 'Create your account'}

+
+ + +
+
+
+
+ + setEmail(e.target.value)} /> +
+
+ + setPassword(e.target.value)} /> +
+ {mode === 'register' && ( +
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+ )} + {error &&
{error}
} +
+
Or continue with
+
+ MojeID + BankID + +
+
+
+
+ ); +} +