Merge pull request #1 from JMR-dev/feat-mvp
Feat mvp
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.husky/_/
|
||||
*.db
|
||||
*.sqlite
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
secrets.env
|
||||
data/
|
||||
.git/
|
||||
README.md
|
||||
compose*.yml
|
||||
test/
|
||||
tests/
|
||||
coverage/
|
||||
@@ -0,0 +1,29 @@
|
||||
# MongoDB connection string for the Stoat database.
|
||||
MONGODB=mongodb://database:27017
|
||||
|
||||
# Resend credentials for invite email delivery.
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# Express session signing secret. Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET=
|
||||
|
||||
# Stoat instance metadata used in invite emails.
|
||||
INSTANCE_URL=https://chat.yourdomain.com
|
||||
INSTANCE_NAME=My Stoat Instance
|
||||
|
||||
# Admin API listen port and the HTTPS browser origin allowed by CORS.
|
||||
ADMIN_API_PORT=5181
|
||||
ADMIN_WEB_ORIGIN=https://localhost:9443
|
||||
|
||||
# Admin hostname terminated by the dedicated Caddy proxy. Use localhost for
|
||||
# local compose usage and replace it with a real name for deployment.
|
||||
ADMIN_HOSTNAME=localhost
|
||||
|
||||
# Compose-level convenience variables for the dedicated admin proxy. These
|
||||
# defaults avoid privileged ports locally; set 80/443 in deployment if needed.
|
||||
ADMIN_BIND_IP=127.0.0.1
|
||||
ADMIN_HTTP_PORT=9080
|
||||
ADMIN_HTTPS_PORT=9443
|
||||
# Optional frontend API base URL override for standalone web builds.
|
||||
ADMIN_WEB_API_URL=
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Build And Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/stoat-admin-api:latest
|
||||
ghcr.io/${{ github.repository_owner }}/stoat-admin-api:${{ github.sha }}
|
||||
|
||||
build-web:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./web
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_API_URL=http://127.0.0.1:5181
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/stoat-admin-web:latest
|
||||
ghcr.io/${{ github.repository_owner }}/stoat-admin-web:${{ github.sha }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm build
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.husky/_/
|
||||
*.db
|
||||
*.sqlite
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
secrets.env
|
||||
data/
|
||||
coverage/
|
||||
.DS_Store
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.turbo
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
This repository is intended to be distributed under the GNU Affero General Public
|
||||
License v3.0 only. Replace this placeholder with the full AGPL-3.0 license text
|
||||
before release.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Stoat Admin
|
||||
|
||||
Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a Stoat chat instance. It runs as a separate Podman Compose stack, joins the Stoat network to talk to MongoDB directly, and now ships with a dedicated `admin-proxy` Caddy service that terminates HTTPS for the admin stack with Caddy's internal CA.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-admin login with Argon2id password hashing and SQLite-backed sessions
|
||||
- Invite creation, revocation, and acceptance tracking
|
||||
- User listing, lookup, ban, unban, and scheduled deletion actions
|
||||
- Dashboard stats for users, pending invites, and recent bans
|
||||
- `pnpm` workspace with Turborepo coordinating cross-package tasks
|
||||
- Example Podman Compose, s6 service directories, and GitHub Actions workflows
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── api/ # Express + TypeScript backend
|
||||
├── proxy/ # Caddy + Coraza reverse proxy image
|
||||
├── web/ # Vite + React frontend and static image build
|
||||
├── deploy/s6/ # Example s6 service directories and systemd unit
|
||||
├── compose.yml # Production-oriented compose file
|
||||
├── compose.override.example.yml
|
||||
├── docs/ # Design and task references
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## Admin Setup
|
||||
|
||||
To create the admin user, run this command inside the admin container
|
||||
|
||||
`node dist/seed.js --username admin --password <your-password>`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 22+
|
||||
- `pnpm` via Corepack
|
||||
- Turborepo is installed through the workspace dependencies
|
||||
- A Stoat deployment with MongoDB reachable on the shared container network
|
||||
- `invite_only = true` in Stoat's `Revolt.toml`
|
||||
- Podman or Docker-compatible compose support
|
||||
- A hostname for the admin dashboard that resolves on your WireGuard/private network
|
||||
- A way to trust Caddy's internal root CA on the admin devices that will access the dashboard
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Enable `pnpm`:
|
||||
|
||||
```sh
|
||||
corepack enable
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Copy the environment template and fill in the real values:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Seed the admin account from the root workspace:
|
||||
|
||||
```sh
|
||||
pnpm seed -- --username admin --password '<strong-password>'
|
||||
```
|
||||
|
||||
5. Run both packages together through Turborepo:
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Useful targeted variants:
|
||||
|
||||
```sh
|
||||
pnpm dev:api
|
||||
pnpm dev:web
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| `MONGODB` | MongoDB connection string for the Stoat database |
|
||||
| `RESEND_API_KEY` | Resend API key for invite delivery |
|
||||
| `RESEND_FROM_EMAIL` | Sender address for invite messages |
|
||||
| `SESSION_SECRET` | Express session signing secret |
|
||||
| `INSTANCE_URL` | Public Stoat URL used in invite links |
|
||||
| `INSTANCE_NAME` | Human-readable instance name used in copy |
|
||||
| `ADMIN_API_PORT` | Listen port for `admin-api` |
|
||||
| `ADMIN_WEB_ORIGIN` | Exact HTTPS browser origin allowed by CORS |
|
||||
| `ADMIN_HOSTNAME` | Hostname served by the dedicated Caddy proxy |
|
||||
| `ADMIN_BIND_IP` | Compose bind IP for the proxy's published ports |
|
||||
| `ADMIN_HTTP_PORT` | Published HTTP port for redirect handling |
|
||||
| `ADMIN_HTTPS_PORT` | Published HTTPS port for the admin dashboard |
|
||||
| `ADMIN_WEB_API_URL` | Optional frontend API override outside compose |
|
||||
|
||||
## Deployment
|
||||
|
||||
The repo ships with a standalone `compose.yml` that expects an external `stoat_default` network. Update the network name if your Stoat stack uses a different one.
|
||||
|
||||
For local compose use, the proxy defaults to `https://localhost:9443` and `http://localhost:9080`. For deployed hosts, set `ADMIN_HOSTNAME` to the real admin name and switch `ADMIN_HTTP_PORT`/`ADMIN_HTTPS_PORT` to `80`/`443` or use [compose.override.example.yml](/home/jasonross/workspace/stoat-admin/compose.override.example.yml) as a starting point.
|
||||
|
||||
The deployment topology is:
|
||||
|
||||
- `admin-proxy` is built from [proxy/Dockerfile](/home/jasonross/workspace/stoat-admin/proxy/Dockerfile), publishes the configured HTTP and HTTPS ports, issues a private certificate from Caddy's internal CA, applies Coraza, and reverse-proxies `/api/*` to `admin-api` and everything else to `admin-web`.
|
||||
- `admin-web` and `admin-api` are no longer published directly on the host.
|
||||
- `admin-web` serves the built Vite bundle privately on the admin network.
|
||||
- `admin-api` stays attached to the shared Stoat network for MongoDB access and also joins a private admin network used by the proxy.
|
||||
|
||||
Before starting the stack, point `ADMIN_HOSTNAME` at the host running `admin-proxy` on your WireGuard/private network and set `ADMIN_WEB_ORIGIN` to `https://<that-hostname>`.
|
||||
|
||||
After the proxy has started once, install Caddy's root CA on each admin device before browsing to the dashboard. One way to export it is:
|
||||
|
||||
```sh
|
||||
docker compose exec admin-proxy sh -c 'cat /data/caddy/pki/authorities/local/root.crt' > admin-proxy-root.crt
|
||||
```
|
||||
|
||||
Then import `admin-proxy-root.crt` into the OS/browser trust store for the devices that should access the dashboard.
|
||||
|
||||
For supervised deployments:
|
||||
|
||||
1. Install `s6`
|
||||
2. Copy `deploy/s6/stoat` and `deploy/s6/stoat-admin` into `/etc/s6-services`
|
||||
3. Adjust service paths and network names
|
||||
4. Copy `deploy/s6/s6-services.service` into `/etc/systemd/system/`
|
||||
5. Enable the unit:
|
||||
|
||||
```sh
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now s6-services
|
||||
```
|
||||
|
||||
Common operations:
|
||||
|
||||
```sh
|
||||
s6-svc -r /etc/s6-services/stoat-admin
|
||||
s6-svc -d /etc/s6-services/stoat-admin
|
||||
s6-svc -u /etc/s6-services/stoat-admin
|
||||
s6-svstat /etc/s6-services/stoat-admin
|
||||
tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- The backend uses SQLite for admin credentials, audit logs, and invite metadata, and MongoDB for Stoat state.
|
||||
- In the composed deployment, the frontend always uses same-origin `/api` requests through `admin-proxy`; `VITE_API_URL` is only useful outside compose.
|
||||
- Root task orchestration is handled by Turborepo through [turbo.json](/home/jasonross/workspace/stoat-admin/turbo.json).
|
||||
- The current repo state is a first implementation slice based on the design docs in [docs/stoat-admin-design.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-design.md) and [docs/stoat-admin-tasks.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-tasks.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Keep infrastructure-specific values out of committed files. Prefer changes that preserve the split between the standalone admin stack and the main Stoat stack.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Security Policy
|
||||
|
||||
## Security Model
|
||||
|
||||
Stoat Admin is designed to keep the application containers private even when the admin entrypoint is fronted by its own HTTPS proxy. The intended deployment model is:
|
||||
|
||||
- `admin-proxy` is the only published service and terminates HTTPS for the admin stack with Caddy's internal CA
|
||||
- `admin-web` and `admin-api` are reachable only on the private admin container network
|
||||
- `admin-api` joins the Stoat network only so it can reach MongoDB
|
||||
- the dashboard is not exposed through the public Stoat reverse proxy
|
||||
- the API still requires session-based authentication with an Argon2id-hashed admin credential
|
||||
|
||||
This means the reverse proxy limits what is exposed, the shared Stoat network is used only where needed, and the application session still limits user access.
|
||||
|
||||
## Reporting
|
||||
|
||||
If you discover a security issue, avoid opening a public issue with exploit details. Share the report privately with the maintainer and include:
|
||||
|
||||
- affected version or commit
|
||||
- reproduction steps
|
||||
- impact
|
||||
- any suggested mitigation
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- Keep `SESSION_SECRET` and `RESEND_API_KEY` out of the repository.
|
||||
- Restrict permissions on the SQLite database file mounted at `/data/admin.db`.
|
||||
- Set `ADMIN_WEB_ORIGIN` precisely. Do not use `*`.
|
||||
- Verify the compose port bindings expose only `admin-proxy`, not `admin-web` or `admin-api`.
|
||||
- Trust Caddy's internal root CA only on the admin devices that should access the dashboard.
|
||||
- Protect the `admin_proxy_data` volume. It contains the private CA material used to issue the dashboard certificate.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Admin Stack
|
||||
|
||||
This repository contains the deployment configuration for the Admin interface.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Same host as Stoat, rootless user with linger.
|
||||
2. Ansible + podman + WireGuard userspace tools installed.
|
||||
3. GCP credentials for Secret Manager.
|
||||
4. Public DNS record for `admin.${DOMAIN}` in Google Cloud DNS pointing to the WG server IP (or no record at all if using `tls internal`).
|
||||
5. Cloud DNS service account provisioned with `roles/dns.admin` and stored in Secret Manager.
|
||||
|
||||
## First Deploy
|
||||
|
||||
Run the bootstrap script:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap.sh
|
||||
```
|
||||
|
||||
## Adding a new WG client
|
||||
|
||||
1. Edit `wg_clients` in `ansible/inventory.yml` (or your overriding group_vars).
|
||||
2. Re-run the wireguard playbook:
|
||||
```bash
|
||||
ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml
|
||||
```
|
||||
3. Distribute the new client config from `./generated/clients/<name>.conf`.
|
||||
|
||||
## Removing a WG client
|
||||
|
||||
1. Remove the client from `wg_clients`.
|
||||
2. Re-run the playbook.
|
||||
3. Verify in `wg show wg0` that the peer is gone.
|
||||
|
||||
## Rotating the WG server key
|
||||
|
||||
Rotating the server key is disruptive — every client config must be regenerated and redistributed.
|
||||
|
||||
1. Remove the old key from Secret Manager or create a new version.
|
||||
2. Re-run the wireguard playbook.
|
||||
|
||||
## Rotating the Caddy DNS service account key
|
||||
|
||||
1. Generate a new key with `gcloud iam service-accounts keys create`.
|
||||
2. Push to Secret Manager as a new version.
|
||||
3. Re-run bootstrap step 6 to materialize the key.
|
||||
4. Restart Caddy (`podman compose restart caddy`).
|
||||
5. Disable the old key with `gcloud iam service-accounts keys disable` and finally delete after a grace period.
|
||||
|
||||
## Redeploy Procedure
|
||||
|
||||
1. `podman compose pull`
|
||||
2. `podman compose up -d`
|
||||
3. `./scripts/verify.sh`
|
||||
|
||||
## Secret Rotation Procedure
|
||||
|
||||
1. Update the secret in GCP Secret Manager (e.g. `admin-env`).
|
||||
2. Materialize the `.env` file again.
|
||||
3. `podman compose up -d` to recreate containers with the new environment.
|
||||
|
||||
## SQLite Backup and Recovery Procedure
|
||||
|
||||
Backups are handled by `scripts/sqlite-backup.sh`.
|
||||
|
||||
1. To restore, stop the `admin-api` container.
|
||||
2. Replace the live `admin.db` in the `admin-sqlite` named volume with the snapshot file.
|
||||
3. Restart the `admin-api` container.
|
||||
@@ -0,0 +1,17 @@
|
||||
all:
|
||||
children:
|
||||
admin_host:
|
||||
hosts:
|
||||
localhost:
|
||||
ansible_connection: local
|
||||
vars:
|
||||
host_public_ip: "192.0.2.1"
|
||||
wg_subnet: "10.42.0.0/24"
|
||||
wg_server_ip: "10.42.0.1"
|
||||
wg_listen_port: 51820
|
||||
wg_clients:
|
||||
- name: jason-laptop
|
||||
ip: "10.42.0.10"
|
||||
- name: jason-phone
|
||||
ip: "10.42.0.11"
|
||||
podman_user: "stoat"
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
- name: Podman Networks Setup
|
||||
hosts: admin_host
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
tasks:
|
||||
- name: Verify linger is enabled
|
||||
ansible.builtin.command: loginctl show-user {{ podman_user }}
|
||||
register: linger_check
|
||||
changed_when: false
|
||||
failed_when: "'Linger=yes' not in linger_check.stdout"
|
||||
|
||||
- name: Admin edge network
|
||||
containers.podman.podman_network:
|
||||
name: admin-edge
|
||||
driver: bridge
|
||||
subnet: 10.89.20.0/24
|
||||
internal: false
|
||||
state: present
|
||||
|
||||
- name: Assert stoat-shared exists (Stoat's Ansible owns it)
|
||||
ansible.builtin.command: podman network inspect stoat-shared
|
||||
register: shared_check
|
||||
changed_when: false
|
||||
failed_when: shared_check.rc != 0
|
||||
@@ -0,0 +1,11 @@
|
||||
[Interface]
|
||||
PrivateKey = {{ client.private_key }}
|
||||
Address = {{ client.ip }}/24
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = {{ wg_server_public_key }}
|
||||
PresharedKey = {{ client.psk }}
|
||||
Endpoint = {{ host_public_ip }}:{{ wg_listen_port }}
|
||||
AllowedIPs = {{ wg_subnet }}
|
||||
PersistentKeepalive = 25
|
||||
@@ -0,0 +1,13 @@
|
||||
[Interface]
|
||||
PrivateKey = {{ wg_server_private_key }}
|
||||
Address = {{ wg_server_ip }}/24
|
||||
ListenPort = {{ wg_listen_port }}
|
||||
SaveConfig = false
|
||||
|
||||
{% for client in wg_clients_enriched %}
|
||||
[Peer]
|
||||
# {{ client.name }}
|
||||
PublicKey = {{ client.public_key }}
|
||||
PresharedKey = {{ client.psk }}
|
||||
AllowedIPs = {{ client.ip }}/32
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
- name: WireGuard Host Setup
|
||||
hosts: admin_host
|
||||
become: true
|
||||
tasks:
|
||||
- name: Ensure wireguard and tools are installed
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- wireguard-tools
|
||||
- firewalld
|
||||
state: present
|
||||
|
||||
- name: Ensure firewalld is running and enabled
|
||||
ansible.builtin.systemd:
|
||||
name: firewalld
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Open WG UDP port on public zone
|
||||
ansible.posix.firewalld:
|
||||
zone: public
|
||||
port: "{{ wg_listen_port }}/udp"
|
||||
permanent: true
|
||||
state: enabled
|
||||
notify: Reload firewalld
|
||||
|
||||
- name: Check if WG server private key exists in Secret Manager
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: >
|
||||
gcloud secrets versions access latest --secret=admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }}
|
||||
register: wg_sm_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Generate WG server private key locally if not in Secret Manager
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: wg genkey
|
||||
register: wg_local_gen
|
||||
when: wg_sm_check.rc != 0
|
||||
changed_when: true
|
||||
|
||||
- name: Create Secret in Secret Manager if missing
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: >
|
||||
gcloud secrets create admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }} --replication-policy="automatic"
|
||||
when: wg_sm_check.rc != 0
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Push new WG server private key to Secret Manager
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: >
|
||||
gcloud secrets versions add admin-wg-server-key
|
||||
--data-file=-
|
||||
--project={{ lookup('env', 'GCP_PROJECT_ID') }}
|
||||
args:
|
||||
stdin: "{{ wg_local_gen.stdout }}"
|
||||
when: wg_sm_check.rc != 0
|
||||
|
||||
- name: Set server private key variable
|
||||
ansible.builtin.set_fact:
|
||||
wg_server_private_key: "{{ wg_sm_check.stdout if wg_sm_check.rc == 0 else wg_local_gen.stdout }}"
|
||||
no_log: true
|
||||
|
||||
- name: Generate server public key
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: wg pubkey
|
||||
args:
|
||||
stdin: "{{ wg_server_private_key }}"
|
||||
register: wg_server_pub_gen
|
||||
changed_when: false
|
||||
|
||||
- name: Set server public key variable
|
||||
ansible.builtin.set_fact:
|
||||
wg_server_public_key: "{{ wg_server_pub_gen.stdout }}"
|
||||
|
||||
- name: Ensure /etc/wireguard directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/wireguard
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Generate client keys
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.shell: |
|
||||
priv=$(wg genkey)
|
||||
pub=$(echo "$priv" | wg pubkey)
|
||||
psk=$(wg genpsk)
|
||||
echo '{"private_key": "'$priv'", "public_key": "'$pub'", "psk": "'$psk'"}'
|
||||
register: wg_client_keys_gen
|
||||
with_items: "{{ wg_clients }}"
|
||||
changed_when: true
|
||||
no_log: true
|
||||
|
||||
- name: Enrich wg_clients with keys
|
||||
ansible.builtin.set_fact:
|
||||
wg_clients_enriched: >-
|
||||
{{
|
||||
wg_clients_enriched | default([]) +
|
||||
[item.0 | combine(item.1.stdout | from_json)]
|
||||
}}
|
||||
loop: "{{ wg_clients | zip(wg_client_keys_gen.results) | list }}"
|
||||
no_log: true
|
||||
|
||||
- name: Render server wg0.conf
|
||||
ansible.builtin.template:
|
||||
src: templates/wg0.conf.j2
|
||||
dest: /etc/wireguard/wg0.conf
|
||||
mode: "0600"
|
||||
notify: Restart wg-quick
|
||||
|
||||
- name: Enable and start wg-quick@wg0
|
||||
ansible.builtin.systemd:
|
||||
name: wg-quick@wg0
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Ensure client config directory exists on control machine
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.file:
|
||||
path: "{{ playbook_dir }}/../generated/clients"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Render client configs on control machine
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.template:
|
||||
src: templates/client.conf.j2
|
||||
dest: "{{ playbook_dir }}/../generated/clients/{{ item.name }}.conf"
|
||||
mode: "0600"
|
||||
loop: "{{ wg_clients_enriched }}"
|
||||
vars:
|
||||
client: "{{ item }}"
|
||||
no_log: true
|
||||
|
||||
handlers:
|
||||
- name: Reload firewalld
|
||||
ansible.builtin.systemd:
|
||||
name: firewalld
|
||||
state: reloaded
|
||||
|
||||
- name: Restart wg-quick
|
||||
ansible.builtin.systemd:
|
||||
name: wg-quick@wg0
|
||||
state: restarted
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
}
|
||||
|
||||
admin.{$DOMAIN} {
|
||||
tls {
|
||||
dns googleclouddns {
|
||||
gcp_project {$GCP_PROJECT_ID}
|
||||
gcp_application_default /etc/caddy/credentials/sa.json
|
||||
}
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy admin-api:3000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy admin-frontend:3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM caddy:2.8.4-builder AS builder
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddy-dns/googleclouddns
|
||||
|
||||
FROM caddy:2.8.4
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
@@ -0,0 +1,59 @@
|
||||
networks:
|
||||
admin-edge:
|
||||
external: true
|
||||
stoat-shared:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
admin-sqlite:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
services:
|
||||
caddy:
|
||||
build: ./caddy
|
||||
ports:
|
||||
- "${WG_SERVER_IP}:80:80"
|
||||
- "${WG_SERVER_IP}:443:443"
|
||||
volumes:
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- ./secrets/caddy-dns-sa.json:/etc/caddy/credentials/sa.json:ro
|
||||
environment:
|
||||
GCP_PROJECT_ID: ${GCP_PROJECT_ID}
|
||||
ACME_EMAIL: ${ACME_EMAIL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
networks:
|
||||
- admin-edge
|
||||
restart: unless-stopped
|
||||
|
||||
admin-frontend:
|
||||
image: ${ADMIN_FRONTEND_IMAGE}
|
||||
environment:
|
||||
- API_URL=http://admin-api:3000
|
||||
networks:
|
||||
- admin-edge
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
admin-api:
|
||||
image: ${ADMIN_API_IMAGE}
|
||||
volumes:
|
||||
- admin-sqlite:/data/db
|
||||
environment:
|
||||
- SQLITE_DB_PATH=/data/db/admin.db
|
||||
- MONGO_URL=mongodb://admin_stack_ro:${ADMIN_STACK_DB_PASSWORD}@mongodb:27017/revolt
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
networks:
|
||||
- admin-edge
|
||||
- stoat-shared
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== 1. Verifying rootless podman ==="
|
||||
PODMAN_USER=$(whoami)
|
||||
if ! loginctl show-user ${PODMAN_USER} | grep -q "Linger=yes"; then
|
||||
echo "Error: Linger is not enabled for user ${PODMAN_USER}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 2. Ansible: WireGuard ==="
|
||||
ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml
|
||||
|
||||
echo "Client configs generated at: $(realpath ./ansible/../generated/clients)"
|
||||
echo "Please securely copy these to your client devices."
|
||||
|
||||
echo "=== 3. Verify wg0 ==="
|
||||
if ! wg show wg0 >/dev/null 2>&1; then
|
||||
echo "Error: wg0 interface is not up"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== 4. Ansible: Networks ==="
|
||||
ansible-playbook -i ansible/inventory.yml ansible/networks.yml
|
||||
|
||||
echo "=== 5. Materialize .env ==="
|
||||
gcloud secrets versions access latest --secret=admin-env > .env
|
||||
chmod 600 .env
|
||||
|
||||
echo "=== 6. Materialize Caddy SA Key ==="
|
||||
mkdir -p ./secrets
|
||||
gcloud secrets versions access latest --secret=admin-caddy-dns-sa-key > ./secrets/caddy-dns-sa.json
|
||||
chmod 600 ./secrets/caddy-dns-sa.json
|
||||
|
||||
echo "=== 7. Podman Compose Build ==="
|
||||
podman compose build
|
||||
|
||||
echo "=== 8. Podman Compose Pull ==="
|
||||
podman compose pull
|
||||
|
||||
echo "=== 9. Podman Compose Up ==="
|
||||
podman compose up -d
|
||||
|
||||
echo "=== 10. Wait for services ==="
|
||||
echo "Waiting up to 120s for services to become healthy..."
|
||||
sleep 10 # Let them start
|
||||
|
||||
echo "=== 11. Verify ==="
|
||||
./scripts/verify.sh
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SRC_VOLUME="admin-sqlite"
|
||||
DEST_DIR="/var/backups/admin-sqlite"
|
||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
DEST_FILE="${DEST_DIR}/admin-${TIMESTAMP}.sqlite"
|
||||
|
||||
mkdir -p "${DEST_DIR}"
|
||||
|
||||
# Use sqlite3 .backup for an atomic snapshot
|
||||
podman run --rm \
|
||||
-v "${SRC_VOLUME}:/data:ro" \
|
||||
-v "${DEST_DIR}:/out" \
|
||||
docker.io/keinos/sqlite3:3.42.0 \
|
||||
sqlite3 /data/admin.db ".backup '/out/admin-${TIMESTAMP}.sqlite'"
|
||||
|
||||
# Retain last 7 snapshots locally
|
||||
ls -1t "${DEST_DIR}"/admin-*.sqlite | tail -n +8 | xargs -r rm
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
WG_SERVER_IP="${WG_SERVER_IP:-10.42.0.1}"
|
||||
HOST_PUBLIC_IP=$(curl -s ifconfig.me || echo "127.0.0.1")
|
||||
|
||||
echo "Running verification checks..."
|
||||
|
||||
# 1. WG interface up
|
||||
if wg show wg0 >/dev/null 2>&1 && wg show wg0 peers | grep -q .; then
|
||||
echo "[ok] WG interface wg0 is up and has peers"
|
||||
else
|
||||
echo "[fail] WG interface wg0 is down or has no peers"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. WG IP bound
|
||||
if ip -o addr show wg0 | grep -q "${WG_SERVER_IP}"; then
|
||||
echo "[ok] WG interface wg0 bound to ${WG_SERVER_IP}"
|
||||
else
|
||||
echo "[fail] WG interface wg0 is not bound to ${WG_SERVER_IP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Caddy listening on WG IP, NOT public IP
|
||||
if ss -tlnp | grep -E ':443\b' | grep -q "${WG_SERVER_IP}"; then
|
||||
if ss -tlnp | grep -E ':443\b' | grep -q -E "0\.0\.0\.0|::|\*"; then
|
||||
echo "[fail] Caddy is bound to public IP"
|
||||
exit 1
|
||||
else
|
||||
echo "[ok] Caddy is bound only to WG IP"
|
||||
fi
|
||||
else
|
||||
echo "[fail] Caddy is not bound to ${WG_SERVER_IP}:443"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Admin endpoint NOT reachable from public
|
||||
if curl --max-time 3 -k https://${HOST_PUBLIC_IP}/ >/dev/null 2>&1; then
|
||||
echo "[fail] Admin endpoint is reachable from public IP"
|
||||
exit 1
|
||||
else
|
||||
echo "[ok] Admin endpoint is not reachable from public IP"
|
||||
fi
|
||||
|
||||
# 5. Networks present
|
||||
if podman network inspect admin-edge >/dev/null 2>&1 && podman network inspect stoat-shared >/dev/null 2>&1; then
|
||||
echo "[ok] Podman networks admin-edge and stoat-shared exist"
|
||||
else
|
||||
echo "[fail] Required podman networks are missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. All expected services healthy
|
||||
SERVICES=("admin-stack-caddy-1" "admin-stack-admin-frontend-1" "admin-stack-admin-api-1")
|
||||
for service in "${SERVICES[@]}"; do
|
||||
if podman ps --format "{{.Names}}" | grep -q "${service}"; then
|
||||
echo "[ok] Service ${service} is running"
|
||||
else
|
||||
echo "[fail] Service ${service} is not running"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. admin-api can reach MongoDB
|
||||
API_CONTAINER=$(podman ps -q -f name=admin-stack-admin-api-1)
|
||||
if podman exec "${API_CONTAINER}" curl -s http://localhost:3000/health >/dev/null 2>&1; then
|
||||
echo "[ok] admin-api healthcheck passed"
|
||||
else
|
||||
echo "[fail] admin-api healthcheck failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 8. No unexpected host ports bound
|
||||
if podman ps --format '{{.Ports}}' | grep -v "${WG_SERVER_IP}" | grep -q ":"; then
|
||||
echo "[fail] Unexpected ports bound"
|
||||
podman ps --format '{{.Names}}: {{.Ports}}'
|
||||
exit 1
|
||||
else
|
||||
echo "[ok] No unexpected host ports bound"
|
||||
fi
|
||||
|
||||
echo "All checks passed!"
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.env
|
||||
.env.*
|
||||
secrets.env
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
data/
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:24-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile=false
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:24-slim
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN corepack enable && pnpm install --prod --frozen-lockfile=false
|
||||
COPY --from=build /app/dist ./dist
|
||||
RUN mkdir -p /data
|
||||
EXPOSE 5181
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,37 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"]
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
},
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "stoat-admin-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.6.3",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "pnpm lint && pnpm typecheck",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"better-sqlite3-session-store": "^0.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.18.1",
|
||||
"helmet": "^8.0.0",
|
||||
"mongodb": "^7.1.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"node-cron": "^4.0.7",
|
||||
"resend": "^6.10.0",
|
||||
"ulid": "^3.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^10.1.0",
|
||||
"globals": "^17.4.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { sqlite } from "./sqlite.js";
|
||||
|
||||
const insertAuditLog = sqlite.prepare<[string, string, string | null]>(
|
||||
"INSERT INTO audit_log (action, target, details) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
export function logAction(
|
||||
action: string,
|
||||
target: string,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
insertAuditLog.run(action, target, details ? JSON.stringify(details) : null);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MongoClient, type Collection, type Db } from "mongodb";
|
||||
|
||||
import { env } from "../lib/env.js";
|
||||
import type {
|
||||
AccountDocument,
|
||||
InviteDocument,
|
||||
SessionDocument,
|
||||
StrikeDocument,
|
||||
UserDocument
|
||||
} from "./types.js";
|
||||
|
||||
let client: MongoClient | null = null;
|
||||
let db: Db | null = null;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectMongo(): Promise<Db> {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
client = new MongoClient(env.MONGODB);
|
||||
await client.connect();
|
||||
db = client.db("revolt");
|
||||
return db;
|
||||
} catch (error) {
|
||||
const waitMs = Math.min(1000 * 2 ** attempt, 30_000);
|
||||
attempt += 1;
|
||||
console.error(
|
||||
`MongoDB connection failed. Retrying in ${waitMs}ms.`,
|
||||
error
|
||||
);
|
||||
await delay(waitMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Db {
|
||||
if (!db) {
|
||||
throw new Error("MongoDB has not been connected yet");
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export function accounts(): Collection<AccountDocument> {
|
||||
return getDb().collection<AccountDocument>("accounts");
|
||||
}
|
||||
|
||||
export function users(): Collection<UserDocument> {
|
||||
return getDb().collection<UserDocument>("users");
|
||||
}
|
||||
|
||||
export function sessions(): Collection<SessionDocument> {
|
||||
return getDb().collection<SessionDocument>("sessions");
|
||||
}
|
||||
|
||||
export function invites(): Collection<InviteDocument> {
|
||||
return getDb().collection<InviteDocument>("invites");
|
||||
}
|
||||
|
||||
export function safetyStrikes(): Collection<StrikeDocument> {
|
||||
return getDb().collection<StrikeDocument>("safety_strikes");
|
||||
}
|
||||
|
||||
export async function closeMongo(): Promise<void> {
|
||||
if (client) {
|
||||
await client.close();
|
||||
client = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import type { AdminUserRecord, InviteRecord } from "./types.js";
|
||||
|
||||
// Keep local development aligned with the compose-mounted ./data directory.
|
||||
const workspaceSqlitePath = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"data",
|
||||
"admin.db"
|
||||
);
|
||||
|
||||
function resolveSqlitePath(): string {
|
||||
if (existsSync("/data")) {
|
||||
return "/data/admin.db";
|
||||
}
|
||||
|
||||
return workspaceSqlitePath;
|
||||
}
|
||||
|
||||
export const sqlitePath = resolveSqlitePath();
|
||||
|
||||
mkdirSync(dirname(sqlitePath), { recursive: true });
|
||||
|
||||
export const sqlite = new Database(sqlitePath);
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT,
|
||||
accepted_at TEXT,
|
||||
resend_message_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
export const statements = {
|
||||
getAdminUserByUsername: sqlite.prepare<[string], AdminUserRecord>(
|
||||
"SELECT id, username, password_hash FROM admin_user WHERE username = ?"
|
||||
),
|
||||
getFirstAdminUser: sqlite.prepare<[], AdminUserRecord>(
|
||||
"SELECT id, username, password_hash FROM admin_user ORDER BY id ASC LIMIT 1"
|
||||
),
|
||||
insertAdminUser: sqlite.prepare<[string, string]>(
|
||||
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)"
|
||||
),
|
||||
updateAdminPasswordByUsername: sqlite.prepare<[string, string]>(
|
||||
"UPDATE admin_user SET password_hash = ? WHERE username = ?"
|
||||
),
|
||||
listInviteRecords: sqlite.prepare<[], InviteRecord>(
|
||||
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
|
||||
FROM invite_records
|
||||
ORDER BY created_at DESC`
|
||||
),
|
||||
countInviteRecords: sqlite.prepare<[], { count: number }>(
|
||||
"SELECT COUNT(*) AS count FROM invite_records"
|
||||
),
|
||||
countPendingInvites: sqlite.prepare<[], { count: number }>(
|
||||
"SELECT COUNT(*) AS count FROM invite_records WHERE status = 'pending'"
|
||||
),
|
||||
insertInviteRecord: sqlite.prepare<[string, string, string | null]>(
|
||||
"INSERT INTO invite_records (code, email, status, expires_at) VALUES (?, ?, 'pending', ?)"
|
||||
),
|
||||
getInviteRecordByCode: sqlite.prepare<[string], InviteRecord>(
|
||||
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
|
||||
FROM invite_records
|
||||
WHERE code = ?`
|
||||
),
|
||||
updateInviteResendMessage: sqlite.prepare<[string | null, string]>(
|
||||
"UPDATE invite_records SET resend_message_id = ? WHERE code = ?"
|
||||
),
|
||||
markInviteRevoked: sqlite.prepare<[string]>(
|
||||
"UPDATE invite_records SET status = 'revoked' WHERE code = ?"
|
||||
),
|
||||
selectPendingInvites: sqlite.prepare<[], InviteRecord>(
|
||||
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
|
||||
FROM invite_records
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC`
|
||||
),
|
||||
markInviteAccepted: sqlite.prepare<[string]>(
|
||||
"UPDATE invite_records SET status = 'accepted', accepted_at = datetime('now') WHERE code = ?"
|
||||
),
|
||||
markInviteExpired: sqlite.prepare<[string]>(
|
||||
"UPDATE invite_records SET status = 'expired' WHERE code = ?"
|
||||
),
|
||||
countRecentBans: sqlite.prepare<[], { count: number }>(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days')"
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface AccountDocument {
|
||||
_id: string;
|
||||
email: string;
|
||||
email_normalised?: string;
|
||||
disabled: boolean;
|
||||
spam?: boolean;
|
||||
verification?: {
|
||||
status: "Verified" | "Pending" | "Moving";
|
||||
};
|
||||
deletion?: {
|
||||
status: "Scheduled" | "WaitingForVerification" | "Deleted";
|
||||
after?: string;
|
||||
};
|
||||
lockout?: {
|
||||
attempts: number;
|
||||
expiry: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserDocument {
|
||||
_id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
flags?: number;
|
||||
avatar?: unknown;
|
||||
}
|
||||
|
||||
export interface SessionDocument {
|
||||
_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface InviteDocument {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export interface StrikeDocument {
|
||||
_id: string;
|
||||
user_id: string;
|
||||
reason: string;
|
||||
type?: "strike" | "suspension" | "ban";
|
||||
case_id?: string;
|
||||
}
|
||||
|
||||
export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired";
|
||||
|
||||
export interface InviteRecord {
|
||||
id: number;
|
||||
code: string;
|
||||
email: string;
|
||||
status: InviteRecordStatus;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
accepted_at: string | null;
|
||||
resend_message_id: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogRecord {
|
||||
id: number;
|
||||
action: string;
|
||||
target: string;
|
||||
details: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cron from "node-cron";
|
||||
|
||||
import "./db/sqlite.js";
|
||||
import { connectMongo } from "./db/mongo.js";
|
||||
import { syncInviteStatuses } from "./jobs/inviteSync.js";
|
||||
import { env } from "./lib/env.js";
|
||||
import { errorHandler, notFound } from "./middleware/errors.js";
|
||||
import { requireAuth, sessionMiddleware } from "./middleware/auth.js";
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { dashboardRouter } from "./routes/dashboard.js";
|
||||
import { invitesRouter } from "./routes/invites.js";
|
||||
import { usersRouter } from "./routes/users.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await connectMongo();
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: false
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.ADMIN_WEB_ORIGIN,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/invites", requireAuth, invitesRouter);
|
||||
app.use("/api/users", requireAuth, usersRouter);
|
||||
app.use("/api/dashboard", requireAuth, dashboardRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
await syncInviteStatuses();
|
||||
cron.schedule("*/5 * * * *", () => {
|
||||
void syncInviteStatuses().catch((error) => {
|
||||
console.error("Invite sync failed", error);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(env.ADMIN_API_PORT, () => {
|
||||
console.log(`admin-api listening on :${env.ADMIN_API_PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("Failed to start admin-api", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { invites } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
|
||||
export async function syncInviteStatuses(): Promise<void> {
|
||||
const pendingInvites = statements.selectPendingInvites.all();
|
||||
|
||||
for (const record of pendingInvites) {
|
||||
const inviteExists = await invites().findOne({ _id: record.code });
|
||||
|
||||
if (!inviteExists) {
|
||||
statements.markInviteAccepted.run(record.code);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
record.expires_at &&
|
||||
new Date(record.expires_at).getTime() < Date.now()
|
||||
) {
|
||||
statements.markInviteExpired.run(record.code);
|
||||
await invites().deleteOne({ _id: record.code });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
|
||||
type AsyncRouteHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void>;
|
||||
|
||||
export function asyncHandler(handler: AsyncRouteHandler): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
void handler(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
MONGODB: z.string().min(1, "MONGODB is required"),
|
||||
RESEND_API_KEY: z.string().min(1, "RESEND_API_KEY is required"),
|
||||
RESEND_FROM_EMAIL: z
|
||||
.string()
|
||||
.email("RESEND_FROM_EMAIL must be a valid email"),
|
||||
SESSION_SECRET: z
|
||||
.string()
|
||||
.min(32, "SESSION_SECRET must be at least 32 characters"),
|
||||
INSTANCE_URL: z.string().url("INSTANCE_URL must be a valid URL"),
|
||||
INSTANCE_NAME: z.string().min(1, "INSTANCE_NAME is required"),
|
||||
ADMIN_API_PORT: z.coerce.number().int().positive().default(5181),
|
||||
ADMIN_WEB_ORIGIN: z.string().url("ADMIN_WEB_ORIGIN must be a valid URL")
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsedEnv.success) {
|
||||
console.error("Invalid environment configuration.");
|
||||
console.error(JSON.stringify(parsedEnv.error.flatten().fieldErrors, null, 2));
|
||||
throw new Error("Environment validation failed");
|
||||
}
|
||||
|
||||
export const env = parsedEnv.data;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const USER_FLAG_SUSPENDED = 1;
|
||||
export const USER_FLAG_DELETED = 2;
|
||||
export const USER_FLAG_BANNED = 4;
|
||||
|
||||
export function hasFlag(
|
||||
flags: number | null | undefined,
|
||||
mask: number
|
||||
): boolean {
|
||||
return ((flags ?? 0) & mask) === mask;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import session from "express-session";
|
||||
import connectSqlite3 from "better-sqlite3-session-store";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { env } from "../lib/env.js";
|
||||
import { sqlite } from "../db/sqlite.js";
|
||||
|
||||
const SQLiteStore = connectSqlite3(session);
|
||||
export const SESSION_COOKIE_NAME = "stoat-admin.sid";
|
||||
const SESSION_COOKIE_SECURE =
|
||||
new URL(env.ADMIN_WEB_ORIGIN).protocol === "https:";
|
||||
export const SESSION_COOKIE_OPTIONS = {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: SESSION_COOKIE_SECURE
|
||||
} as const;
|
||||
|
||||
export const sessionMiddleware = session({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
secret: env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new SQLiteStore({
|
||||
client: sqlite,
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 15 * 60 * 1000
|
||||
}
|
||||
}),
|
||||
cookie: {
|
||||
...SESSION_COOKIE_OPTIONS,
|
||||
maxAge: 2 * 60 * 60 * 1000
|
||||
}
|
||||
});
|
||||
|
||||
export const requireAuth: RequestHandler = (req, res, next) => {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from "express";
|
||||
|
||||
export const notFound: RequestHandler = (_req, res) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import argon2 from "argon2";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import {
|
||||
requireAuth,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_COOKIE_OPTIONS
|
||||
} from "../middleware/auth.js";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.post(
|
||||
"/login",
|
||||
asyncHandler(async (req, res) => {
|
||||
const credentials = loginSchema.parse(req.body);
|
||||
const user = statements.getAdminUserByUsername.get(credentials.username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await argon2.verify(
|
||||
user.password_hash,
|
||||
credentials.password
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
|
||||
res.status(200).json({ username: user.username });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/logout",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.destroy((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
res.clearCookie(SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.get(
|
||||
"/me",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
res.status(200).json({ username: req.session.username });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { users } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { USER_FLAG_BANNED } from "../lib/flags.js";
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
dashboardRouter.get(
|
||||
"/stats",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const [totalUsers, bannedUserAggregate] = await Promise.all([
|
||||
users().countDocuments({}),
|
||||
users()
|
||||
.aggregate([
|
||||
{ $match: { flags: { $bitsAllSet: USER_FLAG_BANNED } } },
|
||||
{ $count: "count" }
|
||||
])
|
||||
.toArray()
|
||||
]);
|
||||
|
||||
const pendingInvites = statements.countPendingInvites.get()?.count ?? 0;
|
||||
const recentBans = statements.countRecentBans.get()?.count ?? 0;
|
||||
const bannedUsers = bannedUserAggregate[0]?.count ?? 0;
|
||||
|
||||
res.status(200).json({
|
||||
totalUsers,
|
||||
bannedUsers,
|
||||
pendingInvites,
|
||||
recentBans
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router } from "express";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAction } from "../db/audit.js";
|
||||
import { invites } from "../db/mongo.js";
|
||||
import { statements } from "../db/sqlite.js";
|
||||
import type { InviteRecord } from "../db/types.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { env } from "../lib/env.js";
|
||||
|
||||
const inviteAlphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
const generateCode = customAlphabet(inviteAlphabet, 12);
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
|
||||
const createInviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
expiresInHours: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(24 * 365)
|
||||
.optional()
|
||||
});
|
||||
|
||||
function getInviteRecordOrThrow(code: string): InviteRecord {
|
||||
const record = statements.getInviteRecordByCode.get(code);
|
||||
if (!record) {
|
||||
throw new Error(`Invite record ${code} not found after insert`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export const invitesRouter = Router();
|
||||
|
||||
invitesRouter.get(
|
||||
"/",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const inviteRecords = statements.listInviteRecords.all();
|
||||
const count =
|
||||
statements.countInviteRecords.get()?.count ?? inviteRecords.length;
|
||||
|
||||
res.status(200).json({ invites: inviteRecords, count });
|
||||
})
|
||||
);
|
||||
|
||||
invitesRouter.post(
|
||||
"/",
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = createInviteSchema.parse(req.body);
|
||||
const code = generateCode();
|
||||
const expiresAt = payload.expiresInHours
|
||||
? new Date(
|
||||
Date.now() + payload.expiresInHours * 60 * 60 * 1000
|
||||
).toISOString()
|
||||
: null;
|
||||
|
||||
await invites().insertOne({ _id: code });
|
||||
statements.insertInviteRecord.run(code, payload.email, expiresAt);
|
||||
|
||||
let warning: string | undefined;
|
||||
|
||||
try {
|
||||
const response = await resend.emails.send({
|
||||
from: env.RESEND_FROM_EMAIL,
|
||||
to: payload.email,
|
||||
subject: `You've been invited to ${env.INSTANCE_NAME}`,
|
||||
text: `You've been invited to ${env.INSTANCE_NAME}.\n\nUse this invite link to register:\n${env.INSTANCE_URL}?invite=${code}`
|
||||
});
|
||||
|
||||
const messageId = response.data?.id ?? null;
|
||||
statements.updateInviteResendMessage.run(messageId, code);
|
||||
} catch (error) {
|
||||
console.error("Invite email delivery failed", error);
|
||||
warning = "Invite created but email delivery failed";
|
||||
}
|
||||
|
||||
logAction("invite_created", payload.email, {
|
||||
code,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
|
||||
const record = getInviteRecordOrThrow(code);
|
||||
res.status(201).json({
|
||||
invite: record,
|
||||
...(warning ? { warning } : {})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
invitesRouter.delete(
|
||||
"/:code",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { code } = z.object({ code: z.string().min(1) }).parse(req.params);
|
||||
const record = statements.getInviteRecordByCode.get(code);
|
||||
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Invite not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.status !== "pending") {
|
||||
res.status(400).json({ error: "Only pending invites can be revoked" });
|
||||
return;
|
||||
}
|
||||
|
||||
await invites().deleteOne({ _id: code });
|
||||
statements.markInviteRevoked.run(code);
|
||||
logAction("invite_revoked", record.email, { code });
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Router } from "express";
|
||||
import { ulid } from "ulid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAction } from "../db/audit.js";
|
||||
import { accounts, safetyStrikes, sessions, users } from "../db/mongo.js";
|
||||
import { asyncHandler } from "../lib/async-handler.js";
|
||||
import { USER_FLAG_BANNED, USER_FLAG_DELETED } from "../lib/flags.js";
|
||||
|
||||
const listUsersSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
search: z.string().trim().optional()
|
||||
});
|
||||
|
||||
const userIdParamsSchema = z.object({
|
||||
id: z.string().min(1)
|
||||
});
|
||||
|
||||
const banSchema = z.object({
|
||||
reason: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
reason: z.string().trim().optional()
|
||||
});
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get(
|
||||
"/",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { page, limit, search } = listUsersSchema.parse(req.query);
|
||||
|
||||
const basePipeline = [
|
||||
{
|
||||
$lookup: {
|
||||
from: "accounts",
|
||||
localField: "_id",
|
||||
foreignField: "_id",
|
||||
as: "account",
|
||||
pipeline: [
|
||||
{
|
||||
$project: {
|
||||
email: 1,
|
||||
disabled: 1,
|
||||
verification: 1,
|
||||
deletion: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
$unwind: {
|
||||
path: "$account",
|
||||
preserveNullAndEmptyArrays: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const searchStage = search
|
||||
? [
|
||||
{
|
||||
$match: {
|
||||
"account.email": {
|
||||
$regex: search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
$options: "i"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
const recordsPipeline = [
|
||||
...basePipeline,
|
||||
...searchStage,
|
||||
{ $sort: { username: 1, discriminator: 1, _id: 1 } },
|
||||
{ $skip: (page - 1) * limit },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
discriminator: 1,
|
||||
flags: 1,
|
||||
avatar: 1,
|
||||
account: 1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const totalPipeline = [
|
||||
...basePipeline,
|
||||
...searchStage,
|
||||
{ $count: "total" }
|
||||
];
|
||||
|
||||
const [userRecords, totalResult] = await Promise.all([
|
||||
users().aggregate(recordsPipeline).toArray(),
|
||||
users().aggregate(totalPipeline).toArray()
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
users: userRecords,
|
||||
total: totalResult[0]?.total ?? 0,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.get(
|
||||
"/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
|
||||
const [user, account, strikes] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id }),
|
||||
safetyStrikes().find({ user_id: id }).sort({ _id: -1 }).toArray()
|
||||
]);
|
||||
|
||||
if (!user && !account) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ user, account, strikes });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.post(
|
||||
"/:id/ban",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
const { reason } = banSchema.parse(req.body);
|
||||
|
||||
const [user, account] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id })
|
||||
]);
|
||||
|
||||
if (!user || !account) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.disabled) {
|
||||
res.status(400).json({ error: "User is already banned" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne({ _id: id }, { $set: { disabled: true } }),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_BANNED } }
|
||||
),
|
||||
sessions().deleteMany({ user_id: id }),
|
||||
safetyStrikes().insertOne({
|
||||
_id: ulid(),
|
||||
user_id: id,
|
||||
reason,
|
||||
type: "ban"
|
||||
})
|
||||
]);
|
||||
|
||||
logAction("user_banned", id, { reason });
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.post(
|
||||
"/:id/unban",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
|
||||
const [user, account] = await Promise.all([
|
||||
users().findOne({ _id: id }),
|
||||
accounts().findOne({ _id: id })
|
||||
]);
|
||||
|
||||
if (!account || !user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.disabled) {
|
||||
res.status(400).json({ error: "User is not banned" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne({ _id: id }, { $set: { disabled: false } }),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) & ~USER_FLAG_BANNED } }
|
||||
)
|
||||
]);
|
||||
|
||||
logAction("user_unbanned", id);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
usersRouter.delete(
|
||||
"/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { id } = userIdParamsSchema.parse(req.params);
|
||||
const { reason } = deleteSchema.parse(req.body ?? {});
|
||||
const user = await users().findOne({ _id: id });
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
accounts().updateOne(
|
||||
{ _id: id },
|
||||
{
|
||||
$set: {
|
||||
deletion: {
|
||||
status: "Scheduled",
|
||||
after: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
users().updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_DELETED } }
|
||||
),
|
||||
sessions().deleteMany({ user_id: id })
|
||||
]);
|
||||
|
||||
logAction("user_deleted", id, reason ? { reason } : undefined);
|
||||
res.status(200).json({ success: true });
|
||||
})
|
||||
);
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { statements } from "./db/sqlite.js";
|
||||
|
||||
type SeedArgs = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
"reset-password"?: boolean;
|
||||
};
|
||||
|
||||
async function promptForMissing(
|
||||
args: SeedArgs
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const readline = createInterface({ input, output });
|
||||
|
||||
try {
|
||||
const username = args.username ?? (await readline.question("Username: "));
|
||||
const password = args.password ?? (await readline.question("Password: "));
|
||||
|
||||
return {
|
||||
username: username.trim(),
|
||||
password: password.trim()
|
||||
};
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
||||
|
||||
const parsed = parseArgs({
|
||||
args: normalizedArgs,
|
||||
options: {
|
||||
username: {
|
||||
type: "string"
|
||||
},
|
||||
password: {
|
||||
type: "string"
|
||||
},
|
||||
"reset-password": {
|
||||
type: "boolean",
|
||||
default: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const args = parsed.values as SeedArgs;
|
||||
const existingUser = statements.getFirstAdminUser.get();
|
||||
|
||||
if (existingUser && !args["reset-password"]) {
|
||||
console.error(
|
||||
"An admin user already exists. Use --reset-password to update it."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { username, password } = await promptForMissing({
|
||||
username: args.username ?? existingUser?.username,
|
||||
password: args.password,
|
||||
"reset-password": args["reset-password"]
|
||||
});
|
||||
|
||||
if (!username || !password) {
|
||||
console.error("Username and password are required.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, {
|
||||
type: argon2.argon2id
|
||||
});
|
||||
|
||||
if (args["reset-password"]) {
|
||||
if (!existingUser) {
|
||||
console.error(
|
||||
"No admin user exists yet. Run the seed script without --reset-password first."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetUsername = existingUser?.username ?? username;
|
||||
statements.updateAdminPasswordByUsername.run(passwordHash, targetUsername);
|
||||
console.log(`Password updated for ${targetUsername}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
statements.insertAdminUser.run(username, passwordHash);
|
||||
console.log(`Admin user ${username} created.`);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
declare module "better-sqlite3-session-store" {
|
||||
import type session from "express-session";
|
||||
|
||||
interface SqliteStoreOptions {
|
||||
client: unknown;
|
||||
expired?: {
|
||||
clear?: boolean;
|
||||
intervalMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SqliteStoreConstructor {
|
||||
new (options: SqliteStoreOptions): session.Store;
|
||||
}
|
||||
|
||||
export default function connectSqlite3(
|
||||
sessionModule: typeof session
|
||||
): SqliteStoreConstructor;
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
admin-proxy:
|
||||
environment:
|
||||
ADMIN_HOSTNAME: admin.example.com
|
||||
ADMIN_HTTP_PORT: 80
|
||||
ADMIN_HTTPS_PORT: 443
|
||||
ports:
|
||||
- "10.0.0.1:80:80"
|
||||
- "10.0.0.1:443:443"
|
||||
|
||||
admin-api:
|
||||
environment:
|
||||
ADMIN_API_PORT: 5181
|
||||
ADMIN_WEB_ORIGIN: https://admin.example.com
|
||||
|
||||
# Common customizations:
|
||||
# - Change the external network name in compose.yml if your Stoat stack uses a different name.
|
||||
# - Bind admin-proxy to the interface IP that should answer ports 80/443.
|
||||
# - Use ADMIN_HTTP_PORT/ADMIN_HTTPS_PORT=9080/9443 for local compose HTTPS without privileged ports.
|
||||
# - Ensure ADMIN_HOSTNAME resolves to that IP on your WireGuard/private network.
|
||||
# - Add resource limits or alternate image tags per deployment.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
volumes:
|
||||
admin_proxy_data:
|
||||
admin_proxy_config:
|
||||
|
||||
networks:
|
||||
stoat:
|
||||
external: true
|
||||
name: stoat_default
|
||||
admin:
|
||||
|
||||
services:
|
||||
admin-api:
|
||||
build:
|
||||
context: ./api
|
||||
image: ghcr.io/owner/stoat-admin-api:latest
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "${ADMIN_API_PORT:-5181}"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./.env:/app/.env:ro
|
||||
networks:
|
||||
- admin
|
||||
- stoat
|
||||
|
||||
admin-web:
|
||||
build:
|
||||
context: ./web
|
||||
image: ghcr.io/owner/stoat-admin-web:latest
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- admin
|
||||
|
||||
admin-proxy:
|
||||
build:
|
||||
context: ./proxy
|
||||
image: ghcr.io/owner/stoat-admin-proxy:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- admin-api
|
||||
- admin-web
|
||||
environment:
|
||||
ADMIN_API_PORT: ${ADMIN_API_PORT:-5181}
|
||||
ADMIN_HOSTNAME: ${ADMIN_HOSTNAME:-localhost}
|
||||
ADMIN_HTTP_PORT: ${ADMIN_HTTP_PORT:-9080}
|
||||
ADMIN_HTTPS_PORT: ${ADMIN_HTTPS_PORT:-9443}
|
||||
ports:
|
||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-9080}:${ADMIN_HTTP_PORT:-9080}"
|
||||
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTPS_PORT:-9443}:${ADMIN_HTTPS_PORT:-9443}"
|
||||
volumes:
|
||||
- admin_proxy_data:/data
|
||||
- admin_proxy_config:/config
|
||||
networks:
|
||||
- admin
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=s6 service supervision tree
|
||||
After=network-online.target podman.socket
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/s6-svscan /etc/s6-services
|
||||
ExecStop=/usr/bin/s6-svscanctl -t /etc/s6-services
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd /srv/stoat-admin
|
||||
podman compose down
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat-admin/
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if ! podman network exists stoat_default; then
|
||||
sleep 5
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /srv/stoat-admin
|
||||
exec podman compose up 2>&1
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd /srv/stoat
|
||||
podman compose down
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat/
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /srv/stoat
|
||||
exec podman compose up 2>&1
|
||||
|
||||
+41
-29
@@ -49,10 +49,10 @@ A lightweight, self-hosted admin dashboard for managing user invites, bans, and
|
||||
|
||||
### Services
|
||||
|
||||
| Service | Stack | Port | Access |
|
||||
|--------------|-------------------|-------|-------------------|
|
||||
| `admin-web` | Vite + React | 5180 | WireGuard only |
|
||||
| `admin-api` | Express + Node 22 | 5181 | WireGuard only |
|
||||
| Service | Stack | Port | Access |
|
||||
| ----------- | ----------------- | ---- | -------------- |
|
||||
| `admin-web` | Vite + React | 5180 | WireGuard only |
|
||||
| `admin-api` | Express + Node 22 | 5181 | WireGuard only |
|
||||
|
||||
Both services run in their own Podman Compose stack but join the Stoat stack's Podman network (`stoat_default`) as an external network, giving them direct access to MongoDB and Redis. No new databases — `admin-api` connects to Stoat's existing MongoDB instance.
|
||||
|
||||
@@ -102,13 +102,16 @@ Stoat's account records. Relevant fields for admin operations:
|
||||
|
||||
```typescript
|
||||
type Account = {
|
||||
_id: string; // ULID, matches user._id
|
||||
_id: string; // ULID, matches user._id
|
||||
email: string;
|
||||
email_normalised: string;
|
||||
disabled: boolean; // ← set true to ban at account level
|
||||
disabled: boolean; // ← set true to ban at account level
|
||||
spam: boolean;
|
||||
verification: { status: "Verified" | "Pending" | "Moving" };
|
||||
deletion?: { status: "Scheduled" | "WaitingForVerification" | "Deleted"; after?: string };
|
||||
deletion?: {
|
||||
status: "Scheduled" | "WaitingForVerification" | "Deleted";
|
||||
after?: string;
|
||||
};
|
||||
lockout?: { attempts: number; expiry: string };
|
||||
};
|
||||
```
|
||||
@@ -119,10 +122,10 @@ Stoat's user profiles. Relevant fields:
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
_id: string; // ULID
|
||||
_id: string; // ULID
|
||||
username: string;
|
||||
discriminator: string;
|
||||
flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned
|
||||
flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned
|
||||
// ... avatar, status, etc.
|
||||
};
|
||||
```
|
||||
@@ -144,7 +147,7 @@ Used when `invite_only = true` in `Revolt.toml`. Each document is an invite code
|
||||
|
||||
```typescript
|
||||
type Invite = {
|
||||
_id: string; // the invite code itself
|
||||
_id: string; // the invite code itself
|
||||
};
|
||||
```
|
||||
|
||||
@@ -154,7 +157,7 @@ Strike/suspension/ban audit records (from the official admin panel schema).
|
||||
|
||||
```typescript
|
||||
type Strike = {
|
||||
_id: string; // ULID
|
||||
_id: string; // ULID
|
||||
user_id: string;
|
||||
reason: string;
|
||||
type?: "strike" | "suspension" | "ban";
|
||||
@@ -217,19 +220,19 @@ All routes prefixed with `/api`. All require a valid session except `POST /api/a
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-------------------|-------------------------------------|
|
||||
| POST | `/api/auth/login` | Login with username + password |
|
||||
| POST | `/api/auth/logout`| Destroy session |
|
||||
| GET | `/api/auth/me` | Return current session user or 401 |
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------ | ---------------------------------- |
|
||||
| POST | `/api/auth/login` | Login with username + password |
|
||||
| POST | `/api/auth/logout` | Destroy session |
|
||||
| GET | `/api/auth/me` | Return current session user or 401 |
|
||||
|
||||
### Invites
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------------------------|--------------------------------------------------------------|
|
||||
| GET | `/api/invites` | List all invite records from SQLite (with status) |
|
||||
| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend |
|
||||
| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` |
|
||||
| Method | Path | Description |
|
||||
| ------ | -------------------- | --------------------------------------------------------------------------------- |
|
||||
| GET | `/api/invites` | List all invite records from SQLite (with status) |
|
||||
| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend |
|
||||
| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` |
|
||||
|
||||
#### Invite creation flow
|
||||
|
||||
@@ -258,13 +261,13 @@ For each SQLite record where status = 'pending':
|
||||
|
||||
### Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------------------------------|---------------------------------------------------|
|
||||
| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) |
|
||||
| GET | `/api/users/:id` | Get user + account details |
|
||||
| POST | `/api/users/:id/ban` | Ban user (see flow below) |
|
||||
| POST | `/api/users/:id/unban` | Reverse a ban |
|
||||
| DELETE | `/api/users/:id` | Delete user (see flow below) |
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------------------- | ------------------------------------------------ |
|
||||
| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) |
|
||||
| GET | `/api/users/:id` | Get user + account details |
|
||||
| POST | `/api/users/:id/ban` | Ban user (see flow below) |
|
||||
| POST | `/api/users/:id/unban` | Reverse a ban |
|
||||
| DELETE | `/api/users/:id` | Delete user (see flow below) |
|
||||
|
||||
#### Ban flow
|
||||
|
||||
@@ -330,7 +333,7 @@ Verify the Stoat network name with `podman network ls` while Stoat is running, a
|
||||
networks:
|
||||
stoat:
|
||||
external: true
|
||||
name: stoat_default # must match the actual Stoat stack network name
|
||||
name: stoat_default # must match the actual Stoat stack network name
|
||||
|
||||
services:
|
||||
admin-api:
|
||||
@@ -394,6 +397,7 @@ systemd
|
||||
### s6 Service Directories
|
||||
|
||||
**`/etc/s6-services/stoat/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
@@ -402,6 +406,7 @@ exec podman compose up 2>&1
|
||||
```
|
||||
|
||||
**`/etc/s6-services/stoat/finish`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /srv/stoat
|
||||
@@ -409,6 +414,7 @@ podman compose down
|
||||
```
|
||||
|
||||
**`/etc/s6-services/stoat-admin/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
@@ -427,6 +433,7 @@ exec podman compose up 2>&1
|
||||
The admin stack's `run` script checks for the Stoat network before starting. If the network doesn't exist yet (because the Stoat stack hasn't finished initializing), the script sleeps briefly and exits. s6 restarts it automatically, effectively retrying until the network appears. Combined with the MongoDB connection retry in the admin-api code, this handles all timing dependencies without explicit dependency declarations.
|
||||
|
||||
**`/etc/s6-services/stoat-admin/finish`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /srv/stoat-admin
|
||||
@@ -436,12 +443,14 @@ podman compose down
|
||||
**Log service (same pattern for both stacks):**
|
||||
|
||||
**`/etc/s6-services/stoat/log/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat/
|
||||
```
|
||||
|
||||
**`/etc/s6-services/stoat-admin/log/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat-admin/
|
||||
@@ -454,6 +463,7 @@ The `T` directive prefixes each log line with a TAI64N timestamp. Logs are writt
|
||||
A single systemd unit runs the s6 scan directory. This is the only systemd unit needed for the entire chat infrastructure.
|
||||
|
||||
**`/etc/systemd/system/s6-services.service`:**
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=s6 service supervision tree
|
||||
@@ -496,6 +506,7 @@ tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal
|
||||
### Dockerfiles
|
||||
|
||||
**admin-api:**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
@@ -508,6 +519,7 @@ CMD ["node", "dist/index.js"]
|
||||
```
|
||||
|
||||
**admin-web:**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-slim AS build-app
|
||||
WORKDIR /app
|
||||
|
||||
+37
-19
@@ -114,11 +114,11 @@ Create `api/src/db/mongo.ts`. Export a function `connectMongo()` that creates a
|
||||
|
||||
```typescript
|
||||
// Each function returns a typed Collection handle
|
||||
accounts() // revolt.accounts
|
||||
users() // revolt.users
|
||||
sessions() // revolt.sessions
|
||||
invites() // revolt.invites
|
||||
safetyStrikes() // revolt.safety_strikes
|
||||
accounts(); // revolt.accounts
|
||||
users(); // revolt.users
|
||||
sessions(); // revolt.sessions
|
||||
invites(); // revolt.invites
|
||||
safetyStrikes(); // revolt.safety_strikes
|
||||
```
|
||||
|
||||
Define TypeScript interfaces for each collection's document shape matching the types in the design doc. Place these in `api/src/db/types.ts`. Only include the fields the admin dashboard reads or writes — do not attempt to type the entire Revolt schema.
|
||||
@@ -205,6 +205,7 @@ Create `api/src/routes/invites.ts`. Implement an Express Router. All routes requ
|
||||
`POST /api/invites`: Accept `{ email, expiresInHours?: number }` in the request body. Validate with Zod (email must be a valid email format).
|
||||
|
||||
Implementation steps, in order:
|
||||
|
||||
1. Generate a 12-character alphanumeric code using `nanoid` with a custom alphabet (`0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`).
|
||||
2. Compute `expires_at` as an ISO 8601 string if `expiresInHours` was provided, otherwise null.
|
||||
3. Insert `{ _id: code }` into MongoDB `revolt.invites`.
|
||||
@@ -241,21 +242,21 @@ Create `api/src/routes/users.ts`. Implement an Express Router. All routes requir
|
||||
The join between `users` and `accounts` is by `_id` (they share the same ULID). Since MongoDB doesn't have native joins, perform this as a `$lookup` aggregation or two sequential queries. The aggregation approach is preferred:
|
||||
|
||||
```typescript
|
||||
db.collection('users').aggregate([
|
||||
db.collection("users").aggregate([
|
||||
{ $match: matchFilter },
|
||||
{ $skip: (page - 1) * limit },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'accounts',
|
||||
localField: '_id',
|
||||
foreignField: '_id',
|
||||
as: 'account',
|
||||
from: "accounts",
|
||||
localField: "_id",
|
||||
foreignField: "_id",
|
||||
as: "account",
|
||||
pipeline: [{ $project: { email: 1, disabled: 1, verification: 1 } }]
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$account', preserveNullAndEmptyArrays: true } }
|
||||
])
|
||||
{ $unwind: { path: "$account", preserveNullAndEmptyArrays: true } }
|
||||
]);
|
||||
```
|
||||
|
||||
`GET /api/users/:id`: Fetch a single user from `revolt.users` and their account from `revolt.accounts` by the same `_id`. Also fetch their strike history from `revolt.safety_strikes` where `user_id = id`. Return `200 { user, account, strikes }`. Return `404` if neither user nor account exists.
|
||||
@@ -263,6 +264,7 @@ db.collection('users').aggregate([
|
||||
`POST /api/users/:id/ban`: Accept `{ reason: string }` in the request body. Validate with Zod (reason must be a non-empty string).
|
||||
|
||||
Implementation steps, in order:
|
||||
|
||||
1. Fetch the user from `revolt.users` to confirm they exist. Return `404` if not found.
|
||||
2. Check if the account is already disabled (`revolt.accounts.disabled === true`). If so, return `400 { error: "User is already banned" }`.
|
||||
3. Update `revolt.accounts`: set `disabled = true` where `_id = id`.
|
||||
@@ -275,6 +277,7 @@ Implementation steps, in order:
|
||||
`POST /api/users/:id/unban`: No request body required.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Fetch the account from `revolt.accounts`. Return `404` if not found.
|
||||
2. Check that `disabled === true`. If not, return `400 { error: "User is not banned" }`.
|
||||
3. Update `revolt.accounts`: set `disabled = false` where `_id = id`.
|
||||
@@ -285,6 +288,7 @@ Implementation steps:
|
||||
`DELETE /api/users/:id`: Accept optional `{ reason?: string }` in the request body.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Fetch the user from `revolt.users`. Return `404` if not found.
|
||||
2. Update `revolt.accounts`: set `deletion = { status: "Scheduled", after: new Date().toISOString() }` where `_id = id`.
|
||||
3. Update `revolt.users`: set `flags` to `(currentFlags || 0) | 2` where `_id = id`.
|
||||
@@ -299,6 +303,7 @@ Do not attempt to delete user data (messages, DMs, memberships) directly. Stoat'
|
||||
Create `api/src/routes/dashboard.ts`. Implement an Express Router. Requires auth.
|
||||
|
||||
`GET /api/dashboard/stats`: Aggregate and return summary counts:
|
||||
|
||||
1. Total users: `revolt.users.countDocuments({})`.
|
||||
2. Banned users: `revolt.users.countDocuments({ flags: { $bitsAllSet: 4 } })`.
|
||||
3. Pending invites: SQLite query `SELECT COUNT(*) FROM invite_records WHERE status = 'pending'`.
|
||||
@@ -315,6 +320,7 @@ Return `200 { totalUsers, bannedUsers, pendingInvites, recentBans }`.
|
||||
Create `api/src/index.ts`. This is the main entry point.
|
||||
|
||||
Startup sequence:
|
||||
|
||||
1. Load environment variables (use a validation function with Zod to parse and validate all required env vars at startup — fail fast with a clear error message if any are missing).
|
||||
2. Connect to MongoDB via `connectMongo()`.
|
||||
3. Initialize SQLite (the import of `sqlite.ts` triggers table creation).
|
||||
@@ -341,19 +347,22 @@ Create `web/src/lib/api.ts`. Export a configured fetch wrapper:
|
||||
```typescript
|
||||
const API_BASE = import.meta.env.VITE_API_URL;
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'include', // send session cookie
|
||||
credentials: "include", // send session cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(res.status, body.error || 'Request failed');
|
||||
throw new ApiError(res.status, body.error || "Request failed");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
@@ -537,7 +546,7 @@ Create `compose.yml` at the repo root. This is the generic, open-source-friendly
|
||||
networks:
|
||||
stoat:
|
||||
external: true
|
||||
name: stoat_default # Adjust to match your Stoat stack's network name
|
||||
name: stoat_default # Adjust to match your Stoat stack's network name
|
||||
|
||||
services:
|
||||
admin-api:
|
||||
@@ -582,6 +591,7 @@ Create `compose.override.example.yml` with comments explaining common customizat
|
||||
Create example s6 service directory structures in a `deploy/s6/` directory at the repo root. These are reference files that users copy to their s6 scan directory (e.g., `/etc/s6-services/`). All `run` and `finish` scripts must be executable (`chmod +x`).
|
||||
|
||||
**`deploy/s6/stoat-admin/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
@@ -601,6 +611,7 @@ exec podman compose up 2>&1
|
||||
The network check handles the race condition where s6 starts both stacks simultaneously. If `stoat_default` doesn't exist yet, the script sleeps briefly and exits non-zero. s6 restarts it, and it tries again. Once the network exists, it falls through to `exec podman compose up` which replaces the bash process with the podman process — exactly what s6 expects as a long-lived supervised process. The `exec` is critical: without it, bash stays resident as a parent between s6 and podman, and signals from s6 would hit bash instead of podman.
|
||||
|
||||
**`deploy/s6/stoat-admin/finish`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /srv/stoat-admin
|
||||
@@ -610,6 +621,7 @@ podman compose down
|
||||
The `finish` script runs whenever `run` exits (whether normally or via `s6-svc -d`). It ensures containers are cleaned up rather than left orphaned. No `exec` needed here — this is a short-lived cleanup script, not a long-running process.
|
||||
|
||||
**`deploy/s6/stoat-admin/log/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat-admin/
|
||||
@@ -620,6 +632,7 @@ The `T` directive prefixes each line with a TAI64N timestamp. Logs for each stac
|
||||
Also create the equivalent Stoat stack service directory structure (`deploy/s6/stoat/`) with the same pattern, substituting the compose project path and removing the network check (the Stoat stack creates the network, it doesn't depend on it). Include both in the repo as reference examples, with a note that paths and network names must be adjusted for each deployment.
|
||||
|
||||
**`deploy/s6/stoat/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
@@ -628,6 +641,7 @@ exec podman compose up 2>&1
|
||||
```
|
||||
|
||||
**`deploy/s6/stoat/finish`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd /srv/stoat
|
||||
@@ -635,6 +649,7 @@ podman compose down
|
||||
```
|
||||
|
||||
**`deploy/s6/stoat/log/run`:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
exec s6-log -b -- T /var/log/s6/stoat/
|
||||
@@ -662,6 +677,7 @@ WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Document the required setup steps in the README:
|
||||
|
||||
1. Install s6 on Ubuntu 24.04: `apt install s6`.
|
||||
2. Create the scan directory: `mkdir -p /etc/s6-services`.
|
||||
3. Create log output directories: `mkdir -p /var/log/s6/stoat /var/log/s6/stoat-admin`.
|
||||
@@ -718,6 +734,7 @@ s6-svc -r /etc/s6-services/stoat-admin
|
||||
Create `.github/workflows/build.yml`. Trigger on push to `main` and on tags matching `v*`.
|
||||
|
||||
Jobs:
|
||||
|
||||
1. **build-api**: Check out the repo, set up Node 22, run `npm ci` and `npm run build` in `api/`, then build the Docker image and push to GHCR. Tag with both `latest` and the Git SHA (or Git tag if triggered by a tag push).
|
||||
2. **build-web**: Same pattern for `web/`. Pass `VITE_API_URL` as a build arg — for the CI-built image, use a placeholder value. Users will rebuild with their own URL or override at runtime.
|
||||
|
||||
@@ -728,6 +745,7 @@ Use `docker/login-action` for GHCR auth and `docker/build-push-action` for build
|
||||
Create `.github/workflows/ci.yml`. Trigger on pull requests and pushes to `main`.
|
||||
|
||||
Jobs:
|
||||
|
||||
1. **api-check**: Run `npm ci`, `npm run build` (TypeScript type checking), and `npx eslint .` in `api/`.
|
||||
2. **web-check**: Run `npm ci`, `npm run build`, and `npx eslint .` in `web/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const quote = (value) => JSON.stringify(value);
|
||||
|
||||
const inWorkspace = (workspace, file) => {
|
||||
const relativePath = path.relative(repoRoot, file);
|
||||
return (
|
||||
relativePath === workspace ||
|
||||
relativePath.startsWith(`${workspace}${path.sep}`)
|
||||
);
|
||||
};
|
||||
|
||||
const commandForWorkspace = (workspaceName, workspaceDir, files) => {
|
||||
const workspaceFiles = files.filter(
|
||||
(file) => inWorkspace(workspaceDir, file) && /\.(ts|tsx)$/.test(file)
|
||||
);
|
||||
|
||||
if (workspaceFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `pnpm --filter ${workspaceName} exec eslint --fix ${workspaceFiles
|
||||
.map(quote)
|
||||
.join(" ")}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
"**/*": (files) => {
|
||||
const commands = [];
|
||||
|
||||
if (files.length > 0) {
|
||||
commands.push(
|
||||
`prettier --write --ignore-unknown ${files.map(quote).join(" ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const apiCommand = commandForWorkspace("stoat-admin-api", "api", files);
|
||||
if (apiCommand) {
|
||||
commands.push(apiCommand);
|
||||
}
|
||||
|
||||
const webCommand = commandForWorkspace("stoat-admin-web", "web", files);
|
||||
if (webCommand) {
|
||||
commands.push(webCommand);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "stoat-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.6.3",
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "turbo run dev --parallel",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"check": "turbo run lint build",
|
||||
"dev:api": "turbo run dev --filter=stoat-admin-api",
|
||||
"dev:web": "turbo run dev --filter=stoat-admin-web",
|
||||
"seed": "pnpm --filter stoat-admin-api seed"
|
||||
}
|
||||
}
|
||||
Generated
+3977
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- api
|
||||
- web
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
http_port {$ADMIN_HTTP_PORT:9080}
|
||||
https_port {$ADMIN_HTTPS_PORT:9443}
|
||||
order coraza_waf first
|
||||
}
|
||||
|
||||
{$ADMIN_HOSTNAME:localhost} {
|
||||
tls internal
|
||||
|
||||
encode zstd gzip
|
||||
|
||||
coraza_waf {
|
||||
load_owasp_crs
|
||||
directives `
|
||||
Include /etc/caddy/coraza.conf
|
||||
SecRuleEngine On
|
||||
`
|
||||
}
|
||||
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
reverse_proxy admin-api:{$ADMIN_API_PORT:5181}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy admin-web:80 admin-web:443 admin-web:9080 admin-web:9443
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM caddy:2-builder-alpine AS build-caddy
|
||||
RUN xcaddy build \
|
||||
--with github.com/corazawaf/coraza-caddy/v2
|
||||
|
||||
FROM caddy:2-alpine
|
||||
COPY --from=build-caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY coraza.conf /etc/caddy/coraza.conf
|
||||
EXPOSE 80 443 9080 9443
|
||||
@@ -0,0 +1,4 @@
|
||||
# Coraza configuration overrides
|
||||
# Tune false positives here as they arise.
|
||||
|
||||
SecAction "id:900000, phase:1, pass, t:none, nolog, setvar:tx.blocking_paranoia_level=1"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "stream",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"seed": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.env*
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:24-slim AS build-app
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile=false
|
||||
COPY . .
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
EXPOSE 9080 9443
|
||||
@@ -0,0 +1,42 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"]
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
},
|
||||
globals: {
|
||||
...globals.browser
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stoat Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "stoat-admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.6.3",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"check": "pnpm lint && pnpm typecheck",
|
||||
"start": "vite preview",
|
||||
"preview": "pnpm start",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"ulid": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", end: true },
|
||||
{ to: "/invites", label: "Invites" },
|
||||
{ to: "/users", label: "Users" }
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { logout, user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-3rem)] max-w-7xl gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside className="rounded-[28px] border border-[color:var(--line)] bg-[color:var(--bg-panel-strong)] p-6 text-stone-100 shadow-[var(--shadow)]">
|
||||
<div className="mb-10 space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-stone-400">
|
||||
Stoat Admin
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Operations
|
||||
</h1>
|
||||
<p className="text-sm text-stone-300">
|
||||
Invite, moderate, and review account state on your instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-2xl px-4 py-3 text-sm transition ${
|
||||
isActive
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
: "bg-white/6 text-stone-200 hover:bg-white/10"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-10 rounded-2xl border border-white/10 bg-white/6 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-stone-400">
|
||||
Signed in
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-medium text-white">
|
||||
{user?.username}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void logout()}
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-white/6 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/12"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="rounded-[28px] border border-[color:var(--line)] bg-[color:var(--bg-panel)] p-5 shadow-[var(--shadow)] sm:p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #efe8db;
|
||||
--bg-panel: rgba(255, 250, 240, 0.88);
|
||||
--bg-panel-strong: rgba(31, 24, 17, 0.9);
|
||||
--ink: #241d17;
|
||||
--ink-muted: #675a4c;
|
||||
--line: rgba(59, 43, 24, 0.12);
|
||||
--accent: #b64926;
|
||||
--accent-soft: rgba(182, 73, 38, 0.12);
|
||||
--positive: #246d4f;
|
||||
--warning: #8d6112;
|
||||
--danger: #872f2f;
|
||||
--shadow: 0 24px 80px rgba(53, 35, 16, 0.12);
|
||||
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(182, 73, 38, 0.18),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at bottom right,
|
||||
rgba(36, 109, 79, 0.12),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(180deg, #f7f1e7 0%, var(--bg) 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(182, 73, 38, 0.18);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new ApiError(response.status, body.error ?? "Request failed");
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren
|
||||
} from "react";
|
||||
|
||||
import { apiFetch, ApiError } from "./api";
|
||||
import type { SessionUser } from "./types";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: SessionUser | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
void apiFetch<SessionUser>("/api/auth/me")
|
||||
.then((sessionUser) => {
|
||||
if (isMounted) {
|
||||
setUser(sessionUser);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(error instanceof ApiError) || error.status !== 401) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
async login(username: string, password: string) {
|
||||
const nextUser = await apiFetch<SessionUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
setUser(nextUser);
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", {
|
||||
method: "POST"
|
||||
});
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}),
|
||||
[isLoading, user]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const USER_FLAG_DELETED = 2;
|
||||
const USER_FLAG_BANNED = 4;
|
||||
|
||||
export function hasFlag(flags: number | undefined, mask: number): boolean {
|
||||
return ((flags ?? 0) & mask) === mask;
|
||||
}
|
||||
|
||||
export function getUserStatus(
|
||||
flags: number | undefined,
|
||||
disabled?: boolean
|
||||
): {
|
||||
label: "active" | "banned" | "deleted";
|
||||
tone: string;
|
||||
} {
|
||||
if (hasFlag(flags, USER_FLAG_DELETED)) {
|
||||
return { label: "deleted", tone: "text-red-800 bg-red-100 border-red-200" };
|
||||
}
|
||||
|
||||
if (disabled || hasFlag(flags, USER_FLAG_BANNED)) {
|
||||
return {
|
||||
label: "banned",
|
||||
tone: "text-amber-900 bg-amber-100 border-amber-200"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "active",
|
||||
tone: "text-emerald-900 bg-emerald-100 border-emerald-200"
|
||||
};
|
||||
}
|
||||
|
||||
export function getFlagLabels(flags: number | undefined): string[] {
|
||||
const labels: string[] = [];
|
||||
|
||||
if (hasFlag(flags, USER_FLAG_BANNED)) {
|
||||
labels.push("banned");
|
||||
}
|
||||
|
||||
if (hasFlag(flags, USER_FLAG_DELETED)) {
|
||||
labels.push("deleted");
|
||||
}
|
||||
|
||||
if (labels.length === 0) {
|
||||
labels.push("none");
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface SessionUser {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalUsers: number;
|
||||
bannedUsers: number;
|
||||
pendingInvites: number;
|
||||
recentBans: number;
|
||||
}
|
||||
|
||||
export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired";
|
||||
|
||||
export interface InviteRecord {
|
||||
id: number;
|
||||
code: string;
|
||||
email: string;
|
||||
status: InviteRecordStatus;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
accepted_at: string | null;
|
||||
resend_message_id: string | null;
|
||||
}
|
||||
|
||||
export interface InviteListResponse {
|
||||
invites: InviteRecord[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CreateInviteResponse {
|
||||
invite: InviteRecord;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface AccountRecord {
|
||||
_id: string;
|
||||
email: string;
|
||||
disabled: boolean;
|
||||
verification?: {
|
||||
status: "Verified" | "Pending" | "Moving";
|
||||
};
|
||||
deletion?: {
|
||||
status: "Scheduled" | "WaitingForVerification" | "Deleted";
|
||||
after?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
_id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
flags?: number;
|
||||
avatar?: unknown;
|
||||
account?: AccountRecord;
|
||||
}
|
||||
|
||||
export interface StrikeRecord {
|
||||
_id: string;
|
||||
user_id: string;
|
||||
reason: string;
|
||||
type?: "strike" | "suspension" | "ban";
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
users: UserRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UserDetailResponse {
|
||||
user: UserRecord | null;
|
||||
account: AccountRecord | null;
|
||||
strikes: StrikeRecord[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { AuthProvider } from "./lib/auth";
|
||||
import { router } from "./router";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
|
||||
import { Layout } from "./components/Layout";
|
||||
import { useAuth } from "./lib/auth";
|
||||
import { DashboardView } from "./views/Dashboard";
|
||||
import { InvitesView } from "./views/Invites";
|
||||
import { LoginView } from "./views/Login";
|
||||
import { UserDetailView } from "./views/UserDetail";
|
||||
import { UsersView } from "./views/Users";
|
||||
|
||||
function ProtectedLayout() {
|
||||
const { isLoading, user } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<div className="rounded-3xl border border-[color:var(--line)] bg-[color:var(--bg-panel)] px-6 py-5 shadow-[var(--shadow)]">
|
||||
Loading admin session...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Layout />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginView />
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <ProtectedLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <DashboardView />
|
||||
},
|
||||
{
|
||||
path: "invites",
|
||||
element: <InvitesView />
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
element: <UsersView />
|
||||
},
|
||||
{
|
||||
path: "users/:id",
|
||||
element: <UserDetailView />
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "../lib/api";
|
||||
import type { DashboardStats } from "../lib/types";
|
||||
|
||||
const statCards: Array<{
|
||||
key: keyof DashboardStats;
|
||||
label: string;
|
||||
note: string;
|
||||
}> = [
|
||||
{
|
||||
key: "totalUsers",
|
||||
label: "Total Users",
|
||||
note: "Accounts currently indexed in Stoat."
|
||||
},
|
||||
{
|
||||
key: "bannedUsers",
|
||||
label: "Banned Users",
|
||||
note: "Users with the banned flag or disabled account state."
|
||||
},
|
||||
{
|
||||
key: "pendingInvites",
|
||||
label: "Pending Invites",
|
||||
note: "Invites issued but not yet consumed."
|
||||
},
|
||||
{
|
||||
key: "recentBans",
|
||||
label: "Recent Bans",
|
||||
note: "Audit entries created in the last 30 days."
|
||||
}
|
||||
];
|
||||
|
||||
export function DashboardView() {
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ["dashboard-stats"],
|
||||
queryFn: () => apiFetch<DashboardStats>("/api/dashboard/stats")
|
||||
});
|
||||
const stats = statsQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-[28px] bg-[color:var(--bg-panel-strong)] px-6 py-8 text-white">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-stone-400">
|
||||
Overview
|
||||
</p>
|
||||
<h2 className="mt-3 text-4xl font-semibold tracking-tight">
|
||||
Instance control room
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm text-stone-300">
|
||||
Fast access to invite state, moderation activity, and account volume
|
||||
without depending on the public Stoat UI.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{statsQuery.isLoading ? (
|
||||
<div className="rounded-3xl border border-[color:var(--line)] bg-white/50 px-5 py-4">
|
||||
Loading dashboard stats...
|
||||
</div>
|
||||
) : statsQuery.isError ? (
|
||||
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
|
||||
Failed to load dashboard stats.
|
||||
</div>
|
||||
) : stats ? (
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map((card) => (
|
||||
<article
|
||||
key={card.key}
|
||||
className="rounded-3xl border border-[color:var(--line)] bg-white/70 p-5 shadow-sm backdrop-blur"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.26em] text-[color:var(--ink-muted)]">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-4 text-4xl font-semibold tracking-tight">
|
||||
{stats[card.key]}
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-[color:var(--ink-muted)]">
|
||||
{card.note}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiFetch, ApiError } from "../lib/api";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import type {
|
||||
CreateInviteResponse,
|
||||
InviteListResponse,
|
||||
InviteRecord
|
||||
} from "../lib/types";
|
||||
|
||||
const expiryOptions = [
|
||||
{ label: "No expiry", value: "" },
|
||||
{ label: "24 hours", value: "24" },
|
||||
{ label: "48 hours", value: "48" },
|
||||
{ label: "7 days", value: "168" },
|
||||
{ label: "30 days", value: "720" }
|
||||
];
|
||||
|
||||
const badgeStyles: Record<InviteRecord["status"], string> = {
|
||||
accepted: "border-emerald-200 bg-emerald-100 text-emerald-900",
|
||||
pending: "border-amber-200 bg-amber-100 text-amber-900",
|
||||
revoked: "border-red-200 bg-red-100 text-red-900",
|
||||
expired: "border-stone-200 bg-stone-100 text-stone-700"
|
||||
};
|
||||
|
||||
export function InvitesView() {
|
||||
const queryClient = useQueryClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [expiresInHours, setExpiresInHours] = useState("");
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const invitesQuery = useQuery({
|
||||
queryKey: ["invites"],
|
||||
queryFn: () => apiFetch<InviteListResponse>("/api/invites")
|
||||
});
|
||||
const inviteList = invitesQuery.data?.invites ?? [];
|
||||
|
||||
const createInviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<CreateInviteResponse>("/api/invites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
...(expiresInHours ? { expiresInHours: Number(expiresInHours) } : {})
|
||||
})
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setEmail("");
|
||||
setExpiresInHours("");
|
||||
setError(null);
|
||||
setFeedback(
|
||||
result.warning
|
||||
? `${result.warning}. Invite code: ${result.invite.code}`
|
||||
: `Invite created for ${result.invite.email}. Code: ${result.invite.code}`
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["invites"] });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setFeedback(null);
|
||||
setError(
|
||||
mutationError instanceof ApiError
|
||||
? mutationError.message
|
||||
: "Failed to create invite"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const revokeInviteMutation = useMutation({
|
||||
mutationFn: (code: string) =>
|
||||
apiFetch<{ success: true }>(`/api/invites/${code}`, {
|
||||
method: "DELETE"
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["invites"] });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
|
||||
Invites
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||
Issue and track access
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--ink-muted)]">
|
||||
Pending invites stay valid in Stoat even if email delivery fails.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 rounded-[28px] border border-[color:var(--line)] bg-white/70 p-6 lg:grid-cols-[minmax(0,1fr)_220px_180px]">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Recipient email</span>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium">Expiry</span>
|
||||
<select
|
||||
value={expiresInHours}
|
||||
onChange={(event) => setExpiresInHours(event.target.value)}
|
||||
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
>
|
||||
{expiryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createInviteMutation.mutate()}
|
||||
disabled={!email || createInviteMutation.isPending}
|
||||
className="self-end rounded-2xl bg-[color:var(--accent)] px-4 py-3 text-sm font-semibold text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{createInviteMutation.isPending ? "Sending..." : "Send Invite"}
|
||||
</button>
|
||||
|
||||
{feedback ? (
|
||||
<div className="lg:col-span-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{feedback}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="lg:col-span-3 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
|
||||
<div className="border-b border-[color:var(--line)] px-6 py-4">
|
||||
<h3 className="text-lg font-semibold">Invite history</h3>
|
||||
</div>
|
||||
|
||||
{invitesQuery.isLoading ? (
|
||||
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
|
||||
Loading invites...
|
||||
</div>
|
||||
) : invitesQuery.isError ? (
|
||||
<div className="px-6 py-6 text-sm text-red-700">
|
||||
Failed to load invites.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">Email</th>
|
||||
<th className="px-6 py-3 font-medium">Code</th>
|
||||
<th className="px-6 py-3 font-medium">Status</th>
|
||||
<th className="px-6 py-3 font-medium">Created</th>
|
||||
<th className="px-6 py-3 font-medium">Expires</th>
|
||||
<th className="px-6 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inviteList.map((invite) => (
|
||||
<tr
|
||||
key={invite.code}
|
||||
className="border-t border-[color:var(--line)]"
|
||||
>
|
||||
<td className="px-6 py-4">{invite.email}</td>
|
||||
<td className="px-6 py-4 font-mono text-xs">
|
||||
{invite.code}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${badgeStyles[invite.status]}`}
|
||||
>
|
||||
{invite.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{formatDateTime(invite.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{formatDateTime(invite.expires_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{invite.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
revokeInviteMutation.mutate(invite.code)
|
||||
}
|
||||
disabled={revokeInviteMutation.isPending}
|
||||
className="rounded-xl border border-red-200 px-3 py-2 text-xs font-semibold text-red-700 transition hover:bg-red-50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-[color:var(--ink-muted)]">
|
||||
No action
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export function LoginView() {
|
||||
const { login, user, isLoading } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (!isLoading && user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
async function handleSubmit(
|
||||
event: FormEvent<HTMLFormElement>
|
||||
): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (submitError) {
|
||||
if (submitError instanceof ApiError) {
|
||||
setError(submitError.message);
|
||||
} else {
|
||||
setError("Unable to sign in");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-md rounded-[32px] border border-[color:var(--line)] bg-[color:var(--bg-panel)] p-8 shadow-[var(--shadow)]">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
|
||||
Stoat Admin
|
||||
</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-3 text-sm text-[color:var(--ink-muted)]">
|
||||
This dashboard is intended for WireGuard-restricted admin access only.
|
||||
</p>
|
||||
|
||||
<form className="mt-8 space-y-4" onSubmit={handleSubmit}>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">Username</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="w-full rounded-2xl border border-[color:var(--line)] bg-white/80 px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-2xl border border-[color:var(--line)] bg-white/80 px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-2xl bg-[color:var(--accent)] px-4 py-3 text-sm font-semibold text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { decodeTime } from "ulid";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { getFlagLabels, getUserStatus } from "../lib/status";
|
||||
import type { UserDetailResponse } from "../lib/types";
|
||||
|
||||
function strikeDate(ulidValue: string): string {
|
||||
try {
|
||||
return formatDateTime(new Date(decodeTime(ulidValue)).toISOString());
|
||||
} catch {
|
||||
return ulidValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function UserDetailView() {
|
||||
const { id } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [deleteReason, setDeleteReason] = useState("");
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: ["user", id],
|
||||
enabled: Boolean(id),
|
||||
queryFn: () => apiFetch<UserDetailResponse>(`/api/users/${id}`)
|
||||
});
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["user", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] })
|
||||
]);
|
||||
};
|
||||
|
||||
const banMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ success: true }>(`/api/users/${id}/ban`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: banReason })
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setBanReason("");
|
||||
await refresh();
|
||||
}
|
||||
});
|
||||
|
||||
const unbanMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ success: true }>(`/api/users/${id}/unban`, {
|
||||
method: "POST"
|
||||
}),
|
||||
onSuccess: refresh
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ success: true }>(`/api/users/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ reason: deleteReason || undefined })
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setDeleteReason("");
|
||||
await refresh();
|
||||
}
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
|
||||
Missing user id.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userQuery.isLoading) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-[color:var(--line)] bg-white/60 px-5 py-4">
|
||||
Loading user…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = userQuery.data;
|
||||
|
||||
if (userQuery.isError || !detail?.user) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
|
||||
Failed to load user.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { user, account, strikes } = detail;
|
||||
const status = getUserStatus(user.flags, account?.disabled);
|
||||
const scheduledDeletion = account?.deletion?.status === "Scheduled";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] bg-[color:var(--bg-panel-strong)] px-6 py-7 text-white">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-stone-400">
|
||||
User Detail
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight">
|
||||
{user.username}#{user.discriminator}
|
||||
</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${status.tone}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
{getFlagLabels(user.flags).map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<article className="rounded-[28px] border border-[color:var(--line)] bg-white/72 p-6">
|
||||
<h3 className="text-lg font-semibold">Account info</h3>
|
||||
<dl className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
|
||||
Email
|
||||
</dt>
|
||||
<dd className="mt-2 text-base font-medium">
|
||||
{account?.email ?? "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
|
||||
Verification
|
||||
</dt>
|
||||
<dd className="mt-2 text-base font-medium">
|
||||
{account?.verification?.status ?? "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
|
||||
User ID
|
||||
</dt>
|
||||
<dd className="mt-2 font-mono text-sm">{user._id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
|
||||
Deletion state
|
||||
</dt>
|
||||
<dd className="mt-2 text-base font-medium">
|
||||
{account?.deletion?.status ?? "Not scheduled"}
|
||||
{account?.deletion?.after
|
||||
? ` · ${formatDateTime(account.deletion.after)}`
|
||||
: ""}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article className="rounded-[28px] border border-[color:var(--line)] bg-white/72 p-6">
|
||||
<h3 className="text-lg font-semibold">Actions</h3>
|
||||
|
||||
{scheduledDeletion ? (
|
||||
<p className="mt-4 text-sm text-[color:var(--ink-muted)]">
|
||||
Deletion is already scheduled. Stoat's `crond` daemon will
|
||||
handle the remaining cleanup.
|
||||
</p>
|
||||
) : status.label === "banned" ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<p className="text-sm text-[color:var(--ink-muted)]">
|
||||
The user is currently banned. You can clear the disabled state
|
||||
and banned flag.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unbanMutation.mutate()}
|
||||
disabled={unbanMutation.isPending}
|
||||
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-800"
|
||||
>
|
||||
{unbanMutation.isPending ? "Unbanning..." : "Unban user"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-6">
|
||||
<div className="space-y-3 rounded-3xl border border-amber-200 bg-amber-50 p-4">
|
||||
<h4 className="font-semibold text-amber-950">Ban user</h4>
|
||||
<textarea
|
||||
value={banReason}
|
||||
onChange={(event) => setBanReason(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-amber-200 bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
placeholder="Required reason"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => banMutation.mutate()}
|
||||
disabled={!banReason.trim() || banMutation.isPending}
|
||||
className="rounded-2xl bg-amber-600 px-4 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{banMutation.isPending ? "Applying ban..." : "Ban user"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-3xl border border-red-200 bg-red-50 p-4">
|
||||
<h4 className="font-semibold text-red-950">
|
||||
Schedule deletion
|
||||
</h4>
|
||||
<textarea
|
||||
value={deleteReason}
|
||||
onChange={(event) => setDeleteReason(event.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-red-200 bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
placeholder="Optional reason"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="rounded-2xl bg-red-700 px-4 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? "Scheduling..."
|
||||
: "Schedule deletion"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
|
||||
<div className="border-b border-[color:var(--line)] px-6 py-4">
|
||||
<h3 className="text-lg font-semibold">Strike history</h3>
|
||||
</div>
|
||||
{strikes.length === 0 ? (
|
||||
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
|
||||
No strike records found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">Reason</th>
|
||||
<th className="px-6 py-3 font-medium">Type</th>
|
||||
<th className="px-6 py-3 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{strikes.map(
|
||||
(strike: UserDetailResponse["strikes"][number]) => (
|
||||
<tr
|
||||
key={strike._id}
|
||||
className="border-t border-[color:var(--line)]"
|
||||
>
|
||||
<td className="px-6 py-4">{strike.reason}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="rounded-full border border-stone-200 bg-stone-100 px-2.5 py-1 text-xs font-medium text-stone-700">
|
||||
{strike.type ?? "strike"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">{strikeDate(strike._id)}</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getUserStatus } from "../lib/status";
|
||||
import type { UsersResponse } from "../lib/types";
|
||||
|
||||
function buildUsersPath(page: number, search: string): string {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: "25"
|
||||
});
|
||||
|
||||
if (search) {
|
||||
params.set("search", search);
|
||||
}
|
||||
|
||||
return `/api/users?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function UsersView() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search.trim());
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", page, deferredSearch],
|
||||
queryFn: () => apiFetch<UsersResponse>(buildUsersPath(page, deferredSearch))
|
||||
});
|
||||
const userData = usersQuery.data;
|
||||
|
||||
const totalPages = userData
|
||||
? Math.max(1, Math.ceil(userData.total / userData.limit))
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
|
||||
Users
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||
Moderation view
|
||||
</h2>
|
||||
</div>
|
||||
<label className="w-full max-w-md space-y-2">
|
||||
<span className="text-sm font-medium">Search by email</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
|
||||
placeholder="name@example.com"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
|
||||
{usersQuery.isLoading ? (
|
||||
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
|
||||
Loading users...
|
||||
</div>
|
||||
) : usersQuery.isError ? (
|
||||
<div className="px-6 py-6 text-sm text-red-700">
|
||||
Failed to load users.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">User</th>
|
||||
<th className="px-6 py-3 font-medium">Email</th>
|
||||
<th className="px-6 py-3 font-medium">Status</th>
|
||||
<th className="px-6 py-3 font-medium">Verified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userData?.users.map((user) => {
|
||||
const status = getUserStatus(
|
||||
user.flags,
|
||||
user.account?.disabled
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={user._id}
|
||||
className="cursor-pointer border-t border-[color:var(--line)] transition hover:bg-black/[0.03]"
|
||||
onClick={() => navigate(`/users/${user._id}`)}
|
||||
>
|
||||
<td className="px-6 py-4 font-medium">
|
||||
{user.username}#{user.discriminator}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.account?.email ?? "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${status.tone}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.account?.verification?.status === "Verified"
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 border-t border-[color:var(--line)] px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-[color:var(--ink-muted)]">
|
||||
Page {userData?.page ?? 1} of {totalPages} ·{" "}
|
||||
{userData?.total ?? 0} results
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPage((currentPage) => Math.max(1, currentPage - 1))
|
||||
}
|
||||
disabled={page <= 1}
|
||||
className="rounded-xl border border-[color:var(--line)] px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPage((currentPage) =>
|
||||
Math.min(totalPages, currentPage + 1)
|
||||
)
|
||||
}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-xl border border-[color:var(--line)] px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()]
|
||||
});
|
||||
Reference in New Issue
Block a user