Initial commit

This commit is contained in:
MUTS 2026-01-21 08:13:53 +04:00
commit 14eacfba5d
24 changed files with 9461 additions and 0 deletions

11
.env Normal file
View file

@ -0,0 +1,11 @@
# Server
PORT=3001
JWT_SECRET=change-me-in-production
# Admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
# Active users
ACTIVE_WINDOW_MINUTES=15
# Postgres
DATABASE_URL=postgresql://finntrack:finntrack@localhost:5432/finntrack

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

117
README-AUTH.md Normal file
View file

@ -0,0 +1,117 @@
# FinnTrack Authentication System
This project includes a full authentication system with both frontend and backend components.
## Backend Setup
The backend server is built with Express.js and includes:
- User registration with password hashing
- User login with JWT token generation
- Protected routes with JWT authentication
- In-memory user storage (for development)
### Backend Features:
- **POST /api/register** - Register new user
- **POST /api/login** - Login user and get JWT token
- **GET /api/profile** - Get user profile (protected route)
- **GET /api/users** - Get all users (for testing)
### Security Features:
- Password hashing with bcryptjs
- JWT token authentication
- Input validation
- CORS enabled
## Frontend Features:
- Beautiful Material UI login/registration interface
- Full-screen gradient background
- Form validation
- Loading states
- Error handling
- Token storage in localStorage
## How to Run
### Option 1: Run Frontend and Backend Separately
1. **Start the backend server:**
```bash
npm run server
```
Backend will run on http://localhost:3001
2. **Start the frontend development server:**
```bash
npm run dev
```
Frontend will run on http://localhost:5174
### Option 2: Run Both Simultaneously
```bash
npm run dev:full
```
This will start both the backend server and frontend development server concurrently.
## Testing the Authentication
### Registration:
1. Go to the "Register" tab
2. Fill in First Name, Last Name, Username, and Password
3. Click "Register"
4. Success message will appear and switch to login tab
### Login:
1. Go to the "Login" tab
2. Enter your username and password
3. Click "Login"
4. Success message will appear and user data will be stored in localStorage
## API Response Format
### Success Response:
```json
{
"success": true,
"message": "Login successful",
"token": "jwt-token-here",
"user": {
"id": "user-id",
"firstName": "John",
"lastName": "Doe",
"username": "johndoe"
}
}
```
### Error Response:
```json
{
"success": false,
"message": "Error message here"
}
```
## Storage
- **JWT Token**: Stored in localStorage as 'token'
- **User Data**: Stored in localStorage as 'user'
- **Users**: In-memory array (resets on server restart)
## Next Steps for Production
1. Replace in-memory storage with a database (MongoDB, PostgreSQL, etc.)
2. Add environment variables for JWT secret and database connection
3. Add email verification for registration
4. Add password reset functionality
5. Add rate limiting for API endpoints
6. Add more comprehensive input validation
7. Add logging and monitoring
## Development Notes
- The backend runs on port 3001
- The frontend runs on port 5174 (or next available)
- CORS is enabled to allow frontend-backend communication
- JWT tokens expire after 24 hours
- Passwords are hashed with bcryptjs (salt rounds: 10)

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

35
db/init/001_init.sql Normal file
View file

@ -0,0 +1,35 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS incomes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
description TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL CHECK (amount >= 0),
category TEXT NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incomes_user_id_date ON incomes(user_id, date DESC);
CREATE TABLE IF NOT EXISTS expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
description TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL CHECK (amount >= 0),
category TEXT NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_expenses_user_id_date ON expenses(user_id, date DESC);

View file

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
db:
image: postgres:16
container_name: finntrack_db
restart: unless-stopped
environment:
POSTGRES_DB: finntrack
POSTGRES_USER: finntrack
POSTGRES_PASSWORD: finntrack
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
volumes:
postgres_data:

29
eslint.config.js Normal file
View file

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>finntrack</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6338
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "finntrack",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"server": "node server.js",
"server:dev": "nodemon server.js",
"dev:full": "concurrently \"npm run server:dev\" \"npm run dev\""
},
"dependencies": {
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.9",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@mui/styled-engine-sc": "^7.3.7",
"@toolpad/core": "^0.16.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.13.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.6.0",
"styled-components": "^6.3.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"nodemon": "^3.1.11",
"vite": "^7.2.4"
}
}

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

517
server.js Normal file
View file

@ -0,0 +1,517 @@
import express from 'express';
import cors from 'cors';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import pg from 'pg';
dotenv.config();
const app = express();
const PORT = Number(process.env.PORT) || 3001;
const JWT_SECRET = process.env.JWT_SECRET;
const DATABASE_URL = process.env.DATABASE_URL;
const ADMIN_USERNAME = process.env.ADMIN_USERNAME;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const ACTIVE_WINDOW_MINUTES = Number(process.env.ACTIVE_WINDOW_MINUTES) || 15;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is not set. Add it to .env');
}
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is not set. Add it to .env');
}
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
console.warn('ADMIN_USERNAME/ADMIN_PASSWORD are not set. Admin login will be disabled.');
}
const { Pool } = pg;
const pool = new Pool({ connectionString: DATABASE_URL });
// Middleware
app.use(cors());
app.use(express.json());
const authRequired = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
message: 'Токен не предоставлен'
});
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Недействительный токен'
});
}
};
const adminRequired = (req, res, next) => {
if (req.user?.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Доступ запрещён'
});
}
return next();
};
// Register endpoint
app.post('/api/register', async (req, res) => {
try {
const { firstName, lastName, username, password } = req.body;
// Validation
if (!firstName || !lastName || !username || !password) {
return res.status(400).json({
success: false,
message: 'Все поля обязательны для заполнения'
});
}
// Check if user already exists
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
if (existing.rows.length > 0) {
return res.status(400).json({
success: false,
message: 'Пользователь с таким именем уже существует'
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
const insert = await pool.query(
'INSERT INTO users (first_name, last_name, username, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, first_name, last_name, username',
[firstName, lastName, username, hashedPassword]
);
const newUser = insert.rows[0];
res.status(201).json({
success: true,
message: 'Регистрация прошла успешно',
user: {
id: newUser.id,
firstName: newUser.first_name,
lastName: newUser.last_name,
username: newUser.username
}
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
success: false,
message: 'Internal server error'
});
}
});
// Login endpoint
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
// Validation
if (!username || !password) {
return res.status(400).json({
success: false,
message: 'Имя пользователя и пароль обязательны'
});
}
// Admin login (credentials stored in .env)
if (ADMIN_USERNAME && ADMIN_PASSWORD && username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const token = jwt.sign(
{
userId: '00000000-0000-0000-0000-000000000000',
username,
role: 'admin'
},
JWT_SECRET,
{ expiresIn: '24h' }
);
return res.json({
success: true,
message: 'Вход выполнен успешно',
token,
user: {
id: 'admin',
firstName: 'Администратор',
lastName: '',
username,
role: 'admin'
}
});
}
// Find user
const result = await pool.query(
'SELECT id, first_name, last_name, username, password_hash FROM users WHERE username = $1',
[username]
);
const user = result.rows[0];
if (!user) {
return res.status(401).json({
success: false,
message: 'Неверные учетные данные'
});
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Неверные учетные данные'
});
}
// Generate JWT token
const token = jwt.sign(
{
userId: user.id,
username: user.username
},
JWT_SECRET,
{ expiresIn: '24h' }
);
await pool.query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [user.id]);
res.json({
success: true,
message: 'Вход выполнен успешно',
token,
user: {
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
username: user.username
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Protected route example
app.get('/api/profile', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const result = await pool.query(
'SELECT id, first_name, last_name, username FROM users WHERE id = $1',
[userId]
);
const user = result.rows[0];
if (!user) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
return res.json({
success: true,
user: {
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
username: user.username
}
});
} catch (error) {
console.error('Profile error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Get all users (for testing)
app.get('/api/users', async (req, res) => {
try {
const result = await pool.query(
'SELECT id, first_name, last_name, username, created_at FROM users ORDER BY created_at DESC'
);
const usersWithoutPasswords = result.rows.map(user => ({
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
username: user.username,
createdAt: user.created_at
}));
return res.json({
success: true,
users: usersWithoutPasswords
});
} catch (error) {
console.error('Users list error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Incomes
app.get('/api/incomes', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const result = await pool.query(
'SELECT id, description, amount, category, date, created_at FROM incomes WHERE user_id = $1 ORDER BY date DESC, created_at DESC',
[userId]
);
return res.json({
success: true,
incomes: result.rows
});
} catch (error) {
console.error('Get incomes error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
app.post('/api/incomes', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const { description, amount, category, date } = req.body;
if (!description || amount === undefined || amount === null || !category || !date) {
return res.status(400).json({
success: false,
message: 'Все поля обязательны для заполнения'
});
}
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
return res.status(400).json({
success: false,
message: 'Некорректная сумма'
});
}
const insert = await pool.query(
'INSERT INTO incomes (user_id, description, amount, category, date) VALUES ($1, $2, $3, $4, $5) RETURNING id, description, amount, category, date, created_at',
[userId, description, parsedAmount, category, date]
);
return res.status(201).json({
success: true,
income: insert.rows[0]
});
} catch (error) {
console.error('Create income error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
app.delete('/api/incomes/:id', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
const result = await pool.query(
'DELETE FROM incomes WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Запись не найдена'
});
}
return res.json({
success: true
});
} catch (error) {
console.error('Delete income error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Expenses
app.get('/api/expenses', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const result = await pool.query(
'SELECT id, description, amount, category, date, created_at FROM expenses WHERE user_id = $1 ORDER BY date DESC, created_at DESC',
[userId]
);
return res.json({
success: true,
expenses: result.rows
});
} catch (error) {
console.error('Get expenses error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
app.post('/api/expenses', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const { description, amount, category, date } = req.body;
if (!description || amount === undefined || amount === null || !category || !date) {
return res.status(400).json({
success: false,
message: 'Все поля обязательны для заполнения'
});
}
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
return res.status(400).json({
success: false,
message: 'Некорректная сумма'
});
}
const insert = await pool.query(
'INSERT INTO expenses (user_id, description, amount, category, date) VALUES ($1, $2, $3, $4, $5) RETURNING id, description, amount, category, date, created_at',
[userId, description, parsedAmount, category, date]
);
return res.status(201).json({
success: true,
expense: insert.rows[0]
});
} catch (error) {
console.error('Create expense error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
app.delete('/api/expenses/:id', authRequired, async (req, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
const result = await pool.query(
'DELETE FROM expenses WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Запись не найдена'
});
}
return res.json({
success: true
});
} catch (error) {
console.error('Delete expense error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Admin
app.get('/api/admin/users', authRequired, adminRequired, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, first_name, last_name, username, created_at, last_login_at FROM users ORDER BY created_at DESC'
);
const usersList = result.rows.map(user => ({
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
username: user.username,
createdAt: user.created_at,
lastLoginAt: user.last_login_at
}));
return res.json({
success: true,
users: usersList
});
} catch (error) {
console.error('Admin users list error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
app.get('/api/admin/active-users', authRequired, adminRequired, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, first_name, last_name, username, last_login_at FROM users WHERE last_login_at IS NOT NULL AND last_login_at > (NOW() - ($1::int * INTERVAL \'1 minute\')) ORDER BY last_login_at DESC',
[ACTIVE_WINDOW_MINUTES]
);
const usersList = result.rows.map(user => ({
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
username: user.username,
lastLoginAt: user.last_login_at
}));
return res.json({
success: true,
activeWindowMinutes: ACTIVE_WINDOW_MINUTES,
users: usersList
});
} catch (error) {
console.error('Admin active users error:', error);
return res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log('Available endpoints:');
console.log('POST /api/register - Register new user');
console.log('POST /api/login - Login user');
console.log('GET /api/profile - Get user profile (protected)');
console.log('GET /api/users - Get all users (for testing)');
});

42
src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
margin: 0;
padding: 0;
min-height: 100vh;
width: 100%;
}
.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;
}

52
src/App.jsx Normal file
View file

@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import LoginPage from './components/LoginPage';
import Dashboard from './components/Dashboard';
import AdminPanel from './components/AdminPanel';
import { isAuthenticated, getCurrentUser } from './services/auth';
import './App.css';
function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAuth = () => {
const isAuth = isAuthenticated();
setAuthenticated(isAuth);
setLoading(false);
};
checkAuth();
}, []);
const handleLoginSuccess = () => {
setAuthenticated(true);
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}}>
<div style={{ color: 'white', fontSize: '24px' }}>Загрузка...</div>
</div>
);
}
if (!authenticated) {
return <LoginPage onLoginSuccess={handleLoginSuccess} />;
}
const user = getCurrentUser();
if (user?.role === 'admin') {
return <AdminPanel />;
}
return <Dashboard />;
}
export default App;

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from 'react';
import {
AppBar,
Box,
Button,
Card,
CardContent,
Container,
Grid,
Toolbar,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper
} from '@mui/material';
import { Refresh, Logout } from '@mui/icons-material';
import { adminGetActiveUsers, adminGetUsers } from '../services/api';
import { logout } from '../services/auth';
const AdminPanel = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [users, setUsers] = useState([]);
const [activeUsers, setActiveUsers] = useState([]);
const [activeWindowMinutes, setActiveWindowMinutes] = useState(null);
const formatDateTime = useMemo(() => {
const fmt = new Intl.DateTimeFormat('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return (value) => {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return fmt.format(d);
};
}, []);
const load = async () => {
setLoading(true);
setError('');
try {
const [usersRes, activeRes] = await Promise.all([adminGetUsers(), adminGetActiveUsers()]);
setUsers(usersRes.users || []);
setActiveUsers(activeRes.users || []);
setActiveWindowMinutes(activeRes.activeWindowMinutes ?? null);
} catch (e) {
setError(e?.message || 'Ошибка загрузки админ‑панели');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f7fa' }}>
<AppBar
position="static"
sx={{
backdropFilter: 'blur(10px)',
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
background: 'linear-gradient(135deg, rgba(26, 35, 126, 0.96) 0%, rgba(44, 62, 80, 0.96) 100%)',
borderBottom: '2px solid rgba(255,255,255,0.2)'
}}
>
<Toolbar sx={{ px: { xs: 2, sm: 3, md: 4 }, py: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1, minWidth: 0 }}>
<Box
sx={{
width: 34,
height: 34,
borderRadius: 2,
mr: 1.5,
background: 'linear-gradient(135deg, rgba(102, 126, 234, 1) 0%, rgba(118, 75, 162, 1) 100%)',
boxShadow: '0 10px 22px rgba(102, 126, 234, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography sx={{ color: '#fff', fontWeight: 900, fontSize: 18, lineHeight: 1, textShadow: '0 2px 10px rgba(0,0,0,0.25)' }}>
$
</Typography>
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h6"
component="div"
sx={{
fontWeight: 900,
letterSpacing: 0.6,
color: '#ffffff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.15
}}
>
FinnTrack
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 700 }}>
Админпанель
</Typography>
</Box>
</Box>
<Button color="inherit" startIcon={<Refresh />} onClick={load} sx={{ mr: 1 }}>
Обновить
</Button>
<Button color="inherit" startIcon={<Logout />} onClick={logout}>
Выйти
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ py: 4 }}>
{error && (
<Card sx={{ mb: 3, borderRadius: 3, border: '1px solid rgba(231, 76, 60, 0.35)' }}>
<CardContent>
<Typography sx={{ color: '#e74c3c', fontWeight: 700 }}>{error}</Typography>
</CardContent>
</Card>
)}
<Grid container spacing={3}>
<Grid item xs={12} lg={6}>
<Card sx={{ borderRadius: 3, boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
<CardContent sx={{ p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 2, color: '#2c3e50' }}>
Все пользователи ({users.length})
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Имя</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Логин</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Создан</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Последний вход</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4}>Загрузка...</TableCell>
</TableRow>
) : (
users.map((u) => (
<TableRow key={u.id}>
<TableCell>{`${u.firstName || ''} ${u.lastName || ''}`.trim()}</TableCell>
<TableCell>{u.username}</TableCell>
<TableCell>{formatDateTime(u.createdAt)}</TableCell>
<TableCell>{formatDateTime(u.lastLoginAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} lg={6}>
<Card sx={{ borderRadius: 3, boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
<CardContent sx={{ p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 2, color: '#2c3e50' }}>
Активные пользователи ({activeUsers.length})
{activeWindowMinutes !== null ? ` (за последние ${activeWindowMinutes} мин.)` : ''}
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 800 }}>Имя</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Логин</TableCell>
<TableCell sx={{ fontWeight: 800 }}>Последний вход</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3}>Загрузка...</TableCell>
</TableRow>
) : (
activeUsers.map((u) => (
<TableRow key={u.id}>
<TableCell>{`${u.firstName || ''} ${u.lastName || ''}`.trim()}</TableCell>
<TableCell>{u.username}</TableCell>
<TableCell>{formatDateTime(u.lastLoginAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
</Box>
);
};
export default AdminPanel;

1319
src/components/Dashboard.jsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,412 @@
import { useState } from 'react';
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
Tabs,
Tab,
Alert,
Container,
Paper,
CircularProgress,
IconButton,
InputAdornment
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { SignInPage } from '@toolpad/core';
import { registerUser, loginUser } from '../services/api';
import { ruRU } from '@mui/material/locale';
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
}, ruRU);
function TabPanel({ children, value, index, ...other }) {
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
const LoginPage = ({ onLoginSuccess }) => {
const [tabValue, setTabValue] = useState(0);
const [loginData, setLoginData] = useState({
username: '',
password: ''
});
const [registerData, setRegisterData] = useState({
firstName: '',
lastName: '',
username: '',
password: ''
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [showLoginPassword, setShowLoginPassword] = useState(false);
const [showRegisterPassword, setShowRegisterPassword] = useState(false);
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
setError('');
setSuccess('');
};
const handleLoginChange = (field) => (event) => {
setLoginData({
...loginData,
[field]: event.target.value
});
};
const handleRegisterChange = (field) => (event) => {
setRegisterData({
...registerData,
[field]: event.target.value
});
};
const handleLogin = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
if (!loginData.username || !loginData.password) {
setError('Пожалуйста, заполните все поля');
return;
}
setLoading(true);
try {
const response = await loginUser(loginData);
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
setSuccess('Вход выполнен успешно!');
setLoginData({ username: '', password: '' });
console.log('Login successful:', response);
// Call the success callback to redirect to dashboard
setTimeout(() => {
onLoginSuccess();
}, 1000);
} catch (err) {
setError(err.message || 'Ошибка входа');
} finally {
setLoading(false);
}
};
const handleRegister = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
if (!registerData.firstName || !registerData.lastName || !registerData.username || !registerData.password) {
setError('Пожалуйста, заполните все поля');
return;
}
setLoading(true);
try {
const response = await registerUser(registerData);
setSuccess('Регистрация прошла успешно! Пожалуйста, войдите.');
setRegisterData({ firstName: '', lastName: '', username: '', password: '' });
setTabValue(0); // Switch to login tab after successful registration
console.log('Registration successful:', response);
} catch (err) {
setError(err.message || 'Ошибка регистрации');
} finally {
setLoading(false);
}
};
return (
<ThemeProvider theme={theme}>
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
py: 4,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'url("data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.05"%3E%3Cpath d="M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
}
}}
>
<Container maxWidth="sm">
<Paper
elevation={8}
sx={{
width: '100%',
maxWidth: 500,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: 3,
overflow: 'hidden'
}}
>
<Box sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
py: 3,
textAlign: 'center'
}}>
<Typography
variant="h4"
component="h1"
sx={{
color: 'white',
fontWeight: 'bold',
mb: 1
}}
>
FinnTrack
</Typography>
<Typography
variant="body1"
sx={{
color: 'rgba(255, 255, 255, 0.9)'
}}
>
Добро пожаловать! Пожалуйста, войдите в систему
</Typography>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab label="Вход" />
<Tab label="Регистрация" />
</Tabs>
</Box>
{error && (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ m: 2 }}>
{success}
</Alert>
)}
<TabPanel value={tabValue} index={0}>
<form onSubmit={handleLogin}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Имя пользователя"
variant="outlined"
value={loginData.username}
onChange={handleLoginChange('username')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
/>
<TextField
label="Пароль"
type={showLoginPassword ? 'text' : 'password'}
variant="outlined"
value={loginData.password}
onChange={handleLoginChange('password')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowLoginPassword(!showLoginPassword)}
edge="end"
>
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={loading}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
py: 1.5,
fontWeight: 'bold',
'&:hover': {
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
},
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Войти'}
</Button>
</Box>
</form>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<form onSubmit={handleRegister}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Имя"
variant="outlined"
value={registerData.firstName}
onChange={handleRegisterChange('firstName')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
/>
<TextField
label="Фамилия"
variant="outlined"
value={registerData.lastName}
onChange={handleRegisterChange('lastName')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
/>
<TextField
label="Имя пользователя"
variant="outlined"
value={registerData.username}
onChange={handleRegisterChange('username')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
/>
<TextField
label="Пароль"
type={showRegisterPassword ? 'text' : 'password'}
variant="outlined"
value={registerData.password}
onChange={handleRegisterChange('password')}
fullWidth
required
sx={{
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowRegisterPassword(!showRegisterPassword)}
edge="end"
>
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={loading}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
py: 1.5,
fontWeight: 'bold',
'&:hover': {
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
},
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Зарегистрироваться'}
</Button>
</Box>
</form>
</TabPanel>
</Paper>
</Container>
</Box>
</ThemeProvider>
);
}
export default LoginPage;

67
src/index.css Normal file
View file

@ -0,0 +1,67 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
width: 100%;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

148
src/services/api.js Normal file
View file

@ -0,0 +1,148 @@
const API_BASE_URL = 'http://localhost:3001/api';
// Helper function to handle API responses
const handleResponse = async (response) => {
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Something went wrong');
}
return data;
};
// Register user
export const registerUser = async (userData) => {
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
return handleResponse(response);
};
// Login user
export const loginUser = async (credentials) => {
const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
return handleResponse(response);
};
// Get user profile
export const getUserProfile = async (token) => {
const response = await fetch(`${API_BASE_URL}/profile`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
return handleResponse(response);
};
// Get all users (for testing)
export const getAllUsers = async () => {
const response = await fetch(`${API_BASE_URL}/users`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return handleResponse(response);
};
const authHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
};
// Incomes
export const getIncomes = async () => {
const response = await fetch(`${API_BASE_URL}/incomes`, {
method: 'GET',
headers: authHeaders(),
});
return handleResponse(response);
};
export const createIncome = async (income) => {
const response = await fetch(`${API_BASE_URL}/incomes`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(income),
});
return handleResponse(response);
};
export const deleteIncome = async (id) => {
const response = await fetch(`${API_BASE_URL}/incomes/${id}`, {
method: 'DELETE',
headers: authHeaders(),
});
return handleResponse(response);
};
// Expenses
export const getExpenses = async () => {
const response = await fetch(`${API_BASE_URL}/expenses`, {
method: 'GET',
headers: authHeaders(),
});
return handleResponse(response);
};
export const createExpense = async (expense) => {
const response = await fetch(`${API_BASE_URL}/expenses`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(expense),
});
return handleResponse(response);
};
export const deleteExpense = async (id) => {
const response = await fetch(`${API_BASE_URL}/expenses/${id}`, {
method: 'DELETE',
headers: authHeaders(),
});
return handleResponse(response);
};
// Admin
export const adminGetUsers = async () => {
const response = await fetch(`${API_BASE_URL}/admin/users`, {
method: 'GET',
headers: authHeaders(),
});
return handleResponse(response);
};
export const adminGetActiveUsers = async () => {
const response = await fetch(`${API_BASE_URL}/admin/active-users`, {
method: 'GET',
headers: authHeaders(),
});
return handleResponse(response);
};

18
src/services/auth.js Normal file
View file

@ -0,0 +1,18 @@
// Authentication service for managing user sessions
export const isAuthenticated = () => {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
return !!(token && user);
};
export const getCurrentUser = () => {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
};
export const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
};

7
vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})