Compare commits
5 Commits
docker
...
695000e35c
| Author | SHA1 | Date | |
|---|---|---|---|
|
695000e35c
|
|||
|
bdd8aa497d
|
|||
|
7c209e3270
|
|||
|
6d3291e331
|
|||
|
c0a471f7c2
|
@@ -1,61 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
# Note: bun.lock is needed by Dockerfile for --frozen-lockfile
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Database - will be in volume mount
|
|
||||||
**/*.db
|
|
||||||
**/*.db-shm
|
|
||||||
**/*.db-wal
|
|
||||||
|
|
||||||
# Build outputs - built in container
|
|
||||||
src/frontend/build/
|
|
||||||
src/frontend/.svelte-kit/
|
|
||||||
src/frontend/dist/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Documentation (keep docs in image but don't need in build context)
|
|
||||||
# README.md
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
backend.log
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
*.test.js
|
|
||||||
*.test.ts
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.yml
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# CI/CD
|
|
||||||
.github/
|
|
||||||
.gitlab-ci.yml
|
|
||||||
|
|
||||||
# Data directory (for volume mount)
|
|
||||||
data/
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Docker Environment Configuration
|
|
||||||
# Copy this file to .env and update with your values
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Application Settings
|
|
||||||
# ============================================
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3001
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Security (IMPORTANT: Change in production!)
|
|
||||||
# ============================================
|
|
||||||
# Generate a secure JWT secret with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=change-this-in-production-use-openssl-rand-base64-32
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# CORS Configuration
|
|
||||||
# ============================================
|
|
||||||
# Your application's public URL (e.g., https://awards.example.com)
|
|
||||||
VITE_APP_URL=
|
|
||||||
|
|
||||||
# Comma-separated list of allowed origins for CORS
|
|
||||||
# Only needed if not using same domain deployment
|
|
||||||
# Example: https://awards.example.com,https://www.awards.example.com
|
|
||||||
ALLOWED_ORIGINS=
|
|
||||||
41
.env.example
41
.env.example
@@ -1,22 +1,47 @@
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
# Copy this file to .env and update with your values
|
# Copy this file to .env and update with your values
|
||||||
|
|
||||||
# Hostname for the application (e.g., https://awards.dj7nt.de)
|
# ===================================================================
|
||||||
|
# Environment
|
||||||
|
# ===================================================================
|
||||||
|
# Development: development
|
||||||
|
# Production: production
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Log Level (debug, info, warn, error)
|
||||||
|
# Development: debug
|
||||||
|
# Production: info
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# URLs
|
||||||
|
# ===================================================================
|
||||||
|
# Frontend URL (e.g., https://awards.dj7nt.de)
|
||||||
# Leave empty for development (uses localhost)
|
# Leave empty for development (uses localhost)
|
||||||
VITE_APP_URL=
|
VITE_APP_URL=
|
||||||
|
|
||||||
# API Base URL (in production, can be same domain or separate)
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
# Leave empty to use relative paths (recommended for same-domain deployment)
|
# Only set if API is on different domain
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# Allowed CORS origins for backend (comma-separated)
|
# Allowed CORS origins for backend (comma-separated)
|
||||||
# Only needed for production if not using same domain
|
# Add all domains that should access the API
|
||||||
# Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
# Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
||||||
ALLOWED_ORIGINS=
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# JWT Secret (for production, use a strong random string)
|
# ===================================================================
|
||||||
# Generate with: openssl rand -base64 32
|
# Security
|
||||||
|
# ===================================================================
|
||||||
|
# JWT Secret (REQUIRED for production)
|
||||||
|
# Development: uses default if not set
|
||||||
|
# Production: Generate with: openssl rand -base64 32
|
||||||
JWT_SECRET=change-this-in-production
|
JWT_SECRET=change-this-in-production
|
||||||
|
|
||||||
# Node Environment
|
# ===================================================================
|
||||||
NODE_ENV=development
|
# Database (Optional)
|
||||||
|
# ===================================================================
|
||||||
|
# Leave empty to use default SQLite database
|
||||||
|
# DATABASE_URL=file:/path/to/custom.db
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Production Configuration Template
|
|
||||||
# Copy this file to .env.production and update with your production values
|
|
||||||
|
|
||||||
# Application Environment
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Log Level (debug, info, warn, error)
|
|
||||||
# Recommended: info for production
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Server Port (default: 3001)
|
|
||||||
PORT=3001
|
|
||||||
|
|
||||||
# Frontend URL (e.g., https://awards.dj7nt.de)
|
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
|
||||||
|
|
||||||
# API Base URL (leave empty for same-domain deployment)
|
|
||||||
VITE_API_BASE_URL=
|
|
||||||
|
|
||||||
# Allowed CORS origins (comma-separated)
|
|
||||||
# Add all domains that should access the API
|
|
||||||
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
|
||||||
|
|
||||||
# JWT Secret (REQUIRED - generate a strong secret!)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
|
|
||||||
|
|
||||||
# Database (if using external database)
|
|
||||||
# Leave empty to use default SQLite database
|
|
||||||
# DATABASE_URL=file:/path/to/production.db
|
|
||||||
52
CLAUDE.md
52
CLAUDE.md
@@ -77,58 +77,6 @@ test("hello world", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
The application supports Docker deployment with single-port architecture and host-mounted database persistence.
|
|
||||||
|
|
||||||
**Quick Start**:
|
|
||||||
```bash
|
|
||||||
# Create environment file
|
|
||||||
cp .env.docker.example .env
|
|
||||||
|
|
||||||
# Generate JWT secret
|
|
||||||
openssl rand -base64 32 # Add to .env as JWT_SECRET
|
|
||||||
|
|
||||||
# Start application
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# Access at http://localhost:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
**Architecture**:
|
|
||||||
- **Single Port**: Port 3001 serves both API (`/api/*`) and frontend (all other routes)
|
|
||||||
- **Database Persistence**: SQLite database stored at `./data/award.db` on host
|
|
||||||
- **Auto-initialization**: Database created from template on first startup
|
|
||||||
- **Health Checks**: Built-in health monitoring at `/api/health`
|
|
||||||
|
|
||||||
**Key Docker Files**:
|
|
||||||
- `Dockerfile`: Multi-stage build using official Bun runtime
|
|
||||||
- `docker-compose.yml`: Stack orchestration with volume mounts
|
|
||||||
- `docker-entrypoint.sh`: Database initialization logic
|
|
||||||
- `.env.docker.example`: Environment variable template
|
|
||||||
- `DOCKER.md`: Complete deployment documentation
|
|
||||||
|
|
||||||
**Environment Variables**:
|
|
||||||
- `NODE_ENV`: Environment mode (default: production)
|
|
||||||
- `PORT`: Application port (default: 3001)
|
|
||||||
- `LOG_LEVEL`: Logging level (debug/info/warn/error)
|
|
||||||
- `JWT_SECRET`: JWT signing secret (required, change in production!)
|
|
||||||
- `VITE_APP_URL`: Your application's public URL
|
|
||||||
- `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated)
|
|
||||||
|
|
||||||
**Database Management**:
|
|
||||||
- Database location: `./data/award.db` (host-mounted volume)
|
|
||||||
- Backups: `cp data/award.db data/award.db.backup.$(date +%Y%m%d)`
|
|
||||||
- Reset: `docker-compose down -v && docker-compose up -d`
|
|
||||||
|
|
||||||
**Important Notes**:
|
|
||||||
- Database persists across container restarts/recreations
|
|
||||||
- Frontend dependencies are reinstalled in container to ensure correct platform binaries
|
|
||||||
- Uses custom init script (`src/backend/scripts/init-db.js`) with `bun:sqlite`
|
|
||||||
- Architecture-agnostic (works on x86, ARM64, etc.)
|
|
||||||
|
|
||||||
For detailed documentation, see `DOCKER.md`.
|
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|||||||
219
DOCKER.md
219
DOCKER.md
@@ -1,219 +0,0 @@
|
|||||||
# Docker Deployment Guide
|
|
||||||
|
|
||||||
This guide covers deploying Quickawards using Docker.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Create environment file:**
|
|
||||||
```bash
|
|
||||||
cp .env.docker.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Generate secure JWT secret:**
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
Copy the output and set it as `JWT_SECRET` in `.env`.
|
|
||||||
|
|
||||||
3. **Update `.env` with your settings:**
|
|
||||||
- `JWT_SECRET`: Strong random string (required)
|
|
||||||
- `VITE_APP_URL`: Your domain (e.g., `https://awards.example.com`)
|
|
||||||
- `ALLOWED_ORIGINS`: Your domain(s) for CORS
|
|
||||||
|
|
||||||
4. **Start the application:**
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Access the application:**
|
|
||||||
- URL: http://localhost:3001
|
|
||||||
- Health check: http://localhost:3001/api/health
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Single Port Design
|
|
||||||
|
|
||||||
The Docker stack exposes a single port (3001) which serves both:
|
|
||||||
- **Backend API** (`/api/*`)
|
|
||||||
- **Frontend SPA** (all other routes)
|
|
||||||
|
|
||||||
### Database Persistence
|
|
||||||
|
|
||||||
- **Location**: `./data/award.db` (host-mounted volume)
|
|
||||||
- **Initialization**: Automatic on first startup
|
|
||||||
- **Persistence**: Database survives container restarts/recreations
|
|
||||||
|
|
||||||
### Startup Behavior
|
|
||||||
|
|
||||||
1. **First startup**: Database is created from template
|
|
||||||
2. **Subsequent startups**: Existing database is used
|
|
||||||
3. **Container recreation**: Database persists in volume
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Start the application
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### View logs
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop the application
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rebuild after code changes
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop and remove everything (including database volume)
|
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
|
||||||
|----------|----------|---------|-------------|
|
|
||||||
| `NODE_ENV` | No | `production` | Environment mode |
|
|
||||||
| `PORT` | No | `3001` | Application port |
|
|
||||||
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) |
|
|
||||||
| `JWT_SECRET` | **Yes** | - | JWT signing secret (change this!) |
|
|
||||||
| `VITE_APP_URL` | No | - | Your application's public URL |
|
|
||||||
| `ALLOWED_ORIGINS` | No | - | CORS allowed origins (comma-separated) |
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
### Backup the database
|
|
||||||
```bash
|
|
||||||
cp data/award.db data/award.db.backup.$(date +%Y%m%d)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restore from backup
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
cp data/award.db.backup.YYYYMMDD data/award.db
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reset the database
|
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container won't start
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Check container status
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database errors
|
|
||||||
```bash
|
|
||||||
# Check database file exists
|
|
||||||
ls -la data/
|
|
||||||
|
|
||||||
# Check database permissions
|
|
||||||
stat data/award.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port already in use
|
|
||||||
Change the port mapping in `docker-compose.yml`:
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "8080:3001" # Maps host port 8080 to container port 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health check failing
|
|
||||||
```bash
|
|
||||||
# Check if container is responding
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# Check container logs
|
|
||||||
docker-compose logs quickawards
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Using a Reverse Proxy (nginx)
|
|
||||||
|
|
||||||
Example nginx configuration:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name awards.example.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3001;
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL/TLS with Let's Encrypt
|
|
||||||
|
|
||||||
Use certbot with nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo certbot --nginx -d awards.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Checklist
|
|
||||||
|
|
||||||
- [ ] Set strong `JWT_SECRET`
|
|
||||||
- [ ] Set `NODE_ENV=production`
|
|
||||||
- [ ] Set `LOG_LEVEL=info` (or `warn` in production)
|
|
||||||
- [ ] Configure `ALLOWED_ORIGINS` to your domain only
|
|
||||||
- [ ] Use HTTPS/TLS in production
|
|
||||||
- [ ] Regular database backups
|
|
||||||
- [ ] Monitor logs for suspicious activity
|
|
||||||
- [ ] Keep Docker image updated
|
|
||||||
|
|
||||||
## File Structure After Deployment
|
|
||||||
|
|
||||||
```
|
|
||||||
project/
|
|
||||||
├── data/
|
|
||||||
│ └── award.db # Persisted database (volume mount)
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .dockerignore
|
|
||||||
├── .env # Your environment variables
|
|
||||||
└── ... (source code)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building Without docker-compose
|
|
||||||
|
|
||||||
If you prefer to use `docker` directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -t quickawards .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -d \
|
|
||||||
--name quickawards \
|
|
||||||
-p 3001:3001 \
|
|
||||||
-v $(pwd)/data:/data \
|
|
||||||
-e JWT_SECRET=your-secret-here \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
quickawards
|
|
||||||
```
|
|
||||||
72
Dockerfile
72
Dockerfile
@@ -1,72 +0,0 @@
|
|||||||
# Multi-stage Dockerfile for Quickawards
|
|
||||||
# Uses official Bun runtime image
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 1: Dependencies & Database Init
|
|
||||||
# ============================================
|
|
||||||
FROM oven/bun:1 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies for drizzle-kit)
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy source code (node_modules excluded by .dockerignore)
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Reinstall frontend dependencies to get correct platform binaries
|
|
||||||
RUN cd src/frontend && bun install
|
|
||||||
|
|
||||||
# Initialize database using custom script
|
|
||||||
# This creates a fresh database with the correct schema using bun:sqlite
|
|
||||||
RUN bun src/backend/scripts/init-db.js
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 2: Production Image
|
|
||||||
# ============================================
|
|
||||||
FROM oven/bun:1 AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install production dependencies only
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile --production
|
|
||||||
|
|
||||||
# Copy backend source and schema files
|
|
||||||
COPY src/backend ./src/backend
|
|
||||||
COPY award-definitions ./award-definitions
|
|
||||||
COPY drizzle.config.ts ./
|
|
||||||
|
|
||||||
# Copy frontend build from builder stage
|
|
||||||
COPY --from=builder /app/src/frontend/build ./src/frontend/build
|
|
||||||
|
|
||||||
# Copy initialized database from builder (will be used as template)
|
|
||||||
COPY --from=builder /app/src/backend/award.db /app/award.db.template
|
|
||||||
|
|
||||||
# Copy drizzle migrations (if they exist)
|
|
||||||
COPY --from=builder /app/drizzle ./drizzle
|
|
||||||
|
|
||||||
# Create directory for database volume mount
|
|
||||||
RUN mkdir -p /data
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
PORT=3001 \
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Expose the application port
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
# Use entrypoint script to handle database initialization
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
|
|
||||||
# Start the backend server
|
|
||||||
CMD ["bun", "run", "src/backend/index.js"]
|
|
||||||
56
README.md
56
README.md
@@ -116,7 +116,7 @@ award/
|
|||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── award-definitions/ # Award rule definitions (JSON)
|
├── award-definitions/ # Award rule definitions (JSON)
|
||||||
├── award.db # SQLite database (auto-created)
|
├── award.db # SQLite database (auto-created)
|
||||||
├── .env.production.template # Production configuration template
|
├── .env.example # Environment configuration template
|
||||||
├── bunfig.toml # Bun configuration
|
├── bunfig.toml # Bun configuration
|
||||||
├── drizzle.config.js # Drizzle ORM configuration
|
├── drizzle.config.js # Drizzle ORM configuration
|
||||||
├── package.json
|
├── package.json
|
||||||
@@ -149,20 +149,32 @@ cp .env.example .env
|
|||||||
|
|
||||||
Edit `.env` with your configuration:
|
Edit `.env` with your configuration:
|
||||||
```env
|
```env
|
||||||
# Application URL (for production deployment)
|
# Environment (development/production)
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Log Level (debug/info/warn/error)
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Frontend URL (e.g., https://awards.dj7nt.de)
|
||||||
|
# Leave empty for development (uses localhost)
|
||||||
|
VITE_APP_URL=
|
||||||
|
|
||||||
# API Base URL (leave empty for same-domain deployment)
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -base64 32)
|
# Allowed CORS origins (comma-separated)
|
||||||
JWT_SECRET=your-generated-secret-here
|
# Add all domains that should access the API
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# Environment
|
# JWT Secret (generate with: openssl rand -base64 32)
|
||||||
NODE_ENV=production
|
JWT_SECRET=change-this-in-production
|
||||||
```
|
```
|
||||||
|
|
||||||
**For development**: You can leave `.env` empty or use defaults.
|
**For development**: Use defaults above.
|
||||||
|
**For production**: Set `NODE_ENV=production`, `LOG_LEVEL=info`, and generate a strong `JWT_SECRET`.
|
||||||
|
|
||||||
4. Initialize the database with performance indexes:
|
4. Initialize the database with performance indexes:
|
||||||
```bash
|
```bash
|
||||||
@@ -414,20 +426,26 @@ bun run build
|
|||||||
Create `.env` in the project root:
|
Create `.env` in the project root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Application URL
|
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
|
||||||
|
|
||||||
# API Base URL (empty for same-domain)
|
|
||||||
VITE_API_BASE_URL=
|
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -base64 32)
|
|
||||||
JWT_SECRET=your-generated-secret-here
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# Database path (absolute path recommended)
|
# Log Level (debug/info/warn/error)
|
||||||
DATABASE_PATH=/path/to/award/award.db
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Frontend URL
|
||||||
|
VITE_APP_URL=https://awards.dj7nt.de
|
||||||
|
|
||||||
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Allowed CORS origins (comma-separated)
|
||||||
|
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
||||||
|
|
||||||
|
# JWT Secret (generate with: openssl rand -base64 32)
|
||||||
|
JWT_SECRET=your-generated-secret-here
|
||||||
```
|
```
|
||||||
|
|
||||||
**Security**: Ensure `.env` has restricted permissions:
|
**Security**: Ensure `.env` has restricted permissions:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
services:
|
|
||||||
quickawards:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: quickawards
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
environment:
|
|
||||||
# Application settings
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3001
|
|
||||||
LOG_LEVEL: info
|
|
||||||
|
|
||||||
# Security - IMPORTANT: Change these in production!
|
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-this-in-production}
|
|
||||||
|
|
||||||
# CORS - Set to your domain in production
|
|
||||||
VITE_APP_URL: ${VITE_APP_URL:-}
|
|
||||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
|
||||||
volumes:
|
|
||||||
# Host-mounted database directory
|
|
||||||
# Database will be created at ./data/award.db on first startup
|
|
||||||
- ./data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Docker container entrypoint script
|
|
||||||
# Handles database initialization on first startup
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Quickawards - Docker Entrypoint"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
# Database location in volume mount
|
|
||||||
DB_PATH="/data/award.db"
|
|
||||||
TEMPLATE_DB="/app/award.db.template"
|
|
||||||
APP_DB_PATH="/app/src/backend/award.db"
|
|
||||||
|
|
||||||
# Check if database exists in the volume
|
|
||||||
if [ ! -f "$DB_PATH" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "📦 Database not found in volume mount."
|
|
||||||
echo " Initializing from template database..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Copy the template database (created during build with drizzle-kit push)
|
|
||||||
cp "$TEMPLATE_DB" "$DB_PATH"
|
|
||||||
|
|
||||||
# Ensure proper permissions
|
|
||||||
chmod 644 "$DB_PATH"
|
|
||||||
|
|
||||||
echo "✅ Database initialized at: $DB_PATH"
|
|
||||||
echo " This database will persist in the Docker volume."
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "✅ Existing database found at: $DB_PATH"
|
|
||||||
echo " Using existing database from volume mount."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink from app's expected db location to volume mount
|
|
||||||
# The app expects the database at src/backend/award.db
|
|
||||||
# We create a symlink so it points to the volume-mounted database
|
|
||||||
if [ -L "$APP_DB_PATH" ]; then
|
|
||||||
# Symlink already exists, remove it to refresh
|
|
||||||
rm "$APP_DB_PATH"
|
|
||||||
elif [ -e "$APP_DB_PATH" ]; then
|
|
||||||
# File or directory exists (shouldn't happen in production, but handle it)
|
|
||||||
echo "⚠ Warning: Found existing database at $APP_DB_PATH, removing..."
|
|
||||||
rm -f "$APP_DB_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink to the volume-mounted database
|
|
||||||
ln -s "$DB_PATH" "$APP_DB_PATH"
|
|
||||||
echo "✅ Created symlink: $APP_DB_PATH -> $DB_PATH"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Starting Quickawards application..."
|
|
||||||
echo "Port: ${PORT:-3001}"
|
|
||||||
echo "Environment: ${NODE_ENV:-production}"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Execute the main command (passed as CMD in Dockerfile)
|
|
||||||
exec "$@"
|
|
||||||
1051
docs/AWARD-SYSTEM-SPECIFICATION.md
Normal file
1051
docs/AWARD-SYSTEM-SPECIFICATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
|
getAdminActions,
|
||||||
impersonateUser,
|
impersonateUser,
|
||||||
verifyImpersonation,
|
verifyImpersonation,
|
||||||
stopImpersonation,
|
stopImpersonation,
|
||||||
@@ -434,9 +435,15 @@ const app = new Elysia()
|
|||||||
return { success: false, error: 'User not found' };
|
return { success: false, error: 'User not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include impersonatedBy from JWT if present (not stored in database)
|
||||||
|
const responseUser = {
|
||||||
|
...userData,
|
||||||
|
impersonatedBy: user.impersonatedBy,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: userData,
|
user: responseUser,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
|
|||||||
* @returns {Promise<Array>} Array of admin actions
|
* @returns {Promise<Array>} Array of admin actions
|
||||||
*/
|
*/
|
||||||
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
||||||
let query = db
|
// Use raw SQL for the self-join (admin users and target users from same users table)
|
||||||
.select({
|
// Using bun:sqlite prepared statements for raw SQL
|
||||||
id: adminActions.id,
|
let query = `
|
||||||
adminId: adminActions.adminId,
|
SELECT
|
||||||
adminEmail: users.email,
|
aa.id as id,
|
||||||
adminCallsign: users.callsign,
|
aa.admin_id as adminId,
|
||||||
actionType: adminActions.actionType,
|
admin_user.email as adminEmail,
|
||||||
targetUserId: adminActions.targetUserId,
|
admin_user.callsign as adminCallsign,
|
||||||
targetEmail: sql`target_users.email`.as('targetEmail'),
|
aa.action_type as actionType,
|
||||||
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
|
aa.target_user_id as targetUserId,
|
||||||
details: adminActions.details,
|
target_user.email as targetEmail,
|
||||||
createdAt: adminActions.createdAt,
|
target_user.callsign as targetCallsign,
|
||||||
})
|
aa.details as details,
|
||||||
.from(adminActions)
|
aa.created_at as createdAt
|
||||||
.leftJoin(users, eq(adminActions.adminId, users.id))
|
FROM admin_actions aa
|
||||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
|
||||||
.orderBy(desc(adminActions.createdAt))
|
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
|
||||||
.limit(limit)
|
`;
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
if (adminId) {
|
const params = [];
|
||||||
query = query.where(eq(adminActions.adminId, adminId));
|
if (adminId !== null) {
|
||||||
|
query += ` WHERE aa.admin_id = ?`;
|
||||||
|
params.push(adminId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query;
|
query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
return sqlite.prepare(query).all(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,7 +131,12 @@ export async function getUserStats() {
|
|||||||
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
lastSync: sql`MAX(${qsos.createdAt})`,
|
lastSync: sql`(
|
||||||
|
SELECT MAX(${syncJobs.completedAt})
|
||||||
|
FROM ${syncJobs}
|
||||||
|
WHERE ${syncJobs.userId} = ${users.id}
|
||||||
|
AND ${syncJobs.status} = 'completed'
|
||||||
|
)`.mapWith(Number),
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -135,7 +144,11 @@ export async function getUserStats() {
|
|||||||
.groupBy(users.id)
|
.groupBy(users.id)
|
||||||
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
||||||
|
|
||||||
return stats;
|
// Convert lastSync timestamps (seconds) to Date objects for JSON serialization
|
||||||
|
return stats.map(stat => ({
|
||||||
|
...stat,
|
||||||
|
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,24 +241,26 @@ export async function stopImpersonation(adminId, targetUserId) {
|
|||||||
* @returns {Promise<Array>} Array of recent impersonation actions
|
* @returns {Promise<Array>} Array of recent impersonation actions
|
||||||
*/
|
*/
|
||||||
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||||
const impersonations = await db
|
// Use raw SQL for the self-join to avoid Drizzle alias issues
|
||||||
.select({
|
// Using bun:sqlite prepared statements for raw SQL
|
||||||
id: adminActions.id,
|
const query = `
|
||||||
actionType: adminActions.actionType,
|
SELECT
|
||||||
targetUserId: adminActions.targetUserId,
|
aa.id as id,
|
||||||
targetEmail: sql`target_users.email`,
|
aa.action_type as actionType,
|
||||||
targetCallsign: sql`target_users.callsign`,
|
aa.target_user_id as targetUserId,
|
||||||
details: adminActions.details,
|
u.email as targetEmail,
|
||||||
createdAt: adminActions.createdAt,
|
u.callsign as targetCallsign,
|
||||||
})
|
aa.details as details,
|
||||||
.from(adminActions)
|
aa.created_at as createdAt
|
||||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
FROM admin_actions aa
|
||||||
.where(eq(adminActions.adminId, adminId))
|
LEFT JOIN users u ON u.id = aa.target_user_id
|
||||||
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
|
WHERE aa.admin_id = ?
|
||||||
.orderBy(desc(adminActions.createdAt))
|
AND aa.action_type LIKE 'impersonate%'
|
||||||
.limit(limit);
|
ORDER BY aa.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`;
|
||||||
|
|
||||||
return impersonations;
|
return sqlite.prepare(query).all(adminId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ function createAuthStore() {
|
|||||||
clearError: () => {
|
clearError: () => {
|
||||||
update((s) => ({ ...s, error: null }));
|
update((s) => ({ ...s, error: null }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Direct login with user object and token (for impersonation)
|
||||||
|
loginWithToken: (user, token) => {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
set({ user, token, loading: false, error: null });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,42 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { adminAPI, authAPI } from '$lib/api.js';
|
||||||
|
|
||||||
|
let stoppingImpersonation = false;
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
goto('/auth/login');
|
// Use hard redirect to ensure proper navigation after logout
|
||||||
|
// goto() may not work properly due to SvelteKit client-side routing
|
||||||
|
if (browser) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopImpersonation() {
|
||||||
|
if (stoppingImpersonation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stoppingImpersonation = true;
|
||||||
|
const data = await adminAPI.stopImpersonation();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Update auth store with admin user data and new token
|
||||||
|
auth.loginWithToken(data.user, data.token);
|
||||||
|
|
||||||
|
// Hard redirect to home page
|
||||||
|
if (browser) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to stop impersonation: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
stoppingImpersonation = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -35,6 +67,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Impersonation Banner -->
|
||||||
|
{#if $auth.user?.impersonatedBy}
|
||||||
|
<div class="impersonation-banner">
|
||||||
|
<div class="impersonation-content">
|
||||||
|
<span class="warning-icon">⚠️</span>
|
||||||
|
<span class="impersonation-text">
|
||||||
|
You are currently impersonating <strong>{$auth.user.email}</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="stop-impersonation-btn"
|
||||||
|
on:click={handleStopImpersonation}
|
||||||
|
disabled={stoppingImpersonation}
|
||||||
|
>
|
||||||
|
{stoppingImpersonation ? 'Stopping...' : 'Stop Impersonation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
@@ -152,4 +204,51 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Impersonation Banner */
|
||||||
|
.impersonation-banner {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 2px solid #ffc107;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impersonation-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-impersonation-btn {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-impersonation-btn:hover:not(:disabled) {
|
||||||
|
background-color: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-impersonation-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { adminAPI } from '$lib/api.js';
|
import { adminAPI, authAPI } from '$lib/api.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
@@ -90,16 +90,16 @@
|
|||||||
const data = await adminAPI.impersonate(userId);
|
const data = await adminAPI.impersonate(userId);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Store new token
|
// Store the new impersonation token
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update auth store with new user data
|
// Fetch the full user profile (which includes impersonatedBy)
|
||||||
auth.login({
|
const profileData = await authAPI.getProfile();
|
||||||
...data.impersonating,
|
|
||||||
impersonatedBy: $auth.user.id,
|
// Update auth store with complete user data
|
||||||
});
|
auth.loginWithToken(profileData.user, data.token);
|
||||||
|
|
||||||
// Redirect to home page
|
// Redirect to home page
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -114,32 +114,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStopImpersonation() {
|
|
||||||
try {
|
|
||||||
loading = true;
|
|
||||||
const data = await adminAPI.stopImpersonation();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
// Store admin token
|
|
||||||
if (browser) {
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update auth store
|
|
||||||
auth.login(data.user);
|
|
||||||
|
|
||||||
alert(data.message);
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to stop impersonation: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteUser(userId) {
|
async function handleDeleteUser(userId) {
|
||||||
const user = users.find(u => u.id === userId);
|
const user = users.find(u => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -203,7 +177,11 @@
|
|||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
// Handle Unix timestamps (seconds) by converting to milliseconds
|
||||||
|
const date = typeof dateString === 'number'
|
||||||
|
? new Date(dateString * 1000)
|
||||||
|
: new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -232,21 +210,6 @@
|
|||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="admin-dashboard">
|
<div class="admin-dashboard">
|
||||||
<!-- Impersonation Banner -->
|
|
||||||
{#if $auth.user?.impersonatedBy}
|
|
||||||
<div class="impersonation-banner">
|
|
||||||
<div class="impersonation-content">
|
|
||||||
<span class="warning-icon">⚠️</span>
|
|
||||||
<span class="impersonation-text">
|
|
||||||
You are currently impersonating <strong>{$auth.user.email}</strong>
|
|
||||||
</span>
|
|
||||||
<button class="stop-impersonation-btn" on:click={handleStopImpersonation}>
|
|
||||||
Stop Impersonation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<h1>Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
@@ -573,45 +536,6 @@
|
|||||||
color: #c00;
|
color: #c00;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Impersonation Banner */
|
|
||||||
.impersonation-banner {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border: 2px solid #ffc107;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.impersonation-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.impersonation-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-impersonation-btn {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: #000;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-impersonation-btn:hover {
|
|
||||||
background-color: #e0a800;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -863,12 +787,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-type.impostor_start {
|
.action-type.impersonate_start {
|
||||||
background-color: #ffc107;
|
background-color: #ffc107;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-type.impostor_stop {
|
.action-type.impersonate_stop {
|
||||||
background-color: #28a745;
|
background-color: #28a745;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { authAPI } from '$lib/api.js';
|
import { authAPI } from '$lib/api.js';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -93,7 +94,10 @@
|
|||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
goto('/auth/login');
|
// Use hard redirect to ensure proper navigation after logout
|
||||||
|
if (browser) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user