Compare commits

...

5 Commits

Author SHA1 Message Date
695000e35c docs: add comprehensive award system specification
Add complete specification document for the JSON-driven award
calculation system. Documents all rule types, filter operators,
QSO schema, and implementation guidance suitable for porting
to any programming language.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:50:38 +01:00
bdd8aa497d fix: admin action log and impersonation improvements
- Fix admin action log not displaying entries (use raw sqlite for self-join)
- Add global impersonation banner to all pages during impersonation
- Fix timestamp display in action log (convert Unix seconds to milliseconds)
- Add loginWithToken method to auth store for direct token authentication
- Fix /api/auth/me to include impersonatedBy field from JWT
- Remove duplicate impersonation code from admin page

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:26:20 +01:00
7c209e3270 fix: correct last-sync date and logout redirect issues
- Fix admin users last-sync showing 1970 instead of actual sync date
  - Changed from MAX(qsos.createdAt) to MAX(syncJobs.completedAt)
  - Added timestamp conversion (seconds to milliseconds) for proper Date serialization
- Fix logout redirect not working from admin dashboard
  - Changed from goto() to window.location.href for hard redirect
  - Ensures proper navigation after auth state changes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:49:27 +01:00
6d3291e331 chore: consolidate env templates and remove Docker docs from master
- Merge .env.production.template into .env.example
- Remove Docker Deployment section from CLAUDE.md (now on docker branch)
- Update README.md to reference .env.example
- Update environment variable documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:08:27 +01:00
c0a471f7c2 chore: remove Docker files from master branch
Master is now for standalone/run-on-metal deployment only.
Docker-related files moved to dedicated 'docker' branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:05:39 +01:00
17 changed files with 1313 additions and 714 deletions

View File

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

View File

@@ -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=

View File

@@ -1,22 +1,47 @@
# Application Configuration
# 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)
VITE_APP_URL=
# API Base URL (in production, can be same domain or separate)
# Leave empty to use relative paths (recommended for same-domain deployment)
# API Base URL (leave empty for same-domain deployment)
# Only set if API is on different domain
VITE_API_BASE_URL=
# 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
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
# Node Environment
NODE_ENV=development
# ===================================================================
# Database (Optional)
# ===================================================================
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/custom.db

View File

@@ -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

View File

@@ -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
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.

219
DOCKER.md
View File

@@ -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
```

View File

@@ -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"]

View File

@@ -116,7 +116,7 @@ award/
│ └── package.json
├── award-definitions/ # Award rule definitions (JSON)
├── award.db # SQLite database (auto-created)
├── .env.production.template # Production configuration template
├── .env.example # Environment configuration template
├── bunfig.toml # Bun configuration
├── drizzle.config.js # Drizzle ORM configuration
├── package.json
@@ -149,20 +149,32 @@ cp .env.example .env
Edit `.env` with your configuration:
```env
# Application URL (for production deployment)
VITE_APP_URL=https://awards.dj7nt.de
# Environment (development/production)
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)
VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
# Allowed CORS origins (comma-separated)
# Add all domains that should access the API
ALLOWED_ORIGINS=
# Environment
NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 32)
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:
```bash
@@ -414,20 +426,26 @@ bun run build
Create `.env` in the project root:
```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
NODE_ENV=production
# Database path (absolute path recommended)
DATABASE_PATH=/path/to/award/award.db
# Log Level (debug/info/warn/error)
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:

View File

@@ -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

View File

@@ -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 "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
import {
getSystemStats,
getUserStats,
getAdminActions,
impersonateUser,
verifyImpersonation,
stopImpersonation,
@@ -434,9 +435,15 @@ const app = new Elysia()
return { success: false, error: 'User not found' };
}
// Include impersonatedBy from JWT if present (not stored in database)
const responseUser = {
...userData,
impersonatedBy: user.impersonatedBy,
};
return {
success: true,
user: userData,
user: responseUser,
};
})

View File

@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
* @returns {Promise<Array>} Array of admin actions
*/
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
let query = db
.select({
id: adminActions.id,
adminId: adminActions.adminId,
adminEmail: users.email,
adminCallsign: users.callsign,
actionType: adminActions.actionType,
targetUserId: adminActions.targetUserId,
targetEmail: sql`target_users.email`.as('targetEmail'),
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
details: adminActions.details,
createdAt: adminActions.createdAt,
})
.from(adminActions)
.leftJoin(users, eq(adminActions.adminId, users.id))
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
.orderBy(desc(adminActions.createdAt))
.limit(limit)
.offset(offset);
// Use raw SQL for the self-join (admin users and target users from same users table)
// Using bun:sqlite prepared statements for raw SQL
let query = `
SELECT
aa.id as id,
aa.admin_id as adminId,
admin_user.email as adminEmail,
admin_user.callsign as adminCallsign,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
target_user.email as targetEmail,
target_user.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
`;
if (adminId) {
query = query.where(eq(adminActions.adminId, adminId));
const params = [];
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)`,
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)`,
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,
})
.from(users)
@@ -135,7 +144,11 @@ export async function getUserStats() {
.groupBy(users.id)
.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
*/
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
const impersonations = await db
.select({
id: adminActions.id,
actionType: adminActions.actionType,
targetUserId: adminActions.targetUserId,
targetEmail: sql`target_users.email`,
targetCallsign: sql`target_users.callsign`,
details: adminActions.details,
createdAt: adminActions.createdAt,
})
.from(adminActions)
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
.where(eq(adminActions.adminId, adminId))
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
.orderBy(desc(adminActions.createdAt))
.limit(limit);
// Use raw SQL for the self-join to avoid Drizzle alias issues
// Using bun:sqlite prepared statements for raw SQL
const query = `
SELECT
aa.id as id,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
u.email as targetEmail,
u.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users u ON u.id = aa.target_user_id
WHERE aa.admin_id = ?
AND aa.action_type LIKE 'impersonate%'
ORDER BY aa.created_at DESC
LIMIT ?
`;
return impersonations;
return sqlite.prepare(query).all(adminId, limit);
}
/**

View File

@@ -103,6 +103,15 @@ function createAuthStore() {
clearError: () => {
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 });
},
};
}

View File

@@ -2,10 +2,42 @@
import { browser } from '$app/environment';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js';
let stoppingImpersonation = false;
function handleLogout() {
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>
@@ -35,6 +67,26 @@
</div>
</nav>
{/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>
<slot />
</main>
@@ -152,4 +204,51 @@
margin: 0;
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>

View File

@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js';
import { adminAPI } from '$lib/api.js';
import { adminAPI, authAPI } from '$lib/api.js';
import { browser } from '$app/environment';
let loading = true;
@@ -90,16 +90,16 @@
const data = await adminAPI.impersonate(userId);
if (data.success) {
// Store new token
// Store the new impersonation token
if (browser) {
localStorage.setItem('auth_token', data.token);
}
// Update auth store with new user data
auth.login({
...data.impersonating,
impersonatedBy: $auth.user.id,
});
// Fetch the full user profile (which includes impersonatedBy)
const profileData = await authAPI.getProfile();
// Update auth store with complete user data
auth.loginWithToken(profileData.user, data.token);
// Redirect to home page
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) {
const user = users.find(u => u.id === userId);
if (!user) return;
@@ -203,7 +177,11 @@
function formatDate(dateString) {
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',
month: 'short',
day: 'numeric',
@@ -232,21 +210,6 @@
<div class="error">{error}</div>
{:else}
<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>
<!-- Tab Navigation -->
@@ -573,45 +536,6 @@
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 {
margin-bottom: 1.5rem;
color: #333;
@@ -863,12 +787,12 @@
font-weight: 600;
}
.action-type.impostor_start {
.action-type.impersonate_start {
background-color: #ffc107;
color: #000;
}
.action-type.impostor_stop {
.action-type.impersonate_stop {
background-color: #28a745;
color: white;
}

View File

@@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { authAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
@@ -93,7 +94,10 @@
function handleLogout() {
auth.logout();
goto('/auth/login');
// Use hard redirect to ensure proper navigation after logout
if (browser) {
window.location.href = '/auth/login';
}
}
</script>