From 66c84ecdeb5b777f14dfa11fd490df28fe8e4b28 Mon Sep 17 00:00:00 2001
From: Zakhar Timoshenko
Date: Wed, 29 Apr 2026 00:55:00 +0300
Subject: [PATCH] Fix initial user creating
---
.env.example | 12 ++-
QUICKSTART.md | 4 +-
README.md | 9 +-
backend/package.json | 1 +
backend/scripts/create-admin.js | 30 +++----
backend/src/index.js | 24 ++++++
backend/src/utils/bootstrapAdmin.js | 122 ++++++++++++++++++++++++++++
backups/polotsk_transit.dump | Bin 0 -> 69145 bytes
docker-compose.yml | 28 +++++--
frontend/.env.example | 2 +-
frontend/Dockerfile | 2 +-
frontend/nginx.conf | 22 +++++
frontend/src/pages/LoginPage.jsx | 4 +-
frontend/src/utils/apiBaseUrl.js | 4 +-
frontend/vite.config.js | 12 ++-
nginx/nginx.conf | 26 ++++++
16 files changed, 267 insertions(+), 35 deletions(-)
create mode 100644 backend/src/utils/bootstrapAdmin.js
create mode 100644 backups/polotsk_transit.dump
diff --git a/.env.example b/.env.example
index 590aa22..59cd440 100644
--- a/.env.example
+++ b/.env.example
@@ -5,10 +5,18 @@ DB_PASSWORD=your_secure_password_here
API_KEY_SALT=generate_random_salt_with_openssl
CORS_ORIGIN=http://localhost:3001
+# Bootstrap admin
+BOOTSTRAP_ADMIN=true
+BOOTSTRAP_ADMIN_RESET_PASSWORD=false
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change_this_admin_password
+ADMIN_FULL_NAME=Main administrator
+ADMIN_EMAIL=admin@polotsk-transit.local
+
# Frontend
-API_URL=http://localhost:3000/api/v1
+API_URL=/api/v1
# Production settings (uncomment for production)
# NODE_ENV=production
# CORS_ORIGIN=https://admin.yourdomain.com
-# API_URL=https://api.yourdomain.com/api/v1
+# API_URL=/api/v1
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 100acd5..0c37167 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -26,7 +26,7 @@ cp .env.example .env
# Сгенерируйте безопасный пароль
openssl rand -base64 32
-# Отредактируйте .env и вставьте сгенерированный пароль в DB_PASSWORD
+# Отредактируйте .env и вставьте сгенерированные пароли в DB_PASSWORD и ADMIN_PASSWORD
nano .env
```
@@ -56,6 +56,8 @@ curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/sync/status
### Шаг 5: Откройте админ-панель
Откройте в браузере: http://localhost:3001
+Войдите под `admin` и паролем из `ADMIN_PASSWORD` в `.env`.
+
**Готово! 🎉**
---
diff --git a/README.md b/README.md
index b26ab56..50ba590 100644
--- a/README.md
+++ b/README.md
@@ -52,10 +52,11 @@ nano .env
# Обязательно измените в продакшене!
DB_PASSWORD=your_secure_password_here
API_KEY_SALT=generate_random_salt_with_openssl
+ADMIN_PASSWORD=your_secure_admin_password_here
-# Для разработки оставьте как есть
+# Для разработки/деплоя через встроенный proxy оставьте как есть
CORS_ORIGIN=http://localhost:3001
-API_URL=http://localhost:3000/api/v1
+API_URL=/api/v1
```
Генерация безопасного salt:
@@ -82,6 +83,10 @@ docker-compose logs -f
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
+Мастер-аккаунт создается автоматически при старте backend:
+- login: `admin`
+- password: значение `ADMIN_PASSWORD` из `.env` (если не задано, fallback `admin123`)
+
### 3. Проверка работоспособности
```bash
diff --git a/backend/package.json b/backend/package.json
index 36201f2..df048c7 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -7,6 +7,7 @@
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"migrate": "node src/utils/migrate.js",
+ "create-admin": "node scripts/create-admin.js",
"seed": "node src/utils/seed.js"
},
"keywords": [
diff --git a/backend/scripts/create-admin.js b/backend/scripts/create-admin.js
index 3702eaa..bf8a5f8 100644
--- a/backend/scripts/create-admin.js
+++ b/backend/scripts/create-admin.js
@@ -1,29 +1,25 @@
-const bcrypt = require('bcrypt');
const pool = require('../src/config/database');
+const { ensureBootstrapAdmin, readBootstrapAdminConfig } = require('../src/utils/bootstrapAdmin');
async function createAdmin() {
- const username = 'admin';
- const password = 'admin123'; // Измените после первого входа!
- const passwordHash = await bcrypt.hash(password, 10);
+ const config = readBootstrapAdminConfig();
try {
- const result = await pool.query(`
- INSERT INTO users (username, password_hash, full_name, email, role, is_active)
- VALUES ($1, $2, $3, $4, $5, $6)
- ON CONFLICT (username) DO UPDATE
- SET password_hash = $2
- RETURNING id, username, role
- `, [username, passwordHash, 'Главный администратор', 'admin@polotsk-transit.local', 'admin', true]);
+ const result = await ensureBootstrapAdmin({
+ resetPassword: true,
+ createIfActiveAdminExists: true,
+ });
- console.log('✅ Мастер-аккаунт создан:', result.rows[0]);
- console.log('Username: admin');
- console.log('Password: admin123');
- console.log('⚠️ ОБЯЗАТЕЛЬНО смените пароль после первого входа!');
+ console.log('Master account is ready:', result.user);
+ console.log(`Username: ${config.username}`);
+ console.log(`Password: ${config.password}`);
+ console.log('Change this password after the first login.');
} catch (error) {
- console.error('❌ Ошибка:', error);
+ console.error('Error:', error);
+ process.exitCode = 1;
} finally {
await pool.end();
}
}
-createAdmin();
\ No newline at end of file
+createAdmin();
diff --git a/backend/src/index.js b/backend/src/index.js
index 57624aa..f1ed475 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -5,11 +5,13 @@ const compression = require('compression');
require('dotenv').config();
const logger = require('./config/logger');
+const pool = require('./config/database');
const apiRoutes = require('./routes/api');
const { authenticateApiKey } = require('./middleware/auth');
const createRateLimiter = require('./middleware/rateLimiter');
const { ensureRealtimeSchema } = require('./services/realtimeSchema');
const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService');
+const { ensureBootstrapAdmin } = require('./utils/bootstrapAdmin');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -133,9 +135,31 @@ function shutdown(signal) {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function waitForDatabase({ attempts = 30, delayMs = 1000 } = {}) {
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ try {
+ await pool.query('SELECT 1');
+ return;
+ } catch (error) {
+ if (attempt === attempts) {
+ throw error;
+ }
+
+ logger.warn(`Database is not ready yet (${attempt}/${attempts}); retrying...`);
+ await sleep(delayMs);
+ }
+ }
+}
+
async function startServer() {
try {
+ await waitForDatabase();
await ensureRealtimeSchema();
+ await ensureBootstrapAdmin();
server = app.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`);
diff --git a/backend/src/utils/bootstrapAdmin.js b/backend/src/utils/bootstrapAdmin.js
new file mode 100644
index 0000000..5ea8334
--- /dev/null
+++ b/backend/src/utils/bootstrapAdmin.js
@@ -0,0 +1,122 @@
+const bcrypt = require('bcrypt');
+
+const pool = require('../config/database');
+const logger = require('../config/logger');
+
+const DEFAULT_ADMIN = {
+ username: 'admin',
+ password: 'admin123',
+ fullName: 'Main administrator',
+ email: 'admin@polotsk-transit.local',
+};
+
+function readBoolean(value, defaultValue) {
+ if (value === undefined || value === null || value === '') {
+ return defaultValue;
+ }
+
+ return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
+}
+
+function readBootstrapAdminConfig(env = process.env) {
+ return {
+ enabled: readBoolean(env.BOOTSTRAP_ADMIN, true),
+ username: env.ADMIN_USERNAME || DEFAULT_ADMIN.username,
+ password: env.ADMIN_PASSWORD || DEFAULT_ADMIN.password,
+ fullName: env.ADMIN_FULL_NAME || DEFAULT_ADMIN.fullName,
+ email: env.ADMIN_EMAIL || DEFAULT_ADMIN.email,
+ resetPassword: readBoolean(env.BOOTSTRAP_ADMIN_RESET_PASSWORD, false),
+ };
+}
+
+async function activeAdminExists() {
+ const result = await pool.query(`
+ SELECT EXISTS (
+ SELECT 1
+ FROM users
+ WHERE role = 'admin' AND is_active = true
+ ) AS exists
+ `);
+
+ return result.rows[0]?.exists === true;
+}
+
+function validateConfig(config) {
+ if (!config.username) {
+ throw new Error('ADMIN_USERNAME cannot be empty');
+ }
+
+ if (!config.password || config.password.length < 6) {
+ throw new Error('ADMIN_PASSWORD must be at least 6 characters');
+ }
+}
+
+async function ensureBootstrapAdmin(options = {}) {
+ const config = {
+ ...readBootstrapAdminConfig(),
+ ...options,
+ };
+
+ if (!config.enabled) {
+ logger.info('Bootstrap admin is disabled');
+ return { changed: false, reason: 'disabled' };
+ }
+
+ validateConfig(config);
+
+ const existingResult = await pool.query(
+ 'SELECT id, username, role, is_active FROM users WHERE username = $1',
+ [config.username]
+ );
+ const existingUser = existingResult.rows[0];
+
+ if (existingUser) {
+ const needsRepair = existingUser.role !== 'admin' || existingUser.is_active !== true;
+ const shouldResetPassword = config.resetPassword || needsRepair;
+
+ if (!needsRepair && !shouldResetPassword) {
+ logger.info(`Bootstrap admin already exists: ${config.username}`);
+ return { changed: false, user: existingUser };
+ }
+
+ const passwordHash = shouldResetPassword
+ ? await bcrypt.hash(config.password, 10)
+ : null;
+
+ const updateResult = await pool.query(`
+ UPDATE users
+ SET role = 'admin',
+ is_active = true,
+ full_name = COALESCE($2, full_name),
+ email = COALESCE($3, email),
+ password_hash = COALESCE($4, password_hash)
+ WHERE username = $1
+ RETURNING id, username, role, is_active
+ `, [config.username, config.fullName || null, config.email || null, passwordHash]);
+
+ logger.info(`Bootstrap admin repaired: ${config.username}`);
+ return { changed: true, user: updateResult.rows[0] };
+ }
+
+ const shouldCreateWithExistingAdmin = options.createIfActiveAdminExists === true;
+ if (!shouldCreateWithExistingAdmin && await activeAdminExists()) {
+ logger.info('Active admin already exists; default bootstrap admin was not created');
+ return { changed: false, reason: 'active-admin-exists' };
+ }
+
+ const passwordHash = await bcrypt.hash(config.password, 10);
+ const insertResult = await pool.query(`
+ INSERT INTO users (username, password_hash, full_name, email, role, is_active)
+ VALUES ($1, $2, $3, $4, 'admin', true)
+ RETURNING id, username, role, is_active
+ `, [config.username, passwordHash, config.fullName || null, config.email || null]);
+
+ logger.info(`Bootstrap admin created: ${config.username}`);
+ return { changed: true, user: insertResult.rows[0] };
+}
+
+module.exports = {
+ DEFAULT_ADMIN,
+ ensureBootstrapAdmin,
+ readBootstrapAdminConfig,
+};
diff --git a/backups/polotsk_transit.dump b/backups/polotsk_transit.dump
new file mode 100644
index 0000000000000000000000000000000000000000..b24ad88f624ef49b3c26904929dcb9e8bbed91f0
GIT binary patch
literal 69145
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
RadB}0040GWQUCw|000u&Xz~C6
literal 0
HcmV?d00001
diff --git a/docker-compose.yml b/docker-compose.yml
index 588269b..94a83b6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
postgres:
image: postgis/postgis:15-3.3
@@ -13,6 +11,11 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U transit_admin -d polotsk_transit"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
networks:
- transit-network
restart: unless-stopped
@@ -24,6 +27,11 @@ services:
- "6379:6379"
volumes:
- redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
networks:
- transit-network
restart: unless-stopped
@@ -41,11 +49,19 @@ services:
API_KEY_SALT: ${API_KEY_SALT:-generate_random_salt}
JWT_SECRET: ${JWT_SECRET:-change_me_in_production_use_random_string}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
+ BOOTSTRAP_ADMIN: ${BOOTSTRAP_ADMIN:-true}
+ BOOTSTRAP_ADMIN_RESET_PASSWORD: ${BOOTSTRAP_ADMIN_RESET_PASSWORD:-false}
+ ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+ ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
+ ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator}
+ ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local}
ports:
- "3000:3000"
depends_on:
- - postgres
- - redis
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
networks:
- transit-network
restart: unless-stopped
@@ -58,10 +74,10 @@ services:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
+ VITE_API_URL: ${API_URL:-/api/v1}
container_name: polotsk-transit-admin
environment:
- VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1}
+ VITE_API_URL: ${API_URL:-/api/v1}
ports:
- "3001:80"
depends_on:
diff --git a/frontend/.env.example b/frontend/.env.example
index 102aaa7..0128a1f 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,2 +1,2 @@
-VITE_API_URL=http://localhost:3000/api/v1
+VITE_API_URL=/api/v1
VITE_API_KEY=dev_key_12345678
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 43b3a3f..47a4876 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -3,7 +3,7 @@ FROM node:18-alpine as build
WORKDIR /app
-ARG VITE_API_URL=http://localhost:3000/api/v1
+ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=$VITE_API_URL
COPY package*.json ./
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 08a0608..8a3a50c 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -10,6 +10,28 @@ server {
gzip_min_length 1000;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+ # Same-origin API proxy for deployed admin panel
+ location /api/ {
+ proxy_pass http://backend:3000;
+ 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 /ws/ {
+ proxy_pass http://backend:3000;
+ 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_read_timeout 3600s;
+ }
+
# SPA routing
location / {
try_files $uri $uri/ /index.html;
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx
index 4103567..4822054 100644
--- a/frontend/src/pages/LoginPage.jsx
+++ b/frontend/src/pages/LoginPage.jsx
@@ -117,7 +117,7 @@ export default function LoginPage() {
Мастер-аккаунт по умолчанию:
- admin / admin123
+ admin / ADMIN_PASSWORD
@@ -129,4 +129,4 @@ export default function LoginPage() {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/apiBaseUrl.js b/frontend/src/utils/apiBaseUrl.js
index 5232323..2cdffc5 100644
--- a/frontend/src/utils/apiBaseUrl.js
+++ b/frontend/src/utils/apiBaseUrl.js
@@ -1,4 +1,4 @@
-const DEFAULT_API_BASE_URL = 'http://localhost:3000/api/v1';
+const DEFAULT_API_BASE_URL = '/api/v1';
function normalizePath(pathname) {
const trimmedPath = (pathname || '').replace(/\/+$/, '');
@@ -40,6 +40,6 @@ export function getApiOrigin(rawValue = import.meta.env.VITE_API_URL || DEFAULT_
const url = new URL(getApiBaseUrl(rawValue));
return `${url.protocol}//${url.host}`;
} catch (error) {
- return 'http://localhost:3000';
+ return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
}
}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 33b341d..3c16881 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -5,7 +5,17 @@ export default defineConfig({
plugins: [react()],
server: {
host: true,
- port: 3001
+ port: 3001,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true
+ },
+ '/ws': {
+ target: 'ws://localhost:3000',
+ ws: true
+ }
+ }
},
build: {
outDir: 'dist',
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 77d1a14..3f911d7 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -104,6 +104,32 @@ http {
access_log /var/log/nginx/admin_access.log;
error_log /var/log/nginx/admin_error.log;
+ location /api/ {
+ limit_req zone=api_limit burst=20 nodelay;
+
+ proxy_pass http://api_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 /ws/ {
+ proxy_pass http://api_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_read_timeout 3600s;
+ }
+
location / {
limit_req zone=admin_limit burst=10 nodelay;