Compare commits

11 Commits

20 changed files with 764 additions and 251 deletions

View File

@@ -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.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import auth_backend, fastapi_users from app.services.user_service import auth_backend, fastapi_users
router = APIRouter() 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/* # Keep existing paths as-is under /auth/* and /users/*
router.include_router( router.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]

View File

@@ -1,7 +1,10 @@
import logging
import os import os
from datetime import datetime
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
from app.models.user import User from app.models.user import User
@@ -29,6 +32,7 @@ fastApi.add_middleware(
allow_origins=[ allow_origins=[
"http://localhost:5173", "http://localhost:5173",
"http://127.0.0.1:5173", "http://127.0.0.1:5173",
os.getenv("FRONTEND_DOMAIN_SCHEME", "")
], ],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
@@ -39,6 +43,27 @@ fastApi.include_router(auth_router)
fastApi.include_router(categories_router) fastApi.include_router(categories_router)
fastApi.include_router(transactions_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),
"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.include_router(
fastapi_users.get_oauth_router( fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"), get_oauth_provider("MojeID"),

View File

@@ -1,20 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: grant
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
privileges:
- "ALL PRIVILEGES"
database: "app-demo-database"
table: "*"
username: "app-demo-user"
grantOption: true
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,7 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: app-demo-database-secret
type: kubernetes.io/basic-auth
stringData:
password: "strongpassword"

View File

@@ -1,20 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: app-demo-user
spec:
# If you want the user to be created with a different name than the resource name
# name: user-custom
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
passwordSecretKeyRef:
name: app-demo-database-secret
key: password
maxUserConnections: 20
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,15 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
name: app-demo-database
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
characterSet: utf8
collate: utf8_general_ci
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,48 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo
template:
metadata:
labels:
app: app-demo
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo
ports:
- containerPort: 8000
env:
- name: MARIADB_HOST
value: mariadb-repl.mariadb-operator.svc.cluster.local
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
value: app-demo-database
- name: MARIADB_USER
value: app-demo-user
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: app-demo-database-secret
key: password
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3

View File

@@ -1,10 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: app-demo
spec:
ports:
- port: 80
targetPort: 8000
selector:
app: app-demo

View File

@@ -1,41 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo-worker
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo-worker
template:
metadata:
labels:
app: app-demo-worker
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo-worker
command:
- celery
- -A
- app.celery_app
- worker
- -Q
- $(MAIL_QUEUE)
- --loglevel
- INFO
env:
- name: RABBITMQ_USERNAME
value: demo-app
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: demo-app-user-credentials
key: password
- name: RABBITMQ_HOST
value: rabbitmq.rabbitmq.svc.cluster.local
- name: RABBITMQ_PORT
value: '5672'
- name: RABBITMQ_VHOST
value: "/"

View File

@@ -1,14 +0,0 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: guestbook-tunnel-binding
namespace: group-project
subjects:
- name: app-server
spec:
target: http://app-demo.group-project.svc.cluster.local
fqdn: demo.ltrk.cz
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -1,42 +1 @@
#root { /* App-level styles moved to ui.css for a cleaner layout. */
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;
}

View File

@@ -1,39 +1,39 @@
import { useState } from 'react' import { useEffect, useState } from 'react';
import reactLogo from './assets/react.svg' import './App.css';
import viteLogo from '/vite.svg' import LoginRegisterPage from './pages/LoginRegisterPage';
import './App.css' import Dashboard from './pages/Dashboard';
import { BACKEND_URL } from './config' import { logout } from './api';
function App() { function App() {
const [count, setCount] = useState(0) const [hasToken, setHasToken] = useState<boolean>(!!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 <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />;
}
return ( return (
<> <Dashboard onLogout={() => { logout(); setHasToken(false); }} />
<div> );
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
<p style={{ fontSize: 12, color: '#888' }}>
Backend URL: <code>{BACKEND_URL || '(not configured)'}</code>
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
} }
export default App export default App;

View File

@@ -0,0 +1,155 @@
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 getHeaders(contentType: 'json' | 'form' | 'none' = 'json'): Record<string, string> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
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<void> {
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<void> {
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<Category[]> {
const res = await fetch(`${getBaseUrl()}/categories/`, {
headers: getHeaders(),
});
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<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/create`, {
method: 'POST',
headers: getHeaders(),
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<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, {
headers: getHeaders(),
});
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<User> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
headers: getHeaders(),
});
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: getHeaders(),
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: getHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to delete account');
}
}
export function logout() {
localStorage.removeItem('token');
}

View File

@@ -0,0 +1,38 @@
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 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<FontSize, string> = {
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);
}

View File

@@ -1,7 +1,11 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.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

@@ -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<User | null>(null);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section className="card">
<h3>Account</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : !user ? (
<div>Not signed in</div>
) : (
<div className="space-y">
<div className="muted">Email: <strong>{user.email}</strong></div>
<form onSubmit={handleSave} className="space-y">
<div className="form-row">
<div>
<label className="muted">First name</label>
<input className="input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label className="muted">Last name</label>
<input className="input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="actions" style={{ justifyContent: 'flex-end' }}>
<button className="btn primary" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
</div>
</form>
<div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted"></div>
<button className="btn" style={{ borderColor: 'crimson', color: 'crimson' }} onClick={handleDelete}>Delete account</button>
</div>
</div>
)}
</section>
);
}

View File

@@ -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<Theme>('light');
const [size, setSize] = useState<FontSize>('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 (
<section className="card">
<h3>Appearance</h3>
<div className="space-y">
<div>
<div className="muted" style={{ marginBottom: 6 }}>Theme</div>
<div className="segmented">
<button className={theme === 'light' ? 'active' : ''} onClick={() => onThemeChange('light')}>Light</button>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onThemeChange('dark')}>Dark</button>
<button className={theme === 'system' ? 'active' : ''} onClick={() => onThemeChange('system')}>System</button>
</div>
</div>
<div>
<div className="muted" style={{ marginBottom: 6 }}>Font size</div>
<div className="segmented">
<button className={size === 'small' ? 'active' : ''} onClick={() => onSizeChange('small')}>Small</button>
<button className={size === 'medium' ? 'active' : ''} onClick={() => onSizeChange('medium')}>Medium</button>
<button className={size === 'large' ? 'active' : ''} onClick={() => onSizeChange('large')}>Large</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -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<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
// Filters
const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>('');
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
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 (
<div className="app-layout">
<aside className="sidebar">
<div className="logo">7Project</div>
<nav className="nav">
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</aside>
<div className="content">
<div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions">
<span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button>
</div>
</div>
<main className="page space-y">
{current === 'home' && (
<>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card">
<h3>Filters</h3>
<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="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) : '')}>
<option value="">All categories</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
</div>
</section>
<section className="card">
<h3>Latest Transactions (last 10)</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
<tr key={t.id}>
<td>{t.id}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
{current === 'account' && (
// lazy import avoided for simplicity
<AccountPage onDeleted={onLogout} />
)}
{current === 'appearance' && (
<AppearancePage />
)}
</main>
</div>
</div>
);
}

View File

@@ -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<string | null>(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 (
<div className="card" style={{ width: 420 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>{mode === 'login' ? 'Welcome back' : 'Create your account'}</h2>
<div className="segmented">
<button className={mode === 'login' ? 'active' : ''} type="button" onClick={() => setMode('login')}>Login</button>
<button className={mode === 'register' ? 'active' : ''} type="button" onClick={() => setMode('register')}>Register</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y">
<div>
<label className="muted">Email</label>
<input className="input" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="muted">Password</label>
<input className="input" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{mode === 'register' && (
<div className="form-row">
<div>
<label className="muted">First name (optional)</label>
<input className="input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label className="muted">Last name (optional)</label>
<input className="input" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
)}
{error && <div style={{ color: 'crimson' }}>{error}</div>}
<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>
</div>
</div>
</form>
</div>
);
}

View File

@@ -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; }