22 Commits

Author SHA1 Message Date
b0cabe027f add debug logging 2025-10-17 15:42:58 +02:00
8974561308 add debug logging 2025-10-17 15:14:10 +02:00
2f275ef605 fix(infrastructure): add frontend URL to CORS 2025-10-17 12:58:11 +02:00
ribardej
1f5d6f127f feat(backend): fixed build errors regarding token in headers 2025-10-15 15:21:10 +02:00
ribardej
3a7580c315 feat(backend): added missing untracked files 2025-10-15 15:08:18 +02:00
ribardej
c21af2732e feat(backend): implemented self delete for users 2025-10-15 11:11:04 +02:00
ribardej
f208e73986 feat(frontend): added account and appearance tabs 2025-10-15 11:00:47 +02:00
ribardej
eb087e457c feat(frontend): improved and centered UI 2025-10-15 10:06:22 +02:00
ribardej
89d032dd69 feat(frontend): introduced a working frontend prototype 2025-10-14 11:34:25 +02:00
e200c73b47 fix(backend): use correct variable to register routers
Some checks failed
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-10-13 17:11:31 +02:00
Dejan Ribarovski
ac10ab381e Merge pull request #26 from dat515-2025/20-create-a-controller-layer-on-backend-side
20 create a controller layer on backend side
2025-10-13 14:05:05 +02:00
Dejan Ribarovski
879109144c Merge branch 'main' into 20-create-a-controller-layer-on-backend-side 2025-10-13 14:03:24 +02:00
ribardej
7061e57442 Merge remote-tracking branch 'origin/20-create-a-controller-layer-on-backend-side' into 20-create-a-controller-layer-on-backend-side 2025-10-13 13:57:04 +02:00
ribardej
30068079c6 feat(backend): renamed endpoints for consistency 2025-10-13 13:56:44 +02:00
Dejan Ribarovski
9580bea630 Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:36 +02:00
Dejan Ribarovski
975f5e5bec Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:24 +02:00
ribardej
f1065bc274 feat(backend): update consistent Pydantic v2 use everywhere 2025-10-13 13:50:59 +02:00
Dejan Ribarovski
12152238c6 Merge pull request #23 from dat515-2025/merge/oauth
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Waiting to run
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
feat(auth): add support for OAuth and MojeID
2025-10-13 12:46:17 +02:00
Dejan Ribarovski
21ef5a3961 Merge pull request #25 from dat515-2025/merge/database_backups
feat(infrastructure): add backups
2025-10-13 12:41:27 +02:00
ribardej
2f20fb12e4 feat(backend): implemented basic controller layer 2025-10-13 12:07:47 +02:00
bf213234b1 feat(infrastructure): add backups 2025-10-12 20:14:48 +02:00
ribardej
6c248039ac feat(backend): fixed DB user schema 2025-10-10 16:16:43 +02:00
25 changed files with 1288 additions and 108 deletions

View File

@@ -0,0 +1,49 @@
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"]
)
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)

View File

@@ -0,0 +1,77 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/categories", tags=["categories"])
@router.post("/create", response_model=CategoryRead, status_code=status.HTTP_201_CREATED)
async def create_category(
payload: CategoryCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Enforce per-user unique name via query to provide 409 feedback
res = await session.execute(
select(Category).where(Category.user_id == user.id, Category.name == payload.name)
)
existing = res.scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category = Category(name=payload.name, description=payload.description, user_id=user.id)
session.add(category)
await session.commit()
await session.refresh(category)
return category
@router.get("/", response_model=List[CategoryRead])
async def list_categories(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(select(Category).where(Category.user_id == user.id))
return list(res.scalars())
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
return category
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category.id).where(Category.id == category_id, Category.user_id == user.id)
)
if res.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Category not found")
await session.execute(
delete(Category).where(Category.id == category_id, Category.user_id == user.id)
)
await session.commit()
return None

View File

@@ -0,0 +1,219 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
from app.models.categories import Category
from app.schemas.transaction import (
TransactionCreate,
TransactionRead,
TransactionUpdate,
)
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/transactions", tags=["transactions"])
def _to_read_model(tx: Transaction) -> TransactionRead:
return TransactionRead(
id=tx.id,
amount=tx.amount,
description=tx.description,
category_ids=[c.id for c in (tx.categories or [])],
)
@router.post("/create", response_model=TransactionRead, status_code=status.HTTP_201_CREATED)
async def create_transaction(
payload: TransactionCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Attach categories if provided (and owned by user)
if payload.category_ids:
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(set(payload.category_ids)):
raise HTTPException(
status_code=400,
detail="Duplicate category IDs provided or one or more categories not found"
)
tx.categories = categories
session.add(tx)
await session.commit()
await session.refresh(tx)
# Ensure categories are loaded
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
for tx in txs:
await session.refresh(tx, attribute_names=["categories"])
return [_to_read_model(tx) for tx in txs]
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.patch("/{transaction_id}/edit", response_model=TransactionRead)
async def update_transaction(
transaction_id: int,
payload: TransactionUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
if payload.amount is not None:
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment
await session.refresh(tx, attribute_names=["categories"])
if payload.category_ids:
# Check for duplicate category IDs in the payload
if len(payload.category_ids) != len(set(payload.category_ids)):
raise HTTPException(status_code=400, detail="Duplicate category IDs in payload")
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(payload.category_ids):
raise HTTPException(status_code=400, detail="One or more categories not found")
tx.categories = categories
else:
tx.categories = []
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/delete", status_code=status.HTTP_204_NO_CONTENT)
async def delete_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.delete(tx)
await session.commit()
return None
@router.post("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def assign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Load transaction and category ensuring ownership
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat not in tx.categories:
tx.categories.append(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def unassign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat in tx.categories:
tx.categories.remove(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)

View File

@@ -1,9 +1,17 @@
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
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import current_active_verified_user
from app.api.auth import router as auth_router
from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider
fastApi = FastAPI()
@@ -14,35 +22,37 @@ fastApi.add_middleware(
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
os.getenv("FRONTEND_DOMAIN_SCHEME", "")
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
fastApi.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
fastApi.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
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),
"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(

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
class CategoryCreate(CategoryBase):
pass
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,21 @@
from typing import List, Optional
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,13 +4,13 @@ from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
surname: Optional[str] = None
last_name: Optional[str] = None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None
surname: Optional[str] = None
last_name: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None
surname: Optional[str] = None
last_name: Optional[str] = None

View File

@@ -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. */

View File

@@ -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<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 (
<>
<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>
</>
)
<Dashboard onLogout={() => { logout(); setHasToken(false); }} />
);
}
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 { 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(
<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; }

View File

@@ -96,6 +96,13 @@ module "database" {
phpmyadmin_enabled = var.phpmyadmin_enabled
cloudflare_domain = var.cloudflare_domain
s3_enabled = var.s3_enabled
s3_bucket = var.s3_bucket
s3_region = var.s3_region
s3_endpoint = var.s3_endpoint
s3_key_id = var.s3_key_id
s3_key_secret = var.s3_key_secret
}
#module "argocd" {

View File

@@ -1,4 +1,4 @@
apiVersion: v2
name: maxscale-helm
version: 1.0.8
version: 1.0.14
description: Helm chart for MaxScale related Kubernetes manifests

View File

@@ -0,0 +1,42 @@
{{- if .Values.s3.enabled }}
apiVersion: k8s.mariadb.com/v1alpha1
kind: Backup
metadata:
name: backup
namespace: mariadb-operator
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
schedule:
cron: "0 */3 * * *"
suspend: false
timeZone: "Europe/Prague"
maxRetention: 720h # 30 days
compression: bzip2
storage:
s3:
bucket: {{ .Values.s3.bucket | quote }}
endpoint: {{ .Values.s3.endpoint | quote }}
accessKeyIdSecretKeyRef:
name: s3-credentials
key: key_id
secretAccessKeySecretKeyRef:
name: s3-credentials
key: secret_key
region: {{ .Values.s3.region | quote }}
tls:
enabled: true
# Define a PVC to use as staging area for keeping the backups while they are being processed.
stagingStorage:
persistentVolumeClaim:
resources:
requests:
storage: 10Gi
accessModes:
- ReadWriteOnce
args:
- --single-transaction
- --all-databases
logLevel: info
{{- end }}

View File

@@ -0,0 +1,11 @@
{{- if .Values.s3.enabled }}
apiVersion: v1
kind: Secret
metadata:
name: s3-credentials
namespace: mariadb-operator
type: Opaque
stringData:
key_id: "{{ .Values.s3.key_id }}"
secret_key: "{{ .Values.s3.key_secret }}"
{{- end }}

View File

@@ -14,4 +14,12 @@ metallb:
phpmyadmin:
enabled: true
s3:
enabled: false
endpoint: ""
region: ""
bucket: ""
key_id: ""
key_secret: ""
base_domain: example.com

View File

@@ -59,7 +59,7 @@ resource "helm_release" "mariadb-operator" {
resource "helm_release" "maxscale_helm" {
name = "maxscale-helm"
chart = "${path.module}/charts/maxscale-helm"
version = "1.0.8"
version = "1.0.14"
depends_on = [helm_release.mariadb-operator-crds, kubectl_manifest.secrets]
timeout = 3600
@@ -71,6 +71,12 @@ resource "helm_release" "maxscale_helm" {
{ name = "metallb.primary_ip", value = var.primary_ip },
{ name = "metallb.secondary_ip", value = var.secondary_ip },
{ name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) },
{ name = "base_domain", value = var.cloudflare_domain }
{ name = "base_domain", value = var.cloudflare_domain },
{ name = "s3.key_id", value = var.s3_key_id },
{ name = "s3.key_secret", value = var.s3_key_secret },
{ name = "s3.enabled", value = var.s3_enabled },
{ name = "s3.endpoint", value = var.s3_endpoint },
{ name = "s3.region", value = var.s3_region },
{ name = "s3.bucket", value = var.s3_bucket },
]
}

View File

@@ -56,3 +56,35 @@ variable "cloudflare_domain" {
default = "Base cloudflare domain, e.g. example.com"
nullable = false
}
variable "s3_key_id" {
description = "S3 Key ID for backups"
type = string
sensitive = true
}
variable "s3_key_secret" {
description = "S3 Key Secret for backups"
type = string
sensitive = true
}
variable "s3_enabled" {
description = "Enable S3 backups"
type = bool
}
variable "s3_endpoint" {
description = "S3 endpoint for backups"
type = string
}
variable "s3_region" {
description = "S3 region for backups"
type = string
}
variable "s3_bucket" {
description = "S3 bucket name for backups"
type = string
}

View File

@@ -108,3 +108,40 @@ variable "rabbitmq-password" {
sensitive = true
description = "Admin password for RabbitMQ user"
}
variable "s3_key_id" {
description = "S3 Key ID for backups"
type = string
sensitive = true
nullable = false
}
variable "s3_key_secret" {
description = "S3 Key Secret for backups"
type = string
sensitive = true
nullable = false
}
variable "s3_enabled" {
description = "Enable S3 backups"
type = bool
}
variable "s3_endpoint" {
description = "S3 endpoint for backups"
type = string
}
variable "s3_region" {
description = "S3 region for backups"
type = string
}
variable "s3_bucket" {
description = "S3 bucket name for backups"
type = string
}