Compare commits
14 Commits
47127f276d
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 17c766c27f | |||
| e2726083f6 | |||
| 2a10c1ce70 | |||
| 56a2e1c7b9 | |||
| 898f355a12 | |||
| 3f90949c2b | |||
| fe2361be64 | |||
| 6f827e0c7b | |||
| dd3a42eddf | |||
| b506276bf9 | |||
| 1b6848917c | |||
| 193fcb3791 | |||
| c8895c8e80 | |||
| 781f0f14fa |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(sudo pacman -S --noconfirm docker docker-compose)",
|
||||
"Bash(sudo systemctl enable --now docker)",
|
||||
"Bash(sudo usermod -aG docker $USER)",
|
||||
"Bash(doas pacman -S --noconfirm docker docker-compose)",
|
||||
"Bash(bun create next-app . --typescript --tailwind --eslint --app --no-src-dir --import-alias=\"@/*\" --no-git)",
|
||||
"Read(//home/kawa/.bun/bin/**)",
|
||||
"Bash(echo $PATH)",
|
||||
"Bash(export PATH=\"$HOME/.bun/bin:$PATH\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Next.js build output (rebuilt in Docker)
|
||||
.next
|
||||
|
||||
# Local env files (pass secrets via Docker env, not baked into image)
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# Dev / editor artifacts
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Data directories (mounted as volumes)
|
||||
data
|
||||
mc-data
|
||||
backups
|
||||
|
||||
# Test / CI
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
*.tsbuildinfo
|
||||
47
.gitea/workflows/docker.yml
Normal file
47
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*" # triggers on v0.1.0, v1.0.0, etc.
|
||||
workflow_dispatch: # allow manual trigger from Gitea UI
|
||||
|
||||
env:
|
||||
REGISTRY: git.azuze.fr
|
||||
IMAGE: git.azuze.fr/kawa/cubeadmin
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version tag
|
||||
id: meta
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}" # e.g. v0.1.0
|
||||
VERSION="${TAG#v}" # strip leading v → 0.1.0
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.claude/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# ─── Stage 1: Install dependencies ───────────────────────────────────────────
|
||||
FROM oven/bun:1 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# ─── Stage 2: Build Next.js ──────────────────────────────────────────────────
|
||||
FROM oven/bun:1 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN bun run build
|
||||
|
||||
# ─── Stage 3: Production runner ──────────────────────────────────────────────
|
||||
FROM oven/bun:1-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy runtime dependencies
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy Next.js build output
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy server entry point and all runtime source files
|
||||
COPY --from=builder /app/server.ts ./server.ts
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
# Pre-create data directory (SQLite db + uploads land here)
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Volumes for persistent data
|
||||
# Mount your Minecraft server directory to /mc-server
|
||||
# Mount your backups directory to /backups
|
||||
VOLUME ["/app/data", "/mc-server", "/backups"]
|
||||
|
||||
CMD ["bun", "--bun", "run", "server.ts"]
|
||||
393
README.md
393
README.md
@@ -1,36 +1,389 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||

|
||||
|
||||
## Getting Started
|
||||
A production-ready Minecraft server administration panel built with Next.js, Bun, and SQLite.
|
||||
[Website](https://cubeadmin.kawa.zip) - [Wiki](https://cubeadmin.kawa.zip/wiki)
|
||||
|
||||
First, run the development server:
|
||||
## Features
|
||||
|
||||
- **Real-time console** — stream server output and send commands via Socket.io
|
||||
- **Server control** — start, stop, and restart your Minecraft server from the dashboard
|
||||
- **Player management** — view online/offline players, ban/unban, manage the whitelist
|
||||
- **Plugin management** — list, enable/disable, and upload plugins
|
||||
- **File explorer** — browse, upload, download, and delete server files
|
||||
- **Backup system** — create and restore backups on demand or on a schedule
|
||||
- **Team management** — invite team members by email with role-based access control
|
||||
- **World map** — embedded BlueMap 3D map integration
|
||||
- **Server version management** — switch between Vanilla, Paper, and Fabric versions
|
||||
- **Monitoring** — live CPU and memory charts
|
||||
- **Task scheduler** — run RCON commands on a cron schedule
|
||||
- **Audit log** — full record of all administrative actions
|
||||
- **Dark/light theme** — persisted per user
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Runtime | [Bun](https://bun.sh) |
|
||||
| Framework | [Next.js 16](https://nextjs.org) (App Router) |
|
||||
| Database | SQLite via `bun:sqlite` + [Drizzle ORM](https://orm.drizzle.team) |
|
||||
| Auth | [Better Auth v1.5](https://better-auth.com) |
|
||||
| UI | [shadcn/ui v4](https://ui.shadcn.com) + [Base UI](https://base-ui.com) + Tailwind CSS v4 |
|
||||
| Real-time | [Socket.io](https://socket.io) |
|
||||
| Email | [Nodemailer](https://nodemailer.com) (any SMTP server) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) ≥ 1.1
|
||||
- A Minecraft server with RCON enabled (or use the provided `docker-compose.dev.yml`)
|
||||
|
||||
### 1. Clone and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/cubeadmin.git
|
||||
cd cubeadmin
|
||||
bun install
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` — at minimum you need:
|
||||
|
||||
```env
|
||||
BETTER_AUTH_SECRET=your-32-char-secret-here
|
||||
MC_SERVER_PATH=/path/to/your/minecraft/server
|
||||
MC_RCON_PASSWORD=your-rcon-password
|
||||
```
|
||||
|
||||
### 3. Spin up a local Minecraft server (optional)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
This starts a Paper Minecraft server with RCON exposed on port 25575.
|
||||
|
||||
### 4. Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open [http://localhost:3000](http://localhost:3000). The first account you register automatically becomes the administrator.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
---
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Deployment
|
||||
|
||||
## Learn More
|
||||
### Option A — Docker Compose (recommended)
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
This is the easiest way to run CubeAdmin and a Minecraft server together on a single host.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
#### 1. Create your environment file
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Deploy on Vercel
|
||||
Fill in all required values (see [Environment Variables](#environment-variables) below).
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
#### 2. Start the stack
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts three services:
|
||||
|
||||
| Service | Description | Port |
|
||||
|---|---|---|
|
||||
| `cubeadmin` | The admin panel | 3000 |
|
||||
| `minecraft` | Paper Minecraft server | 25565 |
|
||||
| `bluemap` | 3D world map (optional) | 8100 |
|
||||
|
||||
#### 3. Reverse proxy (recommended)
|
||||
|
||||
Put Nginx or Caddy in front of CubeAdmin on port 3000. Example Caddyfile:
|
||||
|
||||
```
|
||||
cubeadmin.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
> **Important:** Socket.io requires WebSocket support. Ensure your proxy forwards the `Upgrade` header.
|
||||
|
||||
Nginx snippet:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost: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;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. First login
|
||||
|
||||
Navigate to `https://your-domain.com` and register the first account — it is automatically granted the `superadmin` role.
|
||||
|
||||
---
|
||||
|
||||
### Option B — Bare Metal (Bun)
|
||||
|
||||
#### 1. Build
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build
|
||||
```
|
||||
|
||||
#### 2. Run
|
||||
|
||||
```bash
|
||||
NODE_ENV=production bun --bun run server.ts
|
||||
```
|
||||
|
||||
The server binds to `0.0.0.0:3000` by default. Use `PORT` and `HOSTNAME` env vars to change this.
|
||||
|
||||
#### 3. Run as a systemd service
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cubeadmin.service
|
||||
[Unit]
|
||||
Description=CubeAdmin
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cubeadmin
|
||||
WorkingDirectory=/opt/cubeadmin
|
||||
ExecStart=/usr/local/bin/bun --bun run server.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
EnvironmentFile=/opt/cubeadmin/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cubeadmin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
CubeAdmin uses Drizzle ORM with a SQLite database. Migrations are applied automatically at startup via `lib/db/migrate.ts`.
|
||||
|
||||
To generate a new migration after changing `lib/db/schema.ts`:
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
> **Note:** `bun run db:migrate` (drizzle-kit) does not work with Bun's native `bun:sqlite` driver. Always apply migrations through the app startup migrator or manually with `bun:sqlite` scripts.
|
||||
|
||||
To open the Drizzle Studio database browser:
|
||||
|
||||
```bash
|
||||
bun run db:studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `BETTER_AUTH_SECRET` | **Yes** | — | Secret key used to sign sessions and tokens. Must be at least 32 characters. Generate one with `openssl rand -base64 32`. |
|
||||
| `BETTER_AUTH_URL` | No | `http://localhost:3000` | The public base URL of the CubeAdmin app. Used by Better Auth to construct callback URLs. |
|
||||
| `NEXT_PUBLIC_BETTER_AUTH_URL` | No | *(inferred from browser)* | Browser-side auth URL. Only needed if the public URL differs from what the browser can infer (e.g. behind a proxy). |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | No | `http://localhost:3000` | Comma-separated list of allowed origins for CORS and CSRF protection. Add your public domain in production. |
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
CubeAdmin sends email via any standard SMTP server — Gmail, Mailgun, Postfix, Brevo, Amazon SES, or your own self-hosted relay. Email is only used for team invitations and magic-link sign-in. The app starts without email configured; invitations will fail gracefully with a logged error.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SMTP_HOST` | No* | — | SMTP server hostname (e.g. `smtp.gmail.com`, `smtp.mailgun.org`, `localhost`). Required for any email to be sent. |
|
||||
| `SMTP_PORT` | No | `587` | SMTP server port. Common values: `587` (STARTTLS), `465` (TLS/SSL), `25` (plain, relay), `1025` (local dev). |
|
||||
| `SMTP_SECURE` | No | `false` | Set to `true` to use implicit TLS (port 465). Leave `false` for STARTTLS (port 587) or plain. |
|
||||
| `SMTP_USER` | No | — | SMTP authentication username. Leave unset for unauthenticated relay (e.g. local Postfix). |
|
||||
| `SMTP_PASS` | No | — | SMTP authentication password or app-specific password. |
|
||||
| `EMAIL_FROM` | No | `CubeAdmin <noreply@example.com>` | The `From` address on outgoing emails. Must be authorised by your SMTP provider to avoid spam filtering. |
|
||||
|
||||
#### Common SMTP configurations
|
||||
|
||||
**Gmail** (app password required — enable 2FA first):
|
||||
```env
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
EMAIL_FROM=CubeAdmin <you@gmail.com>
|
||||
```
|
||||
|
||||
**Mailgun**:
|
||||
```env
|
||||
SMTP_HOST=smtp.mailgun.org
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=postmaster@mg.yourdomain.com
|
||||
SMTP_PASS=your-mailgun-smtp-password
|
||||
EMAIL_FROM=CubeAdmin <noreply@yourdomain.com>
|
||||
```
|
||||
|
||||
**Amazon SES**:
|
||||
```env
|
||||
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-ses-access-key-id
|
||||
SMTP_PASS=your-ses-smtp-secret
|
||||
EMAIL_FROM=CubeAdmin <noreply@yourdomain.com>
|
||||
```
|
||||
|
||||
**Local dev with Mailpit** (catches all emails, no real sending):
|
||||
```env
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
# no SMTP_USER / SMTP_PASS needed
|
||||
```
|
||||
Run Mailpit with `docker run -p 1025:1025 -p 8025:8025 axllent/mailpit` and view emails at [http://localhost:8025](http://localhost:8025).
|
||||
|
||||
### Minecraft Server
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `MC_SERVER_PATH` | No | `/opt/minecraft/server` | Absolute path to the Minecraft server directory on the host. CubeAdmin reads/writes files here and spawns the server process from this directory. |
|
||||
| `MC_RCON_HOST` | No | `127.0.0.1` | Hostname or IP where the Minecraft RCON interface is listening. Use `minecraft` when running the Docker Compose stack. |
|
||||
| `MC_RCON_PORT` | No | `25575` | TCP port for the Minecraft RCON interface. Must match `rcon.port` in `server.properties`. |
|
||||
| `MC_RCON_PASSWORD` | **Yes*** | — | Password for the Minecraft RCON interface. Must match `rcon.password` in `server.properties`. Required for player management, whitelist, and scheduler features. |
|
||||
|
||||
*Only truly required if you want RCON-based features (player commands, scheduler). The app will start without it.
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_PATH` | No | `./data/cubeadmin.db` | Path to the SQLite database file. The directory must exist and be writable. In Docker, this is mapped to a named volume. |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `PORT` | No | `3000` | TCP port the HTTP server listens on. |
|
||||
| `HOSTNAME` | No | `0.0.0.0` | Hostname the HTTP server binds to. |
|
||||
| `NODE_ENV` | No | `development` | Set to `production` for production deployments. Affects CSP headers, error verbosity, and Hot Module Replacement. |
|
||||
|
||||
### Optional / Advanced
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `BLUEMAP_URL` | No | — | URL where BlueMap is accessible from the browser (e.g. `http://localhost:8100`). Enables the Map page. Configurable from the Server Settings UI as well. |
|
||||
| `RATE_LIMIT_RPM` | No | `100` | Maximum number of API requests per minute per IP address. Applied to all `/api/*` routes by the middleware. |
|
||||
|
||||
---
|
||||
|
||||
## Role System
|
||||
|
||||
CubeAdmin has three roles:
|
||||
|
||||
| Role | Description |
|
||||
|---|---|
|
||||
| `superadmin` | Full access — server control, settings, team management, everything. Automatically granted to the first registered user. |
|
||||
| `admin` | Can manage players, plugins, files, backups, and the scheduler. Cannot change server settings or manage team roles. |
|
||||
| `moderator` | Read-only access to most sections. Can send console commands and manage players. |
|
||||
|
||||
Roles are assigned when inviting team members. The initial superadmin can promote/demote others from the Team page.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── app/
|
||||
│ ├── (auth)/ # Login, register, accept-invite pages
|
||||
│ ├── (dashboard)/ # All protected dashboard pages
|
||||
│ │ ├── dashboard/ # Overview page (/)
|
||||
│ │ ├── console/ # Real-time server console
|
||||
│ │ ├── players/ # Player management
|
||||
│ │ ├── plugins/ # Plugin management
|
||||
│ │ ├── files/ # File explorer
|
||||
│ │ ├── backups/ # Backup manager
|
||||
│ │ ├── monitoring/ # CPU/RAM charts
|
||||
│ │ ├── scheduler/ # Cron task scheduler
|
||||
│ │ ├── team/ # Team & invitations
|
||||
│ │ ├── audit/ # Audit log
|
||||
│ │ ├── map/ # BlueMap integration
|
||||
│ │ ├── server/ # Server settings
|
||||
│ │ ├── updates/ # Version management
|
||||
│ │ └── settings/ # Account settings
|
||||
│ └── api/ # API routes
|
||||
├── components/
|
||||
│ ├── layout/ # Sidebar, Topbar
|
||||
│ └── ui/ # shadcn/ui components
|
||||
├── lib/
|
||||
│ ├── auth/ # Better Auth config + client
|
||||
│ ├── backup/ # Backup manager
|
||||
│ ├── db/ # Drizzle schema + migrations
|
||||
│ ├── email/ # Nodemailer SMTP client + email templates
|
||||
│ ├── minecraft/ # Process manager, RCON, version fetcher
|
||||
│ ├── security/ # Rate limiting
|
||||
│ └── socket/ # Socket.io server setup
|
||||
├── data/ # SQLite database (gitignored)
|
||||
├── server.ts # Bun entry point (HTTP + Socket.io)
|
||||
├── proxy.ts # Next.js middleware (auth guard + CSP)
|
||||
├── docker-compose.yml # Production stack
|
||||
└── docker-compose.dev.yml # Dev Minecraft server only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enabling RCON on Your Minecraft Server
|
||||
|
||||
Add these lines to your `server.properties`:
|
||||
|
||||
```properties
|
||||
enable-rcon=true
|
||||
rcon.port=25575
|
||||
rcon.password=your-strong-password
|
||||
broadcast-rcon-to-ops=false
|
||||
```
|
||||
|
||||
Restart the Minecraft server after editing `server.properties`.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Change the default `BETTER_AUTH_SECRET`** before going to production. A leaked secret allows anyone to forge session tokens.
|
||||
- **Use a strong RCON password.** RCON has full server control — never expose RCON port 25575 to the public internet.
|
||||
- **HTTPS in production.** Better Auth cookies are `Secure` in production; the app will not authenticate over plain HTTP.
|
||||
- **`BETTER_AUTH_TRUSTED_ORIGINS`** — add your production domain to prevent CSRF attacks from other origins.
|
||||
- The middleware (`proxy.ts`) enforces a strict Content Security Policy with per-request nonces. No inline scripts are permitted.
|
||||
- All API routes are rate-limited to 100 requests/minute per IP by default.
|
||||
|
||||
@@ -315,8 +315,19 @@ function LoginPageInner() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="mt-4 text-center text-xs text-zinc-500">
|
||||
No account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-[11px] text-zinc-600">
|
||||
<p className="mt-4 text-center text-[11px] text-zinc-600">
|
||||
CubeAdmin — Secure server management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
321
app/(auth)/register/page.tsx
Normal file
321
app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirm: z.string(),
|
||||
})
|
||||
.refine((d) => d.password === d.confirm, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirm"],
|
||||
});
|
||||
|
||||
type RegisterFormValues = z.infer<typeof registerSchema>;
|
||||
type FieldErrors = Partial<Record<keyof RegisterFormValues, string>>;
|
||||
|
||||
function CubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="16,4 28,10 16,16 4,10" fill="#059669" opacity="0.9" />
|
||||
<polygon points="4,10 16,16 16,28 4,22" fill="#047857" opacity="0.95" />
|
||||
<polygon points="28,10 16,16 16,28 28,22" fill="#10b981" opacity="0.85" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
disabled,
|
||||
children,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-zinc-300">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
className={cn(
|
||||
"h-9 bg-zinc-900/60 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:border-emerald-500/50 focus-visible:ring-emerald-500/20",
|
||||
children && "pr-10",
|
||||
error &&
|
||||
"border-red-500/50 focus-visible:border-red-500/50 focus-visible:ring-red-500/20",
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
className="flex items-center gap-1 text-xs text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function validate(): RegisterFormValues | null {
|
||||
const result = registerSchema.safeParse({ name, email, password, confirm });
|
||||
if (!result.success) {
|
||||
const errors: FieldErrors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof RegisterFormValues;
|
||||
if (!errors[field]) errors[field] = issue.message;
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return null;
|
||||
}
|
||||
setFieldErrors({});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setGlobalError(null);
|
||||
|
||||
const values = validate();
|
||||
if (!values) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const msg = error.code?.toLowerCase().includes("user_already_exists")
|
||||
? "An account with this email already exists."
|
||||
: (error.message ?? "Registration failed. Please try again.");
|
||||
setGlobalError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Account created — welcome to CubeAdmin!");
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setGlobalError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
|
||||
{/* Background grid */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 bg-[size:32px_32px] opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="h-[500px] w-[500px] rounded-full bg-emerald-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-zinc-900 ring-1 ring-white/[0.08]">
|
||||
<CubeIcon className="h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-100">
|
||||
CubeAdmin
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Minecraft Server Management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-xl bg-zinc-900/50 p-6 ring-1 ring-white/[0.08] backdrop-blur-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
Create an account
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
The first account registered becomes the administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{globalError && (
|
||||
<div
|
||||
className="mb-4 flex items-start gap-2.5 rounded-lg bg-red-500/10 px-3 py-2.5 ring-1 ring-red-500/20"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" />
|
||||
<p className="text-xs text-red-300">{globalError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
<FormField
|
||||
id="name"
|
||||
label="Display name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
error={fieldErrors.name}
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<FormField
|
||||
id="email"
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
error={fieldErrors.email}
|
||||
placeholder="admin@example.com"
|
||||
autoComplete="email"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<FormField
|
||||
id="password"
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
error={fieldErrors.password}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete="new-password"
|
||||
disabled={isPending}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="confirm"
|
||||
label="Confirm password"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
error={fieldErrors.confirm}
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
disabled={isPending}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-1 h-9 w-full bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/50 disabled:opacity-60 border-0 font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Creating account…
|
||||
</>
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-zinc-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,8 +69,9 @@ export default function ConsolePage() {
|
||||
});
|
||||
|
||||
// Receive buffered history on connect
|
||||
socket.on("history", (data: { lines: string[] }) => {
|
||||
const historicalLines = data.lines.map((line) => ({
|
||||
socket.on("history", (data: string[] | { lines: string[] }) => {
|
||||
const rawLines = Array.isArray(data) ? data : (data?.lines ?? []);
|
||||
const historicalLines = rawLines.map((line) => ({
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
type: classifyLine(line) as LogLine["type"],
|
||||
|
||||
183
app/(dashboard)/settings/page.tsx
Normal file
183
app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { User, Lock, Shield } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient, useSession } from "@/lib/auth/client";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nameLoading, startNameTransition] = useTransition();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordLoading, startPasswordTransition] = useTransition();
|
||||
|
||||
// Populate name field once session loads
|
||||
const displayName = name || session?.user?.name || "";
|
||||
|
||||
function handleNameSave() {
|
||||
if (!displayName.trim()) {
|
||||
toast.error("Name cannot be empty");
|
||||
return;
|
||||
}
|
||||
startNameTransition(async () => {
|
||||
const { error } = await authClient.updateUser({ name: displayName.trim() });
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to update name");
|
||||
} else {
|
||||
toast.success("Display name updated");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePasswordChange() {
|
||||
if (!currentPassword) {
|
||||
toast.error("Current password is required");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("New password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
startPasswordTransition(async () => {
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: false,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to change password");
|
||||
} else {
|
||||
toast.success("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Account Settings</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">Manage your profile and security preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Profile */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-emerald-500" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-500">
|
||||
Update your display name and view account info
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Email</Label>
|
||||
<Input
|
||||
value={session?.user?.email ?? ""}
|
||||
disabled
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Email address cannot be changed</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Display Name</Label>
|
||||
<Input
|
||||
value={name || session?.user?.name || ""}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
{session?.user && (
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<Shield className="w-3 h-3" />
|
||||
Role: <span className="text-zinc-300 capitalize">{(session.user as { role?: string }).role ?? "moderator"}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleNameSave}
|
||||
disabled={nameLoading || isPending}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{nameLoading ? "Saving…" : "Save Name"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator className="bg-zinc-800" />
|
||||
|
||||
{/* Password */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-emerald-500" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-500">
|
||||
Choose a strong password with at least 8 characters
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Current Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">New Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Confirm New Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePasswordChange}
|
||||
disabled={passwordLoading}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{passwordLoading ? "Changing…" : "Change Password"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
app/(dashboard)/updates/page.tsx
Normal file
254
app/(dashboard)/updates/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Download, RefreshCw, AlertTriangle, CheckCircle2, Server } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SERVER_TYPES = ["vanilla", "paper", "fabric"] as const;
|
||||
type ServerType = (typeof SERVER_TYPES)[number];
|
||||
|
||||
interface Settings {
|
||||
serverType?: string;
|
||||
serverVersion?: string;
|
||||
serverJar?: string;
|
||||
}
|
||||
|
||||
export default function UpdatesPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<ServerType>("paper");
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState("");
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/server/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.settings) {
|
||||
setSettings(data.settings);
|
||||
setSelectedType((data.settings.serverType as ServerType) ?? "paper");
|
||||
setSelectedVersion(data.settings.serverVersion ?? "");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchVersions = useCallback(async (type: string) => {
|
||||
setLoadingVersions(true);
|
||||
setVersions([]);
|
||||
try {
|
||||
const res = await fetch(`/api/server/versions?type=${type}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setVersions(data.versions ?? []);
|
||||
} else {
|
||||
toast.error("Failed to fetch versions");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Network error fetching versions");
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersions(selectedType);
|
||||
}, [selectedType, fetchVersions]);
|
||||
|
||||
const isUpToDate =
|
||||
settings?.serverVersion === selectedVersion &&
|
||||
settings?.serverType === selectedType;
|
||||
|
||||
async function handleApply() {
|
||||
if (!selectedVersion) {
|
||||
toast.error("Please select a version");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/server/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ serverType: selectedType, serverVersion: selectedVersion }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? "Failed to apply");
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, serverType: selectedType, serverVersion: selectedVersion }));
|
||||
toast.success(`Server version set to ${selectedType} ${selectedVersion}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to apply version");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = settings?.serverVersion
|
||||
? `${settings.serverType ?? "unknown"} ${settings.serverVersion}`
|
||||
: "Not configured";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Updates</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage your Minecraft server version
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current version */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-emerald-500" />
|
||||
Current Version
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingSettings ? (
|
||||
<Skeleton className="h-6 w-48 bg-zinc-800" />
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white font-mono text-sm">{currentVersion}</span>
|
||||
{settings?.serverVersion && (
|
||||
<Badge
|
||||
className={
|
||||
isUpToDate
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
||||
}
|
||||
>
|
||||
{isUpToDate ? "Up to date" : "Update available"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Version picker */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-emerald-500" />
|
||||
Select Version
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-500">
|
||||
Choose a server type and version to apply
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm text-zinc-300">Server Type</label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(v) => { if (v) setSelectedType(v as ServerType); }}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
{SERVER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm text-zinc-300">Version</label>
|
||||
<Select
|
||||
value={selectedVersion}
|
||||
onValueChange={(v) => { if (v) setSelectedVersion(v); }}
|
||||
disabled={loadingVersions || versions.length === 0}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingVersions
|
||||
? "Loading…"
|
||||
: versions.length === 0
|
||||
? "No versions found"
|
||||
: "Select version"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-64">
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white font-mono text-sm">
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={saving || !selectedVersion || loadingVersions}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" />
|
||||
{saving ? "Applying…" : "Apply Version"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchVersions(selectedType)}
|
||||
disabled={loadingVersions}
|
||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${loadingVersions ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedVersion && settings?.serverVersion && selectedVersion !== settings.serverVersion && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-400 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
Changing from <span className="font-mono">{settings.serverVersion}</span> to{" "}
|
||||
<span className="font-mono">{selectedVersion}</span> requires a server restart.
|
||||
Make sure to create a backup first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUpToDate && selectedVersion && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-emerald-400 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<p>This version is already configured.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs, users } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { deleteBackup } from "@/lib/backup/manager";
|
||||
import { db } from "@/lib/db";
|
||||
import { backups } from "@/lib/db/schema";
|
||||
@@ -10,7 +10,7 @@ export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
@@ -30,7 +30,7 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
|
||||
import { z } from "zod";
|
||||
@@ -9,7 +9,7 @@ const CreateBackupSchema = z.object({
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -12,7 +12,7 @@ const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import * as os from "node:os";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
@@ -38,7 +38,7 @@ export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, like, or, eq } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { plugins, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
@@ -36,7 +36,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
@@ -19,7 +19,7 @@ export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
@@ -56,7 +56,7 @@ export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
@@ -18,7 +18,7 @@ const TaskSchema = z.object({
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
|
||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
@@ -14,7 +14,7 @@ const ActionSchema = z.object({
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { serverSettings } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
@@ -25,7 +25,7 @@ const UpdateSettingsSchema = z.object({
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const settings = await db.select().from(serverSettings).get();
|
||||
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { auth, getAuthSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, invitations } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
@@ -15,7 +15,7 @@ const InviteSchema = z.object({
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
@@ -35,7 +35,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
const session = await getAuthSession(req.headers);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
66
app/page.tsx
66
app/page.tsx
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
@@ -27,12 +28,12 @@
|
||||
"next-safe-action": "^8.1.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"recharts": "^3.8.0",
|
||||
"resend": "^6.9.3",
|
||||
"semver": "^7.7.4",
|
||||
"shadcn": "^4.0.2",
|
||||
"socket.io": "^4.8.3",
|
||||
@@ -506,8 +507,6 @@
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -590,6 +589,8 @@
|
||||
|
||||
"@types/node-cron": ["@types/node-cron@3.0.11", "", {}, "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg=="],
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
@@ -1100,8 +1101,6 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
@@ -1520,6 +1519,8 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
@@ -1608,8 +1609,6 @@
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
@@ -1706,8 +1705,6 @@
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resend": ["resend@6.9.3", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -1808,8 +1805,6 @@
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -1860,8 +1855,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -1956,8 +1949,6 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
@@ -450,29 +451,35 @@ export function Sidebar() {
|
||||
sideOffset={8}
|
||||
className="w-52"
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{session?.user?.name ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{session?.user?.name ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Account Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Account Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
@@ -54,7 +55,8 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
"/plugins": "Plugins",
|
||||
"/files": "File Manager",
|
||||
"/backups": "Backups",
|
||||
"/settings": "Server Settings",
|
||||
"/settings": "Account Settings",
|
||||
"/server": "Server Settings",
|
||||
"/updates": "Updates",
|
||||
"/team": "Team",
|
||||
"/audit": "Audit Log",
|
||||
@@ -96,7 +98,7 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
const statusConfigs = {
|
||||
online: {
|
||||
dot: "bg-emerald-500",
|
||||
text: "Online",
|
||||
@@ -117,7 +119,12 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
||||
text: "Stopping…",
|
||||
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
|
||||
},
|
||||
}[status.status];
|
||||
};
|
||||
const config = statusConfigs[status.status] ?? {
|
||||
dot: "bg-zinc-500",
|
||||
text: status.status,
|
||||
className: "border-zinc-700/50 bg-zinc-800/50 text-zinc-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -287,6 +294,27 @@ function NotificationBell() {
|
||||
// ---------------------------------------------------------------------------
|
||||
function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Render a placeholder until mounted to avoid SSR/client mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-400"
|
||||
aria-label="Toggle theme"
|
||||
disabled
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
@@ -344,24 +372,30 @@ function UserMenu() {
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
BIN
cubeadmin-logo-dark.png
Normal file
BIN
cubeadmin-logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
cubeadmin-logo.png
Normal file
BIN
cubeadmin-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
cubeadmin:
|
||||
image: cubeadmin:latest
|
||||
# To build locally instead of pulling, uncomment:
|
||||
# build: .
|
||||
container_name: cubeadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
|
||||
# ── Email / SMTP ────────────────────────────────────────────
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
EMAIL_FROM: ${EMAIL_FROM:-CubeAdmin <noreply@example.com>}
|
||||
|
||||
# ── Minecraft / RCON ────────────────────────────────────────
|
||||
MC_SERVER_PATH: /mc-server
|
||||
MC_RCON_HOST: ${MC_RCON_HOST:-127.0.0.1}
|
||||
MC_RCON_PORT: ${MC_RCON_PORT:-25575}
|
||||
MC_RCON_PASSWORD: ${MC_RCON_PASSWORD}
|
||||
|
||||
# ── Database ────────────────────────────────────────────────
|
||||
DATABASE_PATH: /app/data/cubeadmin.db
|
||||
|
||||
# ── Security ────────────────────────────────────────────────
|
||||
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS}
|
||||
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
|
||||
|
||||
# ── First admin (only used on first startup) ─────────────────
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com}
|
||||
INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Administrator}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-ChangeMe123!}
|
||||
|
||||
# ── Optional ────────────────────────────────────────────────
|
||||
BLUEMAP_URL: ${BLUEMAP_URL:-}
|
||||
|
||||
volumes:
|
||||
# SQLite database + uploaded files
|
||||
- cubeadmin-data:/app/data
|
||||
# Minecraft server files (set MC_DATA_PATH in .env to override host path)
|
||||
- ${MC_DATA_PATH:-./mc-data}:/mc-server
|
||||
# Backups
|
||||
- ${BACKUPS_PATH:-./backups}:/backups
|
||||
|
||||
volumes:
|
||||
cubeadmin-data:
|
||||
2
drizzle/0001_gifted_loa.sql
Normal file
2
drizzle/0001_gifted_loa.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `accounts` ADD `id_token` text;--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD `password` text;
|
||||
1236
drizzle/meta/0001_snapshot.json
Normal file
1236
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1772980984285,
|
||||
"tag": "0000_overjoyed_thundra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1772984147555,
|
||||
"tag": "0001_gifted_loa",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import type { Auth } from "./index";
|
||||
* code. Mirrors the plugins registered on the server-side `auth` instance.
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
// No baseURL — uses window.location.origin automatically, which always
|
||||
// produces same-origin requests and avoids CSP connect-src issues.
|
||||
...(process.env.NEXT_PUBLIC_BETTER_AUTH_URL
|
||||
? { baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL }
|
||||
: {}),
|
||||
|
||||
plugins: [
|
||||
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { organization } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
|
||||
@@ -19,13 +20,14 @@ export const auth = betterAuth({
|
||||
// -------------------------------------------------------------------------
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
// Keys must match Better Auth's internal model names (singular).
|
||||
// usePlural: false (default) → "user", "session", "account", "verification"
|
||||
schema: {
|
||||
users: schema.users,
|
||||
sessions: schema.sessions,
|
||||
accounts: schema.accounts,
|
||||
verifications: schema.verifications,
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verifications,
|
||||
},
|
||||
usePlural: false,
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -52,6 +54,30 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Database hooks — first registered user becomes admin automatically
|
||||
// -------------------------------------------------------------------------
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
// Count all users; if this is the very first, promote to admin
|
||||
const [{ total }] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.users);
|
||||
|
||||
if (total === 1) {
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ role: "admin" } as Record<string, unknown>)
|
||||
.where(eq(schema.users.id, user.id));
|
||||
console.log(`[Auth] First user ${user.id} (${user.email}) promoted to admin`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Plugins
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -104,5 +130,18 @@ export type Auth = typeof auth;
|
||||
/** The server-side session type returned by auth.api.getSession */
|
||||
export type Session = typeof auth.$Infer.Session.session;
|
||||
|
||||
/** The user type embedded in every session */
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
/** The user type embedded in every session, with custom additionalFields */
|
||||
export type User = typeof auth.$Infer.Session.user & {
|
||||
role?: "superadmin" | "admin" | "moderator" | null;
|
||||
};
|
||||
|
||||
type RawSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||
|
||||
/** Typed wrapper around auth.api.getSession that includes the role field */
|
||||
export async function getAuthSession(
|
||||
headers: Headers,
|
||||
): Promise<(Omit<RawSession, "user"> & { user: User }) | null> {
|
||||
return auth.api.getSession({ headers }) as Promise<
|
||||
(Omit<RawSession, "user"> & { user: User }) | null
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export const users = sqliteTable("users", {
|
||||
})
|
||||
.notNull()
|
||||
.default("moderator"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
@@ -34,11 +34,11 @@ export const sessions = sqliteTable("sessions", {
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable("accounts", {
|
||||
@@ -50,18 +50,20 @@ export const accounts = sqliteTable("accounts", {
|
||||
providerId: text("provider_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
idToken: text("id_token"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||
password: text("password"), // hashed password for email/password auth
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const verifications = sqliteTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Resend } from "resend";
|
||||
import nodemailer from "nodemailer";
|
||||
import { render } from "@react-email/render";
|
||||
import { InvitationEmail } from "./templates/invitation";
|
||||
|
||||
function getResend(): Resend {
|
||||
const key = process.env.RESEND_API_KEY;
|
||||
if (!key) throw new Error("RESEND_API_KEY is not configured");
|
||||
return new Resend(key);
|
||||
function createTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
if (!host) throw new Error("SMTP_HOST is not configured");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(process.env.SMTP_PORT ?? "587"),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth:
|
||||
process.env.SMTP_USER && process.env.SMTP_PASS
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const FROM = () => process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>";
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
email,
|
||||
url,
|
||||
@@ -17,14 +28,13 @@ export async function sendMagicLinkEmail({
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
await createTransport().sendMail({
|
||||
from: FROM(),
|
||||
to: email,
|
||||
subject: "Your CubeAdmin sign-in link",
|
||||
html: `<p>Click the link below to sign in to CubeAdmin. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
|
||||
text: `Sign in to CubeAdmin: ${url}\n\nThis link expires in 1 hour.`,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send magic link email: ${error.message}`);
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail({
|
||||
@@ -38,16 +48,12 @@ export async function sendInvitationEmail({
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
const html = await render(
|
||||
InvitationEmail({ invitedByName, inviteUrl, role }),
|
||||
);
|
||||
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
const html = await render(InvitationEmail({ invitedByName, inviteUrl, role }));
|
||||
await createTransport().sendMail({
|
||||
from: FROM(),
|
||||
to,
|
||||
subject: `You've been invited to CubeAdmin`,
|
||||
html,
|
||||
text: `${invitedByName} invited you to CubeAdmin as ${role}.\n\nAccept your invitation: ${inviteUrl}\n\nThis link expires in 48 hours.`,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -28,76 +28,28 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
},
|
||||
|
||||
// Security headers (CSP + non-CSP) are applied by proxy.ts so they can
|
||||
// include a per-request nonce. Only static headers that don't conflict are
|
||||
// set here for paths the middleware doesn't cover (e.g. _next/static).
|
||||
async headers() {
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
// Scripts: self + strict-dynamic (Turbopack compatible)
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
// Styles: self + unsafe-inline (required for Tailwind/CSS-in-JS in Next.js)
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
// Fonts
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
// Images: self + data URIs + MC avatar APIs
|
||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
||||
// Connect: self + WebSocket for Socket.io
|
||||
"connect-src 'self' ws: wss:",
|
||||
// Frames: allow same-origin (BlueMap) + configurable origins
|
||||
"frame-src 'self'",
|
||||
// Frame ancestors: only same origin (replaces X-Frame-Options)
|
||||
"frame-ancestors 'self'",
|
||||
// Workers: self + blob (xterm.js, Monaco)
|
||||
"worker-src 'self' blob:",
|
||||
// Media
|
||||
"media-src 'self'",
|
||||
// Manifest
|
||||
"manifest-src 'self'",
|
||||
// Object: none
|
||||
"object-src 'none'",
|
||||
// Base URI
|
||||
"base-uri 'self'",
|
||||
// Form actions
|
||||
"form-action 'self'",
|
||||
// Upgrade insecure requests in production
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? ["upgrade-insecure-requests"]
|
||||
: []),
|
||||
].join("; ");
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: cspDirectives,
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
||||
},
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
headers: [
|
||||
// CSP is intentionally omitted here — proxy.ts owns it.
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
||||
},
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
@@ -34,12 +35,12 @@
|
||||
"next-safe-action": "^8.1.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"recharts": "^3.8.0",
|
||||
"resend": "^6.9.3",
|
||||
"semver": "^7.7.4",
|
||||
"shadcn": "^4.0.2",
|
||||
"socket.io": "^4.8.3",
|
||||
|
||||
14
proxy.ts
14
proxy.ts
@@ -51,14 +51,24 @@ function isPublicPath(pathname: string): boolean {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers applied to every response
|
||||
// ---------------------------------------------------------------------------
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
function buildCSP(nonce: string): string {
|
||||
// In dev, Next.js hot-reload and some auth libs require 'unsafe-eval'.
|
||||
// In production we restrict to 'wasm-unsafe-eval' (WebAssembly only).
|
||||
const evalDirective = isDev ? "'unsafe-eval'" : "'wasm-unsafe-eval'";
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}'`,
|
||||
`script-src 'self' 'nonce-${nonce}' ${evalDirective} 'unsafe-inline'`,
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
||||
"connect-src 'self' ws: wss:",
|
||||
// In dev, include http://localhost:* explicitly so absolute-URL fetches
|
||||
// (e.g. from Better Auth client) aren't blocked by a strict 'self' check.
|
||||
isDev
|
||||
? "connect-src 'self' http://localhost:* ws://localhost:* wss://localhost:* ws: wss:"
|
||||
: "connect-src 'self' ws: wss:",
|
||||
"frame-src 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
|
||||
11
types/better-auth.d.ts
vendored
Normal file
11
types/better-auth.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Augment Better Auth's session user type to include the `role` additional field
|
||||
* defined in lib/auth/index.ts.
|
||||
*/
|
||||
declare module "better-auth" {
|
||||
interface UserAdditionalFields {
|
||||
role?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user