ci/cd + https + front
This commit is contained in:
@@ -7,3 +7,8 @@ DATABASE_URL=postgresql://your_db_user:your_secure_password@localhost:5435/famil
|
|||||||
|
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|
||||||
|
DOMAIN=yourdomain.com
|
||||||
|
EMAIL=your@email.com
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS=https://yourdomain.com
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
|
||||||
|
certbot/
|
||||||
|
nginx/conf.d/app-ssl.conf
|
||||||
|
.env
|
||||||
|
|||||||
@@ -152,14 +152,16 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
let swagger_ui = SwaggerUi::new("/swagger-ui")
|
let swagger_ui = SwaggerUi::new("/swagger-ui")
|
||||||
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
||||||
|
|
||||||
|
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string());
|
||||||
|
|
||||||
|
let origins: Vec<HeaderValue> = allowed_origins
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|origin| origin.trim().parse::<HeaderValue>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin([
|
.allow_origin(origins)
|
||||||
"http://localhost:3000".parse::<HeaderValue>().unwrap(),
|
|
||||||
"http://localhost:5173".parse::<HeaderValue>().unwrap(),
|
|
||||||
"http://localhost:5174".parse::<HeaderValue>().unwrap(),
|
|
||||||
"http://localhost:5175".parse::<HeaderValue>().unwrap(),
|
|
||||||
"http://localhost:8080".parse::<HeaderValue>().unwrap(),
|
|
||||||
])
|
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
axum::http::header::CONTENT_TYPE,
|
axum::http::header::CONTENT_TYPE,
|
||||||
@@ -168,9 +170,10 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
])
|
])
|
||||||
.allow_credentials(true);
|
.allow_credentials(true);
|
||||||
|
|
||||||
let app = api_routes
|
let app = Router::new()
|
||||||
.layer(cors)
|
.nest("/api", api_routes)
|
||||||
.merge(swagger_ui);
|
.merge(swagger_ui)
|
||||||
|
.layer(cors);
|
||||||
|
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
deploy-update.tar.gz
Normal file
BIN
deploy-update.tar.gz
Normal file
Binary file not shown.
@@ -21,8 +21,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
ports:
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
||||||
- "8080:8080"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
@@ -31,16 +30,44 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
image: ghcr.io/${OWNER:-${COMPOSE_PROJECT_NAME}}/family_budget-frontend:latest
|
image: ghcr.io/${OWNER:-${COMPOSE_PROJECT_NAME}}/family_budget-frontend:latest
|
||||||
container_name: family_budget_frontend
|
container_name: family_budget_frontend
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- app_network
|
- app_network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: family_budget_nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- certbot_www:/var/www/certbot:ro
|
||||||
|
- certbot_conf:/etc/letsencrypt:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: family_budget_certbot
|
||||||
|
volumes:
|
||||||
|
- certbot_www:/var/www/certbot:rw
|
||||||
|
- certbot_conf:/etc/letsencrypt:rw
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
certbot_www:
|
||||||
|
driver: local
|
||||||
|
certbot_conf:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app_network:
|
app_network:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:3000
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RUN npm ci
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_BASE_URL=/
|
ARG VITE_API_BASE_URL=/api
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>Family budget</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
<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>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y="80" font-size="80" text-anchor="middle" x="50">💰</text>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 140 B |
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { categoryApi, expenseApi } from '../api/client';
|
import { categoryApi, expenseApi } from '../api/client';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import type { Category } from '../types';
|
import type { Category, Expense } from '../types';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
X,
|
X,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Tag,
|
Tag,
|
||||||
|
History,
|
||||||
|
Calendar,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function FamilyView() {
|
export default function FamilyView() {
|
||||||
@@ -34,6 +37,9 @@ export default function FamilyView() {
|
|||||||
const [expenseAmount, setExpenseAmount] = useState('');
|
const [expenseAmount, setExpenseAmount] = useState('');
|
||||||
const [expenseDescription, setExpenseDescription] = useState('');
|
const [expenseDescription, setExpenseDescription] = useState('');
|
||||||
|
|
||||||
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
|
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!familyId) {
|
if (!familyId) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -150,6 +156,27 @@ export default function FamilyView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowHistory = async (categoryId: number) => {
|
||||||
|
if (!familyId) return;
|
||||||
|
|
||||||
|
if (showHistory === categoryId) {
|
||||||
|
setShowHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await expenseApi.getAllByCategory(
|
||||||
|
parseInt(familyId),
|
||||||
|
categoryId
|
||||||
|
);
|
||||||
|
setCategoryExpenses(response.data);
|
||||||
|
setShowHistory(categoryId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка загрузки истории трат');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center gradient-bg">
|
<div className="min-h-screen flex items-center justify-center gradient-bg">
|
||||||
@@ -172,6 +199,21 @@ export default function FamilyView() {
|
|||||||
return Math.max(0, Math.min(100, (remaining / limit) * 100));
|
return Math.max(0, Math.min(100, (remaining / limit) * 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
let dateStr = dateString;
|
||||||
|
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
|
||||||
|
dateStr = dateStr + 'Z';
|
||||||
|
}
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
@@ -214,14 +256,14 @@ export default function FamilyView() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 card-hover"
|
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4 mb-6">
|
<div className="flex items-center justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-3 bg-linear-to-br from-purple-500 to-blue-500 text-white rounded-2xl shadow-lg">
|
<div className="p-2 bg-linear-to-br from-purple-500 to-blue-500 text-white rounded-xl shadow-lg">
|
||||||
<Tag className="w-8 h-8" />
|
<Tag className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900">
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,38 +271,113 @@ export default function FamilyView() {
|
|||||||
{showAddExpense !== category.id && (
|
{showAddExpense !== category.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddExpense(category.id)}
|
onClick={() => setShowAddExpense(category.id)}
|
||||||
className="flex items-center gap-2 px-5 py-3 bg-linear-to-r from-red-500 to-pink-500 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold whitespace-nowrap"
|
className="flex items-center gap-2 px-4 py-2 bg-linear-to-r from-red-500 to-pink-500 text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
|
||||||
>
|
>
|
||||||
<TrendingDown className="w-5 h-5" />
|
<TrendingDown className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Добавить расход</span>
|
<span className="hidden sm:inline">Добавить расход</span>
|
||||||
<span className="sm:hidden">Расход</span>
|
<span className="sm:hidden">Расход</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-3 mb-4">
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="text-gray-600 font-medium">Остаток:</span>
|
<span className="text-gray-600 font-medium text-sm">Остаток:</span>
|
||||||
<span className="text-3xl sm:text-4xl font-bold text-gray-900">
|
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||||
{remaining.toFixed(2)} ₽
|
{remaining.toFixed(2)} ₽
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-baseline text-gray-500">
|
<div className="flex justify-between items-baseline text-gray-500 text-sm">
|
||||||
<span>Лимит:</span>
|
<span>Лимит:</span>
|
||||||
<span className="text-lg font-semibold">{limit.toFixed(2)} ₽</span>
|
<span className="text-base font-semibold">{limit.toFixed(2)} ₽</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-4 bg-gray-200 rounded-full overflow-hidden">
|
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
|
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 text-center font-medium">
|
<p className="text-xs text-gray-500 text-center font-medium">
|
||||||
{percentage.toFixed(0)}% осталось
|
{percentage.toFixed(0)}% осталось
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResetLimit(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>Обнулить</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowHistory(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
<span>История</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCategory(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>Удалить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory === category.id && (
|
||||||
|
<div className="mt-4 bg-linear-to-br from-blue-50 to-purple-50 p-4 rounded-2xl border-2 border-blue-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
|
||||||
|
<History className="w-5 h-5" />
|
||||||
|
История трат
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory(null)}
|
||||||
|
className="p-2 hover:bg-white/50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoryExpenses.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4">Нет трат</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{categoryExpenses.map((expense) => (
|
||||||
|
<div
|
||||||
|
key={expense.id}
|
||||||
|
className="bg-white p-3 rounded-xl shadow-sm border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-red-100 rounded-lg">
|
||||||
|
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900 text-lg">
|
||||||
|
{parseFloat(expense.amount.toString()).toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{formatDate(expense.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expense.description && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded-lg">
|
||||||
|
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{expense.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showAddExpense === category.id && (
|
{showAddExpense === category.id && (
|
||||||
<div className="bg-linear-to-br from-purple-50 to-blue-50 p-6 rounded-2xl border-2 border-purple-200">
|
<div className="bg-linear-to-br from-purple-50 to-blue-50 p-6 rounded-2xl border-2 border-purple-200">
|
||||||
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
||||||
@@ -364,48 +481,12 @@ export default function FamilyView() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCategory(true)}
|
onClick={() => setShowAddCategory(true)}
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold mb-8"
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
Добавить категорию
|
Добавить категорию
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 p-5 bg-linear-to-r from-purple-50 to-blue-50 rounded-2xl border-2 border-purple-200 card-hover"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-purple-500 text-white rounded-xl">
|
|
||||||
<Tag className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-gray-800 text-lg">
|
|
||||||
{category.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleResetLimit(category.id)}
|
|
||||||
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-medium shadow-md hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">Обнулить</span>
|
|
||||||
<span className="sm:hidden">Сброс</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteCategory(category.id)}
|
|
||||||
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-medium shadow-md hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">Удалить</span>
|
|
||||||
<span className="sm:hidden">X</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
init-letsencrypt.sh
Executable file
83
init-letsencrypt.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ -z "$DOMAIN" ]; then
|
||||||
|
echo "Error: DOMAIN environment variable is not set"
|
||||||
|
echo "Usage: DOMAIN=yourdomain.com EMAIL=your@email.com ./init-letsencrypt.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$EMAIL" ]; then
|
||||||
|
echo "Error: EMAIL environment variable is not set"
|
||||||
|
echo "Usage: DOMAIN=yourdomain.com EMAIL=your@email.com ./init-letsencrypt.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "### Initializing Let's Encrypt for domain: $DOMAIN"
|
||||||
|
echo "### Email: $EMAIL"
|
||||||
|
|
||||||
|
data_path="./certbot"
|
||||||
|
rsa_key_size=4096
|
||||||
|
staging=0
|
||||||
|
|
||||||
|
if [ -d "$data_path/conf/live/$DOMAIN" ]; then
|
||||||
|
read -p "Existing certificate found for $DOMAIN. Continue and replace? (y/N) " decision
|
||||||
|
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
|
||||||
|
echo "### Downloading recommended TLS parameters ..."
|
||||||
|
mkdir -p "$data_path/conf"
|
||||||
|
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
|
||||||
|
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "### Creating dummy certificate for $DOMAIN ..."
|
||||||
|
path="/etc/letsencrypt/live/$DOMAIN"
|
||||||
|
mkdir -p "$data_path/conf/live/$DOMAIN"
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm --entrypoint "\
|
||||||
|
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
|
||||||
|
-keyout '$path/privkey.pem' \
|
||||||
|
-out '$path/fullchain.pem' \
|
||||||
|
-subj '/CN=localhost'" certbot
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "### Starting nginx ..."
|
||||||
|
docker compose -f docker-compose.prod.yml up --force-recreate -d nginx
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "### Deleting dummy certificate for $DOMAIN ..."
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm --entrypoint "\
|
||||||
|
rm -Rf /etc/letsencrypt/live/$DOMAIN && \
|
||||||
|
rm -Rf /etc/letsencrypt/archive/$DOMAIN && \
|
||||||
|
rm -Rf /etc/letsencrypt/renewal/$DOMAIN.conf" certbot
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "### Requesting Let's Encrypt certificate for $DOMAIN ..."
|
||||||
|
domain_args="-d $DOMAIN"
|
||||||
|
|
||||||
|
case "$staging" in
|
||||||
|
1) staging_arg="--staging" ;;
|
||||||
|
*) staging_arg="" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm --entrypoint "\
|
||||||
|
certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
$staging_arg \
|
||||||
|
$domain_args \
|
||||||
|
--email $EMAIL \
|
||||||
|
--rsa-key-size $rsa_key_size \
|
||||||
|
--agree-tos \
|
||||||
|
--force-renewal" certbot
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "### Enabling SSL configuration..."
|
||||||
|
envsubst '${DOMAIN}' < nginx/conf.d/app-ssl.conf.template > nginx/conf.d/app-ssl.conf
|
||||||
|
rm nginx/conf.d/app.conf
|
||||||
|
|
||||||
|
echo "### Reloading nginx ..."
|
||||||
|
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload
|
||||||
|
|
||||||
|
echo "### Done! Your site is now secured with HTTPS"
|
||||||
81
nginx/conf.d/app-ssl.conf.template
Normal file
81
nginx/conf.d/app-ssl.conf.template
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
upstream backend {
|
||||||
|
server backend:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${DOMAIN};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name ${DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /swagger-ui {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api-docs {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
nginx/conf.d/app.conf
Normal file
58
nginx/conf.d/app.conf
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
upstream backend {
|
||||||
|
server backend:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /swagger-ui {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api-docs {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
nginx/nginx.conf
Normal file
37
nginx/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/json application/javascript application/xml+rss
|
||||||
|
application/rss+xml font/truetype font/opentype
|
||||||
|
application/vnd.ms-fontobject image/svg+xml;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
9
renew-cert.sh
Executable file
9
renew-cert.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "### Renewing SSL certificate..."
|
||||||
|
docker compose -f docker-compose.prod.yml run --rm certbot renew
|
||||||
|
|
||||||
|
echo "### Reloading nginx..."
|
||||||
|
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload
|
||||||
|
|
||||||
|
echo "### Done!"
|
||||||
Reference in New Issue
Block a user