From 7e5e38c921a18c72a822ed25b6dacced64ae568f Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:05:16 +0200 Subject: [PATCH] Admin panel (warp-admin) and the apps/ui design system apps/ui: Warp's design system for Vue 3 + Tailwind v4. Tokens, fonts, icons and logo come from the brand package, with a Tailwind theme on the tokens and Vue components on the brand component styles. apps/admin: the admin panel as a separate, optional service. warp-admin embeds the built Vue app and proxies only the login endpoints and /_warp/admin/v1 to the homeserver. Pages: dashboard, users, rooms, federation, registration and tokens, server notices, application services, audit log. Also: pnpm workspace, go.work entry, Dockerfile, an `admin` compose profile, Makefile targets, a CI job with a proxy smoke test, and docs. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 14 + .github/workflows/ci.yml | 82 + Makefile | 37 + README.md | 7 +- apps/README.md | 21 +- apps/admin/Dockerfile | 52 + apps/admin/README.md | 119 ++ apps/admin/cmd/warp-admin/main.go | 115 ++ apps/admin/go.mod | 3 + apps/admin/index.html | 15 + apps/admin/internal/host/host.go | 207 +++ apps/admin/internal/host/host_test.go | 236 +++ apps/admin/internal/panel/build/README.md | 4 + apps/admin/internal/panel/panel.go | 23 + apps/admin/package.json | 28 + apps/admin/public/favicon.svg | 1 + apps/admin/src/App.vue | 25 + apps/admin/src/api/admin.ts | 138 ++ apps/admin/src/api/client.ts | 90 ++ apps/admin/src/api/types.ts | 162 ++ apps/admin/src/components/ConfirmDialog.vue | 54 + apps/admin/src/components/LoadState.vue | 22 + apps/admin/src/components/Mxid.vue | 36 + apps/admin/src/components/PageHeader.vue | 34 + apps/admin/src/components/Pager.vue | 26 + apps/admin/src/components/StatTile.vue | 23 + apps/admin/src/components/ToastList.vue | 33 + apps/admin/src/env.d.ts | 10 + apps/admin/src/layouts/AppShell.vue | 113 ++ apps/admin/src/lib/format.ts | 60 + apps/admin/src/lib/useLoad.ts | 58 + apps/admin/src/main.ts | 7 + apps/admin/src/router.ts | 71 + apps/admin/src/stores/session.ts | 110 ++ apps/admin/src/stores/toasts.ts | 34 + apps/admin/src/style.css | 2 + apps/admin/src/views/AppServicesView.vue | 117 ++ apps/admin/src/views/AuditView.vue | 134 ++ apps/admin/src/views/DashboardView.vue | 81 + apps/admin/src/views/FederationView.vue | 274 ++++ apps/admin/src/views/LoginView.vue | 78 + apps/admin/src/views/NoticesView.vue | 72 + apps/admin/src/views/RegistrationView.vue | 269 ++++ apps/admin/src/views/RoomView.vue | 201 +++ apps/admin/src/views/RoomsView.vue | 124 ++ apps/admin/src/views/UserView.vue | 477 ++++++ apps/admin/src/views/UsersView.vue | 198 +++ apps/admin/tsconfig.json | 18 + apps/admin/vite.config.ts | 29 + apps/ui/README.md | 62 + apps/ui/package.json | 26 + apps/ui/src/components/WAvatar.vue | 37 + apps/ui/src/components/WBadge.vue | 24 + apps/ui/src/components/WBanner.vue | 34 + apps/ui/src/components/WButton.vue | 33 + apps/ui/src/components/WCheckbox.vue | 12 + apps/ui/src/components/WDialog.vue | 69 + apps/ui/src/components/WIcon.vue | 37 + apps/ui/src/components/WIconButton.vue | 28 + apps/ui/src/components/WLogo.vue | 36 + apps/ui/src/components/WSelect.vue | 24 + apps/ui/src/components/WSpinner.vue | 13 + apps/ui/src/components/WSwitch.vue | 21 + apps/ui/src/components/WTextArea.vue | 37 + apps/ui/src/components/WTextField.vue | 40 + apps/ui/src/env.d.ts | 1 + apps/ui/src/icons/attach.svg | 1 + apps/ui/src/icons/bell.svg | 1 + apps/ui/src/icons/check.svg | 1 + apps/ui/src/icons/chevron-left.svg | 1 + apps/ui/src/icons/chevron-right.svg | 1 + apps/ui/src/icons/close.svg | 1 + apps/ui/src/icons/copy.svg | 1 + apps/ui/src/icons/edit.svg | 1 + apps/ui/src/icons/globe.svg | 1 + apps/ui/src/icons/history.svg | 1 + apps/ui/src/icons/home.svg | 1 + apps/ui/src/icons/image.svg | 1 + apps/ui/src/icons/index.ts | 60 + apps/ui/src/icons/key.svg | 1 + apps/ui/src/icons/link.svg | 1 + apps/ui/src/icons/lock.svg | 1 + apps/ui/src/icons/logout.svg | 1 + apps/ui/src/icons/megaphone.svg | 1 + apps/ui/src/icons/menu.svg | 1 + apps/ui/src/icons/moon.svg | 1 + apps/ui/src/icons/more.svg | 1 + apps/ui/src/icons/plug.svg | 1 + apps/ui/src/icons/plus.svg | 1 + apps/ui/src/icons/react.svg | 1 + apps/ui/src/icons/read.svg | 1 + apps/ui/src/icons/refresh.svg | 1 + apps/ui/src/icons/reply.svg | 1 + apps/ui/src/icons/room.svg | 1 + apps/ui/src/icons/search.svg | 1 + apps/ui/src/icons/send.svg | 1 + apps/ui/src/icons/server.svg | 1 + apps/ui/src/icons/settings.svg | 1 + apps/ui/src/icons/shield-alert.svg | 1 + apps/ui/src/icons/shield-check.svg | 1 + apps/ui/src/icons/sun.svg | 1 + apps/ui/src/icons/trash.svg | 1 + apps/ui/src/icons/user.svg | 1 + apps/ui/src/icons/video.svg | 1 + apps/ui/src/index.ts | 17 + apps/ui/src/logos/favicon.svg | 1 + apps/ui/src/logos/index.ts | 12 + apps/ui/src/logos/warp-horizontal-black.svg | 1 + .../ui/src/logos/warp-horizontal-reversed.svg | 1 + apps/ui/src/logos/warp-horizontal-white.svg | 1 + apps/ui/src/logos/warp-horizontal.svg | 1 + apps/ui/src/logos/warp-stacked-reversed.svg | 1 + apps/ui/src/logos/warp-stacked.svg | 1 + apps/ui/src/logos/warp-symbol-app-icon.svg | 1 + apps/ui/src/logos/warp-symbol-black.svg | 1 + apps/ui/src/logos/warp-symbol-small.svg | 1 + apps/ui/src/logos/warp-symbol-white.svg | 1 + apps/ui/src/logos/warp-symbol.svg | 1 + apps/ui/src/logos/warp-wordmark.svg | 1 + apps/ui/src/styles/components.css | 105 ++ apps/ui/src/styles/extras.css | 69 + .../src/styles/fonts/Figtree-Variable.woff2 | Bin 0 -> 22828 bytes .../styles/fonts/JetBrainsMono-Variable.woff2 | Bin 0 -> 43104 bytes .../ui/src/styles/fonts/Outfit-Variable.woff2 | Bin 0 -> 38868 bytes apps/ui/src/styles/index.css | 49 + apps/ui/src/styles/theme.css | 68 + apps/ui/src/styles/tokens.css | 141 ++ apps/ui/src/styles/tokens.json | 115 ++ apps/ui/src/theme.ts | 45 + apps/ui/tsconfig.json | 17 + deploy/compose/docker-compose.yml | 21 +- docs/admin-api.md | 3 +- docs/deployment.md | 22 +- go.work | 7 +- package.json | 13 + pnpm-lock.yaml | 1412 +++++++++++++++++ pnpm-workspace.yaml | 6 + 137 files changed, 6895 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/admin/Dockerfile create mode 100644 apps/admin/README.md create mode 100644 apps/admin/cmd/warp-admin/main.go create mode 100644 apps/admin/go.mod create mode 100644 apps/admin/index.html create mode 100644 apps/admin/internal/host/host.go create mode 100644 apps/admin/internal/host/host_test.go create mode 100644 apps/admin/internal/panel/build/README.md create mode 100644 apps/admin/internal/panel/panel.go create mode 100644 apps/admin/package.json create mode 100644 apps/admin/public/favicon.svg create mode 100644 apps/admin/src/App.vue create mode 100644 apps/admin/src/api/admin.ts create mode 100644 apps/admin/src/api/client.ts create mode 100644 apps/admin/src/api/types.ts create mode 100644 apps/admin/src/components/ConfirmDialog.vue create mode 100644 apps/admin/src/components/LoadState.vue create mode 100644 apps/admin/src/components/Mxid.vue create mode 100644 apps/admin/src/components/PageHeader.vue create mode 100644 apps/admin/src/components/Pager.vue create mode 100644 apps/admin/src/components/StatTile.vue create mode 100644 apps/admin/src/components/ToastList.vue create mode 100644 apps/admin/src/env.d.ts create mode 100644 apps/admin/src/layouts/AppShell.vue create mode 100644 apps/admin/src/lib/format.ts create mode 100644 apps/admin/src/lib/useLoad.ts create mode 100644 apps/admin/src/main.ts create mode 100644 apps/admin/src/router.ts create mode 100644 apps/admin/src/stores/session.ts create mode 100644 apps/admin/src/stores/toasts.ts create mode 100644 apps/admin/src/style.css create mode 100644 apps/admin/src/views/AppServicesView.vue create mode 100644 apps/admin/src/views/AuditView.vue create mode 100644 apps/admin/src/views/DashboardView.vue create mode 100644 apps/admin/src/views/FederationView.vue create mode 100644 apps/admin/src/views/LoginView.vue create mode 100644 apps/admin/src/views/NoticesView.vue create mode 100644 apps/admin/src/views/RegistrationView.vue create mode 100644 apps/admin/src/views/RoomView.vue create mode 100644 apps/admin/src/views/RoomsView.vue create mode 100644 apps/admin/src/views/UserView.vue create mode 100644 apps/admin/src/views/UsersView.vue create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/ui/README.md create mode 100644 apps/ui/package.json create mode 100644 apps/ui/src/components/WAvatar.vue create mode 100644 apps/ui/src/components/WBadge.vue create mode 100644 apps/ui/src/components/WBanner.vue create mode 100644 apps/ui/src/components/WButton.vue create mode 100644 apps/ui/src/components/WCheckbox.vue create mode 100644 apps/ui/src/components/WDialog.vue create mode 100644 apps/ui/src/components/WIcon.vue create mode 100644 apps/ui/src/components/WIconButton.vue create mode 100644 apps/ui/src/components/WLogo.vue create mode 100644 apps/ui/src/components/WSelect.vue create mode 100644 apps/ui/src/components/WSpinner.vue create mode 100644 apps/ui/src/components/WSwitch.vue create mode 100644 apps/ui/src/components/WTextArea.vue create mode 100644 apps/ui/src/components/WTextField.vue create mode 100644 apps/ui/src/env.d.ts create mode 100644 apps/ui/src/icons/attach.svg create mode 100644 apps/ui/src/icons/bell.svg create mode 100644 apps/ui/src/icons/check.svg create mode 100644 apps/ui/src/icons/chevron-left.svg create mode 100644 apps/ui/src/icons/chevron-right.svg create mode 100644 apps/ui/src/icons/close.svg create mode 100644 apps/ui/src/icons/copy.svg create mode 100644 apps/ui/src/icons/edit.svg create mode 100644 apps/ui/src/icons/globe.svg create mode 100644 apps/ui/src/icons/history.svg create mode 100644 apps/ui/src/icons/home.svg create mode 100644 apps/ui/src/icons/image.svg create mode 100644 apps/ui/src/icons/index.ts create mode 100644 apps/ui/src/icons/key.svg create mode 100644 apps/ui/src/icons/link.svg create mode 100644 apps/ui/src/icons/lock.svg create mode 100644 apps/ui/src/icons/logout.svg create mode 100644 apps/ui/src/icons/megaphone.svg create mode 100644 apps/ui/src/icons/menu.svg create mode 100644 apps/ui/src/icons/moon.svg create mode 100644 apps/ui/src/icons/more.svg create mode 100644 apps/ui/src/icons/plug.svg create mode 100644 apps/ui/src/icons/plus.svg create mode 100644 apps/ui/src/icons/react.svg create mode 100644 apps/ui/src/icons/read.svg create mode 100644 apps/ui/src/icons/refresh.svg create mode 100644 apps/ui/src/icons/reply.svg create mode 100644 apps/ui/src/icons/room.svg create mode 100644 apps/ui/src/icons/search.svg create mode 100644 apps/ui/src/icons/send.svg create mode 100644 apps/ui/src/icons/server.svg create mode 100644 apps/ui/src/icons/settings.svg create mode 100644 apps/ui/src/icons/shield-alert.svg create mode 100644 apps/ui/src/icons/shield-check.svg create mode 100644 apps/ui/src/icons/sun.svg create mode 100644 apps/ui/src/icons/trash.svg create mode 100644 apps/ui/src/icons/user.svg create mode 100644 apps/ui/src/icons/video.svg create mode 100644 apps/ui/src/index.ts create mode 100644 apps/ui/src/logos/favicon.svg create mode 100644 apps/ui/src/logos/index.ts create mode 100644 apps/ui/src/logos/warp-horizontal-black.svg create mode 100644 apps/ui/src/logos/warp-horizontal-reversed.svg create mode 100644 apps/ui/src/logos/warp-horizontal-white.svg create mode 100644 apps/ui/src/logos/warp-horizontal.svg create mode 100644 apps/ui/src/logos/warp-stacked-reversed.svg create mode 100644 apps/ui/src/logos/warp-stacked.svg create mode 100644 apps/ui/src/logos/warp-symbol-app-icon.svg create mode 100644 apps/ui/src/logos/warp-symbol-black.svg create mode 100644 apps/ui/src/logos/warp-symbol-small.svg create mode 100644 apps/ui/src/logos/warp-symbol-white.svg create mode 100644 apps/ui/src/logos/warp-symbol.svg create mode 100644 apps/ui/src/logos/warp-wordmark.svg create mode 100644 apps/ui/src/styles/components.css create mode 100644 apps/ui/src/styles/extras.css create mode 100644 apps/ui/src/styles/fonts/Figtree-Variable.woff2 create mode 100644 apps/ui/src/styles/fonts/JetBrainsMono-Variable.woff2 create mode 100644 apps/ui/src/styles/fonts/Outfit-Variable.woff2 create mode 100644 apps/ui/src/styles/index.css create mode 100644 apps/ui/src/styles/theme.css create mode 100644 apps/ui/src/styles/tokens.css create mode 100644 apps/ui/src/styles/tokens.json create mode 100644 apps/ui/src/theme.ts create mode 100644 apps/ui/tsconfig.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe092c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +**/node_modules +**/dist +!apps/admin/internal/panel/build +bin +coverage.* +.complement +DESIGN +PLAN +Docker +Docker-AIO +*.db +*.db-shm +*.db-wal diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e35737..00fbd2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,3 +207,85 @@ jobs: # Graceful shutdown: SIGTERM must stop the process cleanly. kill -TERM "$server_pid" wait "$server_pid" + + admin: + name: Admin panel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: server/go.sum + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Typecheck and build the frontend + run: | + pnpm -r typecheck + pnpm --filter @warp/admin build + + - name: Verify formatting + working-directory: apps/admin + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Vet and test warp-admin + working-directory: apps/admin + run: | + go vet ./... + go test -count=1 ./... + + - name: Smoke test warp-admin against the homeserver + run: | + set -euo pipefail + make build + (cd apps/admin && go build -o ../../bin/warp-admin ./cmd/warp-admin) + sed 's/^ server_name: .*/ server_name: ci.example/' warp.yaml.example > warp.yaml + ./bin/warp generate-keys + ./bin/warp migrate + ./bin/warp create-account --username ci-admin --password hunter2hunter2 --admin + ./bin/warp serve & + ./bin/warp-admin -homeserver http://127.0.0.1:8008 -listen 127.0.0.1:8009 & + trap 'kill $(jobs -p) 2>/dev/null || true' EXIT + + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8008/healthz >/dev/null 2>&1 && + curl -fsS http://127.0.0.1:8009/healthz >/dev/null 2>&1; then + break + fi + sleep 1 + done + + # The panel is embedded, and its routes fall back to index.html. + curl -fsS http://127.0.0.1:8009/ | grep -q '
' + curl -fsS http://127.0.0.1:8009/users | grep -q '
' + + # Login and the admin API go through the proxy... + token="$(curl -fsS http://127.0.0.1:8009/_matrix/client/v3/login -d '{ + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": "ci-admin"}, + "password": "hunter2hunter2" + }' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" + test -n "$token" + curl -fsS http://127.0.0.1:8009/_warp/admin/v1/stats \ + -H "Authorization: Bearer $token" | grep -q '"admins":1' + + # ...and the rest of the homeserver does not. + status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8009/_matrix/client/v3/sync \ + -H "Authorization: Bearer $token")" + test "$status" = "404" diff --git a/Makefile b/Makefile index 15bb822..c47fac9 100644 --- a/Makefile +++ b/Makefile @@ -91,6 +91,43 @@ create-account: build ## Create an account: make create-account ACCOUNT=alice AR @test -n "$(ACCOUNT)" || { echo "set ACCOUNT, e.g. make create-account ACCOUNT=alice ARGS=--admin"; exit 1; } ./$(BIN_DIR)/$(BINARY)$(EXE) create-account --username "$(ACCOUNT)" $(ARGS) +# --- Admin panel (apps/admin) ------------------------------------------- +# The panel is optional and runs as its own process, warp-admin, which +# serves the built frontend and proxies to the homeserver. + +PNPM ?= pnpm +ADMIN_DIR := apps/admin +ADMIN_IMAGE ?= warp-admin + +.PHONY: admin-deps +admin-deps: ## Install the frontend dependencies (pnpm) + $(PNPM) install --frozen-lockfile + +.PHONY: admin-web +admin-web: ## Build the admin panel frontend into apps/admin/internal/panel/build/dist + $(PNPM) --filter @warp/admin build + +.PHONY: admin +admin: admin-web ## Build bin/warp-admin with the panel embedded + cd $(ADMIN_DIR) && $(GO) build -ldflags "-X main.version=$(VERSION)" -o ../../$(BIN_DIR)/warp-admin$(EXE) ./cmd/warp-admin + +.PHONY: admin-run +admin-run: admin ## Run warp-admin against http://localhost:8008 + ./$(BIN_DIR)/warp-admin$(EXE) -homeserver http://localhost:8008 + +.PHONY: admin-dev +admin-dev: ## Run the panel with hot reload on :5174, proxied to localhost:8008 + $(PNPM) --filter @warp/admin dev + +.PHONY: admin-test +admin-test: ## Typecheck the frontend and test warp-admin + $(PNPM) -r typecheck + cd $(ADMIN_DIR) && $(GO) vet ./... && $(GO) test ./... + +.PHONY: admin-docker +admin-docker: ## Build the warp-admin container image + docker build -t $(ADMIN_IMAGE):$(VERSION) -f $(ADMIN_DIR)/Dockerfile . + .PHONY: complement-image complement-image: ## Build the Complement homeserver image docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile . diff --git a/README.md b/README.md index 3c3eca0..74c89de 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,11 @@ application services and their delivery, the room list and detail, purging a room, removing a user from one, the audit log and the dashboard counters. See [docs/admin-api.md](docs/admin-api.md). +The admin panel is a web UI for all of it. It is optional and runs as its +own process, `warp-admin`, next to the homeserver: +`docker compose --profile admin up -d`, or see +[apps/admin](apps/admin/README.md). + Also in place: argon2id passwords, hashed access and refresh tokens, per-endpoint rate limiting, YAML + environment configuration, structured logging, graceful shutdown, the storage interfaces with SQLite, PostgreSQL @@ -364,7 +369,7 @@ server/ the homeserver (Go module) filestore/ media content on the local filesystem sqlite/ postgres/ pocketbase/ test/complement/ the Complement homeserver image and runner -apps/ clients: ui, admin, web, desktop, mobile (Phase 1+) +apps/ clients: ui (design system), admin (warp-admin panel); web, desktop, mobile to come deploy/ compose/ (with Prometheus, Grafana, Jaeger), helm/warp, grafana/ dashboard docs/ ``` diff --git a/apps/README.md b/apps/README.md index 4bc3354..692d8fd 100644 --- a/apps/README.md +++ b/apps/README.md @@ -1,14 +1,17 @@ # Clients -Nothing here yet. The client sub-projects start once the server can do -something worth talking to: - -| Directory | What | Starts after | +| Directory | What | State | |---|---|---| -| `ui/` | shared Vue 3 + Tailwind component library and Matrix store | server Phase 1 | -| `admin/` | admin web panel, embedded into the `warp` binary | server Phase 1 | -| `web/` | web client (Vue SPA) + Go host | server M1 (Phase 4) | +| [`ui/`](ui/README.md) | shared Vue 3 + Tailwind design system: tokens, fonts, icons, components | in use by admin; the Matrix store comes with web | +| [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API | +| `web/` | web client (Vue SPA) + Go host | next | | `desktop/` | Wails v3 shell reusing the web frontend | once web has a timeline | -| `mobile/` | Flutter app, Android first | server Phase 8 (push) | +| `mobile/` | Flutter app, Android first | after web | -See PLAN.md section 8 for the stack decisions behind each one. +The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the +repository root); the Go hosts join `go.work`. + +```bash +pnpm install +pnpm admin:dev +``` diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile new file mode 100644 index 0000000..4d19f2a --- /dev/null +++ b/apps/admin/Dockerfile @@ -0,0 +1,52 @@ +# warp-admin — the optional admin panel, as its own container. +# +# Build from the repository root (the panel uses the shared apps/ui package): +# +# docker build -t warp-admin -f apps/admin/Dockerfile . +# docker run -p 8009:8009 -e WARP_ADMIN_HOMESERVER=http://warp:8008 warp-admin + +FROM node:22-alpine AS web + +WORKDIR /src +RUN corepack enable + +# Lockfile and manifests first, so the package store survives source changes. +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/ui/package.json ./apps/ui/ +COPY apps/admin/package.json ./apps/admin/ +RUN pnpm install --frozen-lockfile --filter @warp/admin... + +COPY apps/ui ./apps/ui +COPY apps/admin ./apps/admin +RUN pnpm --filter @warp/admin build + + +FROM golang:1.26-alpine AS build + +WORKDIR /src/apps/admin +COPY apps/admin/go.mod ./ +COPY apps/admin/cmd ./cmd +COPY apps/admin/internal ./internal +COPY --from=web /src/apps/admin/internal/panel/build/dist ./internal/panel/build/dist + +ARG VERSION=0.0.0-dev +ENV CGO_ENABLED=0 GOWORK=off +RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/warp-admin ./cmd/warp-admin + + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates curl && adduser -D -u 10002 warp-admin +COPY --from=build /out/warp-admin /usr/local/bin/warp-admin +USER warp-admin + +ENV WARP_ADMIN_LISTEN=":8009" \ + WARP_ADMIN_HOMESERVER="http://warp:8008" \ + WARP_ADMIN_LOG_FORMAT="json" + +EXPOSE 8009 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8009/healthz || exit 1 + +ENTRYPOINT ["warp-admin"] diff --git a/apps/admin/README.md b/apps/admin/README.md new file mode 100644 index 0000000..e48300b --- /dev/null +++ b/apps/admin/README.md @@ -0,0 +1,119 @@ +# Warp admin panel + +The operator's web UI for a Warp homeserver: dashboard, users, rooms, +federation, registration and tokens, server notices, application services +and the audit log. It is the frontend of the admin API +([docs/admin-api.md](../../docs/admin-api.md)). + +The panel is **optional and separate from the homeserver**. It ships as its +own small binary, `warp-admin`, with the built Vue app embedded. A Warp +server runs the same whether or not you install it. + +``` +browser ──► warp-admin :8009 ──► warp :8008 + serves the panel /_matrix/client/v3/login, logout, account/whoami + proxies 5 paths /_matrix/client/versions + /_warp/admin/v1/* +``` + +warp-admin proxies only what the panel uses and answers `404` for anything +else, so it is not a second way into the homeserver. Because the browser +only talks to warp-admin, the panel is same-origin (no CORS), the homeserver +URL is runtime configuration rather than baked into the build, and +`/_warp/admin` can stay off your public listener: only warp-admin has to +reach it. + +## Signing in + +With a normal account that has the admin flag. Make the first one on the +server: + +```bash +warp create-account --username root --admin +``` + +The panel logs in like any Matrix client and uses the access token for the +admin API. The token is kept in `sessionStorage` (gone with the tab) unless +you tick "Stay signed in on this browser". Signing out logs the session out +on the homeserver. + +## Run it + +### With Docker Compose + +```bash +cd deploy/compose +docker compose --profile admin up -d +``` + +The panel is on . Leave out `--profile admin` and it +isn't installed. + +### As a container + +```bash +docker build -t warp-admin -f apps/admin/Dockerfile . +docker run -p 8009:8009 -e WARP_ADMIN_HOMESERVER=http://warp:8008 warp-admin +``` + +### As a binary + +```bash +make admin +./bin/warp-admin -homeserver http://localhost:8008 +``` + +| Flag | Environment | Default | +|---|---|---| +| `-listen` | `WARP_ADMIN_LISTEN` | `:8009` | +| `-homeserver` | `WARP_ADMIN_HOMESERVER` | `http://localhost:8008` | +| `-log-format` | `WARP_ADMIN_LOG_FORMAT` | `text` (or `json`) | + +`/healthz` answers `ok` for container health checks. + +## Deploying + +- Put warp-admin behind your TLS proxy, ideally on a hostname or network + only admins can reach. It sends a strict Content-Security-Policy, + `X-Frame-Options: DENY` and `Referrer-Policy: no-referrer`. +- warp-admin adds `X-Forwarded-For`. The homeserver uses it for rate limits + and a session's last-seen IP only with `client_api.trust_x_forwarded_for` + on; without it, admin logins count against warp-admin's own address. + Turn it on only when every client reaches the homeserver through proxies + you control. + +## Develop + +```bash +pnpm install +pnpm admin:dev # http://localhost:5174, proxied to localhost:8008 +``` + +`WARP_ADMIN_HOMESERVER` points the dev server at another homeserver. Vite +proxies the same paths warp-admin does, so the app code is the same in +both. + +| Command | What | +|---|---| +| `make admin-web` | build the frontend into `internal/panel/build/dist` | +| `make admin` | build the frontend, then `bin/warp-admin` with it embedded | +| `make admin-test` | typecheck the frontend, vet and test the Go host | +| `make admin-docker` | build the container image | + +A `warp-admin` compiled before the frontend is built still runs. It serves a +page saying the panel is missing. + +## Layout + +| Path | What | +|---|---| +| `src/api/` | typed client for the admin API and the login endpoints | +| `src/stores/` | the admin's session, toasts (Pinia) | +| `src/views/` | one view per page | +| `src/components/` | panel-specific pieces: page header, pager, confirm dialog | +| `cmd/warp-admin/` | the binary | +| `internal/host/` | static serving and the allow-listed proxy | +| `internal/panel/` | the embedded build | + +The design system (tokens, fonts, icons, base components) comes from +[`@warp/ui`](../ui/README.md). diff --git a/apps/admin/cmd/warp-admin/main.go b/apps/admin/cmd/warp-admin/main.go new file mode 100644 index 0000000..c5d0946 --- /dev/null +++ b/apps/admin/cmd/warp-admin/main.go @@ -0,0 +1,115 @@ +// Command warp-admin serves the Warp admin panel and proxies its requests +// to a homeserver. It is optional: a Warp server runs without it, and an +// operator who wants the panel runs this next to it. +// +// warp-admin -homeserver http://localhost:8008 -listen :8009 +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/dariuszjaniak/warp/apps/admin/internal/host" + "github.com/dariuszjaniak/warp/apps/admin/internal/panel" +) + +// Set with -ldflags "-X main.version=...". +var version = "0.0.0-dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "warp-admin:", err) + os.Exit(1) + } +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func run() error { + listen := flag.String("listen", env("WARP_ADMIN_LISTEN", ":8009"), "address to listen on (WARP_ADMIN_LISTEN)") + homeserver := flag.String("homeserver", env("WARP_ADMIN_HOMESERVER", "http://localhost:8008"), + "base URL of the Warp homeserver (WARP_ADMIN_HOMESERVER)") + logFormat := flag.String("log-format", env("WARP_ADMIN_LOG_FORMAT", "text"), "text or json (WARP_ADMIN_LOG_FORMAT)") + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + + if *showVersion { + fmt.Println("warp-admin", version) + return nil + } + + var logger *slog.Logger + switch *logFormat { + case "json": + logger = slog.New(slog.NewJSONHandler(os.Stderr, nil)) + case "text": + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + default: + return fmt.Errorf("unknown log format %q", *logFormat) + } + slog.SetDefault(logger) + + target, err := url.Parse(strings.TrimRight(*homeserver, "/")) + if err != nil { + return fmt.Errorf("homeserver: %w", err) + } + + files, built := panel.FS() + if !built { + logger.Warn("the panel was not built into this binary; serving a placeholder page") + files = nil + } + + h, err := host.New(host.Options{Homeserver: target, Panel: files, Logger: logger}) + if err != nil { + return err + } + + srv := &http.Server{ + Addr: *listen, + Handler: h, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: time.Minute, + WriteTimeout: 3 * time.Minute, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 64 << 10, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errc := make(chan error, 1) + go func() { + logger.Info("warp-admin listening", "addr", *listen, "homeserver", target.String(), "version", version) + errc <- srv.ListenAndServe() + }() + + select { + case err := <-errc: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-ctx.Done(): + } + + logger.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) +} diff --git a/apps/admin/go.mod b/apps/admin/go.mod new file mode 100644 index 0000000..487bd25 --- /dev/null +++ b/apps/admin/go.mod @@ -0,0 +1,3 @@ +module github.com/dariuszjaniak/warp/apps/admin + +go 1.26.4 diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..a726e4a --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Warp admin + + +
+ + + diff --git a/apps/admin/internal/host/host.go b/apps/admin/internal/host/host.go new file mode 100644 index 0000000..de01e36 --- /dev/null +++ b/apps/admin/internal/host/host.go @@ -0,0 +1,207 @@ +// Package host is the warp-admin HTTP server: it serves the built panel and +// proxies the few homeserver paths the panel uses. +// +// The panel runs apart from the homeserver so an operator chooses whether +// to have one at all, and where. Proxying, rather than having the browser +// call the homeserver, keeps the panel same-origin (no CORS, no homeserver +// URL baked into the build) and lets a deployment keep /_warp/admin off the +// public listener: only warp-admin needs to reach it. +package host + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" +) + +// Options configures the handler. +type Options struct { + // Homeserver is where the proxied requests go, e.g. http://warp:8008. + Homeserver *url.URL + // Panel is the built panel rooted at its index.html; nil serves a page + // saying it has not been built. + Panel fs.FS + // Logger receives proxy errors. Defaults to slog.Default(). + Logger *slog.Logger + // Transport overrides the proxy's round tripper, for tests. + Transport http.RoundTripper +} + +// proxiedExact are the Client-Server endpoints the panel calls for its own +// session. Nothing else of the C-S API is reachable through warp-admin, so +// it can't be used as a general client front door. +var proxiedExact = map[string]bool{ + "/_matrix/client/versions": true, + "/_matrix/client/v3/login": true, + "/_matrix/client/v3/logout": true, + "/_matrix/client/v3/account/whoami": true, +} + +const adminPrefix = "/_warp/admin/v1/" + +// contentSecurityPolicy allows the panel's own scripts, styles and fonts and +// nothing else. Inline styles are allowed because Vue binds style attributes. +const contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; " + + "base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + +// New returns the warp-admin handler. +func New(opts Options) (http.Handler, error) { + if opts.Homeserver == nil || opts.Homeserver.Scheme == "" || opts.Homeserver.Host == "" { + return nil, errors.New("host: homeserver must be an absolute URL") + } + if opts.Logger == nil { + opts.Logger = slog.Default() + } + + h := &handler{opts: opts} + + transport := opts.Transport + if transport == nil { + transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 32, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 2 * time.Minute, + } + } + upstream := opts.Homeserver + h.proxy = &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(upstream) + pr.SetXForwarded() + // The panel has no cookies of its own to send; don't pass on any + // a shared domain might have set. + pr.Out.Header.Del("Cookie") + }, + Transport: transport, + ModifyResponse: func(res *http.Response) error { + res.Header.Del("Set-Cookie") + if res.Header.Get("Cache-Control") == "" { + res.Header.Set("Cache-Control", "no-store") + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err) + writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The admin service can’t reach the homeserver.") + }, + } + + if opts.Panel != nil { + index, err := fs.ReadFile(opts.Panel, "index.html") + if err != nil { + return nil, err + } + h.index = index + } + return h, nil +} + +type handler struct { + opts Options + proxy *httputil.ReverseProxy + index []byte +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + hdr := w.Header() + hdr.Set("X-Content-Type-Options", "nosniff") + hdr.Set("Referrer-Policy", "no-referrer") + hdr.Set("X-Frame-Options", "DENY") + + p := r.URL.Path + switch { + case p == "/healthz": + hdr.Set("Content-Type", "text/plain; charset=utf-8") + hdr.Set("Cache-Control", "no-store") + _, _ = w.Write([]byte("ok\n")) + return + + case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/"): + // A path that cleans to something else (dot segments, doubled + // slashes) is refused rather than normalised: what is allowed + // here must be exactly what the homeserver routes. + if path.Clean(p) != p || !(proxiedExact[p] || strings.HasPrefix(p, adminPrefix)) { + writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the admin service.") + return + } + h.proxy.ServeHTTP(w, r) + return + } + + if r.Method != http.MethodGet && r.Method != http.MethodHead { + hdr.Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + hdr.Set("Content-Security-Policy", contentSecurityPolicy) + h.serveStatic(w, r) +} + +func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) { + if h.opts.Panel == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(notBuiltPage)) + return + } + + name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if name != "" && name != "index.html" { + if st, err := fs.Stat(h.opts.Panel, name); err == nil && st.Mode().IsRegular() { + if strings.HasPrefix(name, "assets/") { + // Vite puts a content hash in every file name under assets/. + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + http.ServeFileFS(w, r, h.opts.Panel, name) + return + } + if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") { + // A missing asset is a 404, not the app: answering a stale + // script URL with HTML only produces a confusing MIME error. + // Deeper paths are routes even with a dot in them, like + // /users/@alice:warp.example. + http.NotFound(w, r) + return + } + } + + // Everything else is a route of the single-page app. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index)) +} + +func writeError(w http.ResponseWriter, status int, errcode, msg string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg}) +} + +const notBuiltPage = ` + +Warp admin + +

The admin panel isn’t built into this binary

+

This warp-admin was compiled before the frontend. Build it, then the binary:

+

pnpm --filter @warp/admin build
make admin

+` diff --git a/apps/admin/internal/host/host_test.go b/apps/admin/internal/host/host_test.go new file mode 100644 index 0000000..159416e --- /dev/null +++ b/apps/admin/internal/host/host_test.go @@ -0,0 +1,236 @@ +package host + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "testing/fstest" +) + +// upstream records what reached the homeserver. +type upstream struct { + srv *httptest.Server + hits []*http.Request +} + +func newUpstream(t *testing.T) *upstream { + t.Helper() + u := &upstream{} + u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u.hits = append(u.hits, r.Clone(r.Context())) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "tracker=1") + _, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`)) + })) + t.Cleanup(u.srv.Close) + return u +} + +var panelFS = fstest.MapFS{ + "index.html": {Data: []byte("panel")}, + "favicon.svg": {Data: []byte("")}, + "assets/index-a1.js": {Data: []byte("console.log(1)")}, +} + +func newHandler(t *testing.T, up *upstream, panel bool) http.Handler { + t.Helper() + target, _ := url.Parse(up.srv.URL) + opts := Options{Homeserver: target} + if panel { + opts.Panel = panelFS + } + h, err := New(opts) + if err != nil { + t.Fatal(err) + } + return h +} + +func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, nil) + for k, v := range header { + req.Header[k] = v + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestProxiesAdminAndSessionPaths(t *testing.T) { + up := newUpstream(t) + h := newHandler(t, up, true) + + for _, p := range []string{ + "/_matrix/client/versions", + "/_matrix/client/v3/login", + "/_matrix/client/v3/logout", + "/_matrix/client/v3/account/whoami", + "/_warp/admin/v1/stats", + "/_warp/admin/v1/users/%40alice%3Awarp.example/devices", + "/_warp/admin/v1/audit_log?limit=5&action=user.create", + } { + rec := do(h, http.MethodGet, p, nil) + if rec.Code != http.StatusOK { + t.Errorf("%s: status %d, want 200", p, rec.Code) + } + } + if len(up.hits) != 7 { + t.Fatalf("upstream saw %d requests, want 7", len(up.hits)) + } + // Escaped IDs and the query reach the homeserver unchanged. + if got := up.hits[5].URL.EscapedPath(); got != "/_warp/admin/v1/users/%40alice%3Awarp.example/devices" { + t.Errorf("escaped path = %q", got) + } + if got := up.hits[6].URL.RawQuery; got != "limit=5&action=user.create" { + t.Errorf("query = %q", got) + } +} + +func TestRefusesOtherHomeserverPaths(t *testing.T) { + up := newUpstream(t) + h := newHandler(t, up, true) + + for _, p := range []string{ + "/_matrix/client/v3/sync", + "/_matrix/client/v3/rooms/!a:b/send/m.room.message/1", + "/_matrix/federation/v1/version", + "/_matrix/media/v3/upload", + "/_warp/admin/v2/stats", + "/_warp/admin/v1/../../_matrix/client/v3/sync", + "/_warp/admin/v1/%2e%2e/%2e%2e/%2e%2e/_matrix/client/v3/sync", + "/_warp/admin/v1//stats", + } { + rec := do(h, http.MethodGet, p, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("%s: status %d, want 404", p, rec.Code) + } + var body map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["errcode"] != "M_UNRECOGNIZED" { + t.Errorf("%s: errcode %q", p, body["errcode"]) + } + } + if len(up.hits) != 0 { + t.Fatalf("upstream saw %d requests, want none", len(up.hits)) + } +} + +func TestProxyStripsCookiesAndForwardsToken(t *testing.T) { + up := newUpstream(t) + h := newHandler(t, up, true) + + rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", http.Header{ + "Authorization": {"Bearer tok"}, + "Cookie": {"session=secret"}, + }) + if rec.Header().Get("Set-Cookie") != "" { + t.Error("Set-Cookie from the homeserver was passed to the browser") + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control")) + } + got := up.hits[0] + if got.Header.Get("Cookie") != "" { + t.Error("Cookie was passed to the homeserver") + } + if got.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("Authorization = %q", got.Header.Get("Authorization")) + } + if got.Header.Get("X-Forwarded-For") == "" { + t.Error("X-Forwarded-For not set") + } +} + +func TestUnreachableHomeserverIs502(t *testing.T) { + target, _ := url.Parse("http://127.0.0.1:1") + h, err := New(Options{Homeserver: target, Panel: panelFS}) + if err != nil { + t.Fatal(err) + } + rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", nil) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status %d, want 502", rec.Code) + } + if !strings.Contains(rec.Body.String(), "M_UNKNOWN") { + t.Errorf("body %q", rec.Body.String()) + } +} + +func TestServesPanel(t *testing.T) { + up := newUpstream(t) + h := newHandler(t, up, true) + + cases := []struct { + path, wantBody, wantCache string + wantCode int + }{ + {"/", "panel", "no-cache", 200}, + {"/users/%40alice%3Awarp.example", "panel", "no-cache", 200}, + {"/rooms/!abc:warp.example", "panel", "no-cache", 200}, + {"/index.html", "panel", "no-cache", 200}, + {"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200}, + {"/favicon.svg", "Warp \ No newline at end of file diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue new file mode 100644 index 0000000..21cd9c0 --- /dev/null +++ b/apps/admin/src/App.vue @@ -0,0 +1,25 @@ + + + diff --git a/apps/admin/src/api/admin.ts b/apps/admin/src/api/admin.ts new file mode 100644 index 0000000..af029ff --- /dev/null +++ b/apps/admin/src/api/admin.ts @@ -0,0 +1,138 @@ +import { enc, request } from './client' +import type { + AppService, + AuditLog, + Destinations, + Device, + Federation, + MediaUsage, + RegistrationToken, + RegistrationTokenList, + RoomDetail, + RoomList, + Stats, + Switch, + UserDetail, + UserList, +} from './types' + +const base = '/_warp/admin/v1' + +function query(params: Record): string { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== '') q.set(k, String(v)) + } + const s = q.toString() + return s ? `?${s}` : '' +} + +// --- Matrix login, for the admin's own session --- + +export interface LoginResponse { + user_id: string + access_token: string + device_id: string +} + +export function login(username: string, password: string) { + return request( + 'POST', + '/_matrix/client/v3/login', + { + type: 'm.login.password', + identifier: { type: 'm.id.user', user: username }, + password, + initial_device_display_name: 'Warp admin panel', + }, + false, + ) +} + +export function logout() { + return request('POST', '/_matrix/client/v3/logout', {}) +} + +export function whoami() { + return request<{ user_id: string; device_id?: string }>('GET', '/_matrix/client/v3/account/whoami') +} + +// --- Dashboard --- + +export const getStats = () => request('GET', `${base}/stats`) + +// --- Federation --- + +export const getFederation = () => request('GET', `${base}/federation`) +export const setFederation = (enabled: boolean) => request('PUT', `${base}/federation`, { enabled }) +export const setFederationLists = (allowlist: string[], denylist: string[]) => + request('PUT', `${base}/federation/lists`, { allowlist, denylist }) +export const resetFederationLists = () => request('DELETE', `${base}/federation/lists`) +export const getDestinations = () => request('GET', `${base}/federation/destinations`) +export const resetDestination = (serverName: string) => + request('POST', `${base}/federation/destinations/${enc(serverName)}/reset`) + +// --- Registration --- + +export const getRegistration = () => request('GET', `${base}/registration`) +export const setRegistration = (enabled: boolean) => request('PUT', `${base}/registration`, { enabled }) + +export const listTokens = () => request('GET', `${base}/registration_tokens`) +export const createToken = (body: { + token?: string + length?: number + uses_allowed?: number | null + expiry_time_ms?: number | null +}) => request('POST', `${base}/registration_tokens`, body) +export const updateToken = (token: string, body: { uses_allowed?: number | null; expiry_time_ms?: number | null }) => + request('PUT', `${base}/registration_tokens/${enc(token)}`, body) +export const deleteToken = (token: string) => request('DELETE', `${base}/registration_tokens/${enc(token)}`) + +// --- Users --- + +export const listUsers = (search: string, limit: number, offset: number) => + request('GET', `${base}/users${query({ search, limit, offset })}`) +export const createUser = (body: { username: string; password: string; display_name?: string; admin: boolean }) => + request('POST', `${base}/users`, body) + +const user = (id: string) => `${base}/users/${enc(id)}` + +export const getUser = (id: string) => request('GET', user(id)) +export const setAdmin = (id: string, admin: boolean) => request('PUT', `${user(id)}/admin`, { admin }) +export const deactivateUser = (id: string, erase: boolean) => + request('POST', `${user(id)}/deactivate`, { erase }) +export const reactivateUser = (id: string) => request('POST', `${user(id)}/reactivate`) +export const resetPassword = (id: string, password: string, logoutDevices: boolean) => + request('POST', `${user(id)}/password`, { password, logout_devices: logoutDevices }) + +export const listDevices = (id: string) => request<{ devices: Device[] }>('GET', `${user(id)}/devices`) +export const deleteDevice = (id: string, deviceId: string) => + request('DELETE', `${user(id)}/devices/${enc(deviceId)}`) +export const deleteAllDevices = (id: string) => request('DELETE', `${user(id)}/devices`) + +export const getMedia = (id: string) => request('GET', `${user(id)}/media`) +export const setQuota = (id: string, maxBytes: number) => + request('PUT', `${user(id)}/media/quota`, { max_bytes: maxBytes }) +export const resetQuota = (id: string) => request('DELETE', `${user(id)}/media/quota`) + +// --- Rooms --- + +export const listRooms = (search: string, limit: number, offset: number) => + request('GET', `${base}/rooms${query({ search, limit, offset })}`) +export const getRoom = (id: string) => request('GET', `${base}/rooms/${enc(id)}`) +export const purgeRoom = (id: string) => request('DELETE', `${base}/rooms/${enc(id)}`) +export const forceLeave = (id: string, userId: string) => + request('POST', `${base}/rooms/${enc(id)}/force_leave`, { user_id: userId }) + +// --- Server notices, app services, audit log --- + +export const sendNotice = (userId: string, body: string) => + request<{ room_id: string; event_id: string }>('POST', `${base}/server_notices`, { + user_id: userId, + content: { msgtype: 'm.text', body }, + }) + +export const listAppServices = () => request<{ appservices: AppService[] }>('GET', `${base}/appservices`) + +export const getAuditLog = (params: { limit?: number; before?: number; actor?: string; action?: string }) => + request('GET', `${base}/audit_log${query(params)}`) diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts new file mode 100644 index 0000000..f56c69b --- /dev/null +++ b/apps/admin/src/api/client.ts @@ -0,0 +1,90 @@ +// One fetch wrapper for both APIs the panel speaks: the C-S login and the +// admin API share the Matrix error envelope, so there is one error path. +// +// Requests go to the panel's own origin. warp-admin (or Vite, in +// development) proxies /_matrix/client and /_warp/admin to the homeserver, +// so the panel never needs CORS or to know where the homeserver lives. + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly errcode: string, + message: string, + ) { + super(message) + this.name = 'ApiError' + } + + get isUnauthorized() { + return this.status === 401 + } +} + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' + +let accessToken: string | null = null +let onUnauthorized: (() => void) | null = null + +export function setAccessToken(token: string | null) { + accessToken = token +} + +/** Called when a request comes back 401: the token was revoked or expired. */ +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn +} + +export async function request(method: Method, path: string, body?: unknown, auth = true): Promise { + const headers: Record = { Accept: 'application/json' } + if (body !== undefined) headers['Content-Type'] = 'application/json' + // The admin API only takes the token in the header: in a URL it would land + // in every access log on the way. + if (auth && accessToken) headers.Authorization = `Bearer ${accessToken}` + + let res: Response + try { + res = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + credentials: 'omit', + cache: 'no-store', + }) + } catch { + throw new ApiError(0, 'NETWORK', 'Can’t reach the admin service. Check your connection and try again.') + } + + const text = await res.text() + let data: unknown = undefined + if (text) { + try { + data = JSON.parse(text) + } catch { + data = undefined + } + } + + if (!res.ok) { + const env = (data ?? {}) as { errcode?: string; error?: string } + const err = new ApiError(res.status, env.errcode ?? `HTTP_${res.status}`, env.error ?? describeStatus(res.status)) + if (err.isUnauthorized && auth && onUnauthorized) onUnauthorized() + throw err + } + return (data ?? {}) as T +} + +function describeStatus(status: number): string { + switch (status) { + case 404: + return 'Not found.' + case 502: + case 504: + return 'The admin service can’t reach the homeserver.' + case 503: + return 'The homeserver is not ready for this.' + default: + return `The server answered ${status}.` + } +} + +export const enc = encodeURIComponent diff --git a/apps/admin/src/api/types.ts b/apps/admin/src/api/types.ts new file mode 100644 index 0000000..9b36096 --- /dev/null +++ b/apps/admin/src/api/types.ts @@ -0,0 +1,162 @@ +// Shapes of /_warp/admin/v1, as docs/admin-api.md describes them. + +export interface Stats { + server_name: string + version: string + database: string + users: number + admins: number + deactivated: number + devices: number + rooms: number + published_rooms: number + events: number + federation_enabled: boolean + registration_enabled: boolean +} + +export interface Switch { + enabled: boolean + toggleable: boolean +} + +export interface Federation extends Switch { + allowlist: string[] + denylist: string[] + lists_edited: boolean +} + +export interface Destination { + server_name: string + failures: number + retry_at_ms: number + last_success_ms: number + last_failure_ms: number + queued_pdus: number + queued_edus: number +} + +export interface Destinations { + running: boolean + destinations: Destination[] +} + +export interface User { + user_id: string + display_name?: string + admin: boolean + deactivated: boolean + created_at_ms: number +} + +export interface Page { + total: number + limit: number + offset: number +} + +export interface UserList extends Page { + users: User[] +} + +export interface MediaUsage { + uploads: number + bytes: number + quota_bytes: number + custom_quota: boolean +} + +export interface UserDetail extends User { + devices: number + joined_rooms?: number + media: MediaUsage +} + +export interface Device { + device_id: string + display_name?: string + created_at_ms: number + last_seen_ms?: number + last_seen_ip?: string + user_agent?: string +} + +export interface RegistrationToken { + token: string + uses_allowed: number | null + completed: number + expiry_time_ms: number | null + created_at_ms: number + valid: boolean + pending?: number +} + +export interface RegistrationTokenList { + registration_tokens: RegistrationToken[] + required: boolean +} + +export interface Room { + room_id: string + room_version: string + creator: string + name?: string + topic?: string + canonical_alias?: string + join_rule?: string + history_visibility?: string + guest_access?: string + published: boolean + joined_members: number + created_at_ms: number +} + +export interface RoomList extends Page { + rooms: Room[] +} + +export interface RoomDetail extends Room { + members: number + invited_members: number + aliases: string[] + forward_extremities: string[] +} + +export interface Namespace { + regex: string + exclusive: boolean +} + +export interface AppService { + id: string + url?: string + sender: string + rate_limited: boolean + protocols: string[] + namespaces: { users: Namespace[]; aliases: Namespace[]; rooms: Namespace[] } + delivery?: { + stream_pos: number + behind: number + txn_id: number + events_delivered: number + failures: number + last_success_ms: number + last_failure_ms: number + retry_at_ms: number + last_error?: string + } +} + +export interface AuditEntry { + id: number + ts_ms: number + actor: string + action: string + target?: string + details?: Record +} + +export interface AuditLog { + entries: AuditEntry[] + next_before?: number +} diff --git a/apps/admin/src/components/ConfirmDialog.vue b/apps/admin/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..5498e36 --- /dev/null +++ b/apps/admin/src/components/ConfirmDialog.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/admin/src/components/LoadState.vue b/apps/admin/src/components/LoadState.vue new file mode 100644 index 0000000..63d9769 --- /dev/null +++ b/apps/admin/src/components/LoadState.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/admin/src/components/Mxid.vue b/apps/admin/src/components/Mxid.vue new file mode 100644 index 0000000..cc3ba23 --- /dev/null +++ b/apps/admin/src/components/Mxid.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/admin/src/components/PageHeader.vue b/apps/admin/src/components/PageHeader.vue new file mode 100644 index 0000000..6a21ed4 --- /dev/null +++ b/apps/admin/src/components/PageHeader.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/admin/src/components/Pager.vue b/apps/admin/src/components/Pager.vue new file mode 100644 index 0000000..4c53b67 --- /dev/null +++ b/apps/admin/src/components/Pager.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/admin/src/components/StatTile.vue b/apps/admin/src/components/StatTile.vue new file mode 100644 index 0000000..30bd1d6 --- /dev/null +++ b/apps/admin/src/components/StatTile.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/admin/src/components/ToastList.vue b/apps/admin/src/components/ToastList.vue new file mode 100644 index 0000000..ec4a9f1 --- /dev/null +++ b/apps/admin/src/components/ToastList.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/admin/src/env.d.ts b/apps/admin/src/env.d.ts new file mode 100644 index 0000000..86dd518 --- /dev/null +++ b/apps/admin/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string + public?: boolean + } +} diff --git a/apps/admin/src/layouts/AppShell.vue b/apps/admin/src/layouts/AppShell.vue new file mode 100644 index 0000000..f76b7b2 --- /dev/null +++ b/apps/admin/src/layouts/AppShell.vue @@ -0,0 +1,113 @@ + + + diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts new file mode 100644 index 0000000..6dbd37c --- /dev/null +++ b/apps/admin/src/lib/format.ts @@ -0,0 +1,60 @@ +import { ApiError } from '@/api/client' + +const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +const dateOnly = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }) +const number = new Intl.NumberFormat() +const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +export function formatDateTime(ms: number | null | undefined): string { + return ms ? dateTime.format(ms) : '—' +} + +export function formatDate(ms: number | null | undefined): string { + return ms ? dateOnly.format(ms) : '—' +} + +export function formatNumber(n: number | null | undefined): string { + return n === null || n === undefined ? '—' : number.format(n) +} + +export function formatRelative(ms: number | null | undefined, now = Date.now()): string { + if (!ms) return '—' + const s = Math.round((ms - now) / 1000) + const abs = Math.abs(s) + if (abs < 60) return relative.format(s, 'second') + if (abs < 3600) return relative.format(Math.round(s / 60), 'minute') + if (abs < 86400) return relative.format(Math.round(s / 3600), 'hour') + return relative.format(Math.round(s / 86400), 'day') +} + +const units = ['B', 'KB', 'MB', 'GB', 'TB'] + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + let v = n + let i = 0 + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + i++ + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}` +} + +/** Parses "512 MB", "1.5GB", "200k" into bytes; null when it can't. */ +export function parseBytes(input: string): number | null { + const m = input.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)(?:i?b)?$/i) + if (!m) return null + const mult: Record = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4 } + return Math.round(Number(m[1]) * (mult[(m[2] ?? '').toLowerCase()] ?? 1)) +} + +export function errorMessage(e: unknown): string { + if (e instanceof ApiError) return e.message + if (e instanceof Error) return e.message + return 'Something went wrong.' +} + +/** The localpart of a Matrix ID, for avatars and short labels. */ +export function localpart(mxid: string): string { + return mxid.replace(/^@/, '').split(':')[0] ?? mxid +} diff --git a/apps/admin/src/lib/useLoad.ts b/apps/admin/src/lib/useLoad.ts new file mode 100644 index 0000000..367efba --- /dev/null +++ b/apps/admin/src/lib/useLoad.ts @@ -0,0 +1,58 @@ +import { ref, shallowRef, type Ref } from 'vue' +import { ApiError } from '@/api/client' +import { errorMessage } from './format' + +// A view's data: what was loaded, whether it is loading, and why it failed. +// A reload keeps the old data on screen until the new data is in. + +export function useLoad(fn: () => Promise) { + const data = shallowRef() as Ref + const loading = ref(false) + const error = ref('') + const status = ref(0) + let seq = 0 + + async function load() { + const mine = ++seq + loading.value = true + error.value = '' + try { + const value = await fn() + if (mine === seq) { + data.value = value + status.value = 200 + } + } catch (e) { + if (mine === seq) { + error.value = errorMessage(e) + status.value = e instanceof ApiError ? e.status : 0 + } + } finally { + if (mine === seq) loading.value = false + } + } + + return { data, loading, error, status, load } +} + +/** Runs an action and tracks whether it is running; returns whether it worked. */ +export function useAction() { + const busy = ref(false) + const error = ref('') + + async function run(fn: () => Promise): Promise { + busy.value = true + error.value = '' + try { + await fn() + return true + } catch (e) { + error.value = errorMessage(e) + return false + } finally { + busy.value = false + } + } + + return { busy, error, run } +} diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts new file mode 100644 index 0000000..ddab9c5 --- /dev/null +++ b/apps/admin/src/main.ts @@ -0,0 +1,7 @@ +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import App from './App.vue' +import { router } from './router' +import './style.css' + +createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/apps/admin/src/router.ts b/apps/admin/src/router.ts new file mode 100644 index 0000000..892d531 --- /dev/null +++ b/apps/admin/src/router.ts @@ -0,0 +1,71 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useSession } from './stores/session' + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true } }, + { + path: '/', + component: () => import('./layouts/AppShell.vue'), + children: [ + { path: '', name: 'dashboard', component: () => import('./views/DashboardView.vue'), meta: { title: 'Dashboard' } }, + { path: 'users', name: 'users', component: () => import('./views/UsersView.vue'), meta: { title: 'Users' } }, + { + path: 'users/:id', + name: 'user', + component: () => import('./views/UserView.vue'), + props: true, + meta: { title: 'User' }, + }, + { path: 'rooms', name: 'rooms', component: () => import('./views/RoomsView.vue'), meta: { title: 'Rooms' } }, + { + path: 'rooms/:id', + name: 'room', + component: () => import('./views/RoomView.vue'), + props: true, + meta: { title: 'Room' }, + }, + { + path: 'federation', + name: 'federation', + component: () => import('./views/FederationView.vue'), + meta: { title: 'Federation' }, + }, + { + path: 'registration', + name: 'registration', + component: () => import('./views/RegistrationView.vue'), + meta: { title: 'Registration' }, + }, + { + path: 'notices', + name: 'notices', + component: () => import('./views/NoticesView.vue'), + meta: { title: 'Server notices' }, + }, + { + path: 'appservices', + name: 'appservices', + component: () => import('./views/AppServicesView.vue'), + meta: { title: 'Application services' }, + }, + { path: 'audit', name: 'audit', component: () => import('./views/AuditView.vue'), meta: { title: 'Audit log' } }, + ], + }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +router.beforeEach((to) => { + const session = useSession() + if (!to.meta.public && !session.signedIn) { + return { name: 'login', query: to.fullPath !== '/' ? { next: to.fullPath } : undefined } + } + if (to.name === 'login' && session.signedIn) return { name: 'dashboard' } +}) + +router.afterEach((to) => { + const title = to.meta.title as string | undefined + document.title = title ? `${title} · Warp admin` : 'Warp admin' +}) diff --git a/apps/admin/src/stores/session.ts b/apps/admin/src/stores/session.ts new file mode 100644 index 0000000..889904e --- /dev/null +++ b/apps/admin/src/stores/session.ts @@ -0,0 +1,110 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import * as api from '@/api/admin' +import { ApiError, setAccessToken, setUnauthorizedHandler } from '@/api/client' + +// The admin's own Matrix session. The token lives in sessionStorage, so it +// goes with the tab, unless the admin asked to stay signed in; then it is in +// localStorage until they sign out. + +interface Saved { + userId: string + deviceId: string + accessToken: string + serverName: string +} + +const key = 'warp.admin.session' + +function load(): Saved | null { + for (const store of [sessionStorage, localStorage]) { + try { + const raw = store.getItem(key) + if (raw) return JSON.parse(raw) as Saved + } catch { + // Unreadable or blocked: treat as signed out. + } + } + return null +} + +function save(s: Saved | null, remember: boolean) { + for (const store of [sessionStorage, localStorage]) { + try { + store.removeItem(key) + } catch { + // Ignore. + } + } + if (!s) return + try { + ;(remember ? localStorage : sessionStorage).setItem(key, JSON.stringify(s)) + } catch { + // Not kept across reloads; the session still works in this page. + } +} + +export const useSession = defineStore('session', () => { + const saved = load() + const userId = ref(saved?.userId ?? '') + const deviceId = ref(saved?.deviceId ?? '') + const token = ref(saved?.accessToken ?? '') + const serverName = ref(saved?.serverName ?? '') + /** Set when the session ended on its own, shown on the sign-in page. */ + const endedReason = ref('') + + setAccessToken(token.value || null) + + const signedIn = computed(() => token.value !== '') + + function clear(reason = '') { + userId.value = '' + deviceId.value = '' + token.value = '' + serverName.value = '' + endedReason.value = reason + setAccessToken(null) + save(null, false) + } + + setUnauthorizedHandler(() => { + if (token.value) clear('Your session ended. Sign in again to continue.') + }) + + async function signIn(username: string, password: string, remember: boolean) { + endedReason.value = '' + const res = await api.login(username.trim(), password) + setAccessToken(res.access_token) + try { + // The dashboard call doubles as the admin check: a valid token + // without the admin flag gets 403 here. + const stats = await api.getStats() + userId.value = res.user_id + deviceId.value = res.device_id + token.value = res.access_token + serverName.value = stats.server_name + save( + { userId: res.user_id, deviceId: res.device_id, accessToken: res.access_token, serverName: stats.server_name }, + remember, + ) + } catch (e) { + await api.logout().catch(() => undefined) + setAccessToken(null) + if (e instanceof ApiError && e.status === 403) { + throw new ApiError(403, e.errcode, `${res.user_id} isn’t an administrator of this homeserver.`) + } + throw e + } + } + + async function signOut() { + try { + await api.logout() + } catch { + // The token may already be gone; forget it either way. + } + clear() + } + + return { userId, deviceId, serverName, signedIn, endedReason, signIn, signOut, clear } +}) diff --git a/apps/admin/src/stores/toasts.ts b/apps/admin/src/stores/toasts.ts new file mode 100644 index 0000000..5ffd43f --- /dev/null +++ b/apps/admin/src/stores/toasts.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +// Short confirmations after an action ("Password reset for @alice:..."), +// and errors that have no form to sit next to. + +export interface Toast { + id: number + tone: 'success' | 'danger' | 'info' + text: string +} + +export const useToasts = defineStore('toasts', () => { + const items = ref([]) + let next = 1 + + function push(tone: Toast['tone'], text: string, ms = 5000) { + const id = next++ + items.value.push({ id, tone, text }) + window.setTimeout(() => dismiss(id), ms) + } + + function dismiss(id: number) { + items.value = items.value.filter((t) => t.id !== id) + } + + return { + items, + success: (text: string) => push('success', text), + error: (text: string) => push('danger', text, 8000), + info: (text: string) => push('info', text), + dismiss, + } +}) diff --git a/apps/admin/src/style.css b/apps/admin/src/style.css new file mode 100644 index 0000000..f3c256e --- /dev/null +++ b/apps/admin/src/style.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@warp/ui/styles.css"; diff --git a/apps/admin/src/views/AppServicesView.vue b/apps/admin/src/views/AppServicesView.vue new file mode 100644 index 0000000..b473b94 --- /dev/null +++ b/apps/admin/src/views/AppServicesView.vue @@ -0,0 +1,117 @@ + + + diff --git a/apps/admin/src/views/AuditView.vue b/apps/admin/src/views/AuditView.vue new file mode 100644 index 0000000..3d31297 --- /dev/null +++ b/apps/admin/src/views/AuditView.vue @@ -0,0 +1,134 @@ + + + diff --git a/apps/admin/src/views/DashboardView.vue b/apps/admin/src/views/DashboardView.vue new file mode 100644 index 0000000..5e1e8de --- /dev/null +++ b/apps/admin/src/views/DashboardView.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/admin/src/views/FederationView.vue b/apps/admin/src/views/FederationView.vue new file mode 100644 index 0000000..cb33d7b --- /dev/null +++ b/apps/admin/src/views/FederationView.vue @@ -0,0 +1,274 @@ + + + diff --git a/apps/admin/src/views/LoginView.vue b/apps/admin/src/views/LoginView.vue new file mode 100644 index 0000000..bbfb498 --- /dev/null +++ b/apps/admin/src/views/LoginView.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/admin/src/views/NoticesView.vue b/apps/admin/src/views/NoticesView.vue new file mode 100644 index 0000000..0812a38 --- /dev/null +++ b/apps/admin/src/views/NoticesView.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/admin/src/views/RegistrationView.vue b/apps/admin/src/views/RegistrationView.vue new file mode 100644 index 0000000..8e92789 --- /dev/null +++ b/apps/admin/src/views/RegistrationView.vue @@ -0,0 +1,269 @@ + + + diff --git a/apps/admin/src/views/RoomView.vue b/apps/admin/src/views/RoomView.vue new file mode 100644 index 0000000..787079d --- /dev/null +++ b/apps/admin/src/views/RoomView.vue @@ -0,0 +1,201 @@ + + + diff --git a/apps/admin/src/views/RoomsView.vue b/apps/admin/src/views/RoomsView.vue new file mode 100644 index 0000000..5ad0d11 --- /dev/null +++ b/apps/admin/src/views/RoomsView.vue @@ -0,0 +1,124 @@ + + + diff --git a/apps/admin/src/views/UserView.vue b/apps/admin/src/views/UserView.vue new file mode 100644 index 0000000..59f58b9 --- /dev/null +++ b/apps/admin/src/views/UserView.vue @@ -0,0 +1,477 @@ + + + diff --git a/apps/admin/src/views/UsersView.vue b/apps/admin/src/views/UsersView.vue new file mode 100644 index 0000000..b8c9f29 --- /dev/null +++ b/apps/admin/src/views/UsersView.vue @@ -0,0 +1,198 @@ + + + diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..3cc5123 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "preserve", + "types": ["vite/client", "node"], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..d4c4ce8 --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath } from 'node:url' +import tailwindcss from '@tailwindcss/vite' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' + +// In development the panel talks to a homeserver through Vite's proxy, the +// same paths warp-admin proxies in production, so the code never needs to +// know where the homeserver is. WARP_ADMIN_HOMESERVER points it elsewhere. +const homeserver = process.env.WARP_ADMIN_HOMESERVER ?? 'http://localhost:8008' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + build: { + // Embedded into the warp-admin binary by internal/panel. + outDir: 'internal/panel/build/dist', + emptyOutDir: true, + assetsInlineLimit: 0, + }, + server: { + port: 5174, + proxy: { + '/_matrix': { target: homeserver, changeOrigin: true }, + '/_warp/admin': { target: homeserver, changeOrigin: true }, + }, + }, +}) diff --git a/apps/ui/README.md b/apps/ui/README.md new file mode 100644 index 0000000..0dddd4c --- /dev/null +++ b/apps/ui/README.md @@ -0,0 +1,62 @@ +# @warp/ui + +Warp's design system for Vue 3 and Tailwind CSS v4: the brand tokens, fonts, +icons and logo, and the components built on them. Every Warp app in the +browser uses it, so a colour or a radius is changed here once. + +The package ships source, not a build: apps import the `.vue` and `.ts` +files and their own Vite compiles them. + +## Use + +```css +/* the app's main stylesheet */ +@import "tailwindcss"; +@import "@warp/ui/styles.css"; +``` + +```ts +import { WButton, WTextField, WBanner, useTheme } from '@warp/ui' +``` + +The stylesheet brings in: + +- `tokens.css`: the colour, type, space, radius and shadow tokens as CSS + variables, the three font faces and the type classes (`title`, `heading`, + `body`, `small`, `caption`, `mxid`, `code`, ...). Dark follows the OS, or + `data-theme="dark"` on `` (`useTheme()` sets it). +- A Tailwind theme on those tokens. The default palette is cleared, so the + colour utilities are the token names: `bg-surface-sunken`, `text-ink-muted`, + `border-line`, `text-teal-text`. They switch with the theme on their own; + no `dark:` variants. `shadow-raised` and `shadow-float` are the tokens' + `shadow-sm` and `shadow-lg`. +- The component styles (`wp-*` classes) from the brand package, plus the + ones drawn here for the apps: card, switch, checkbox, select, textarea, + dialog, table, nav item, segmented control, spinner. + +## Components + +| Component | What | +|---|---| +| `WLogo` | horizontal lockup or symbol, never redrawn | +| `WIcon` | the stroke icons, in `currentColor` | +| `WButton`, `WIconButton` | actions; one `primary` per view | +| `WTextField`, `WTextArea`, `WSelect`, `WCheckbox`, `WSwitch` | forms | +| `WAvatar`, `WBadge` | identity and status | +| `WBanner` | feedback; always icon and words, never colour alone | +| `WDialog` | modal on the native `` | +| `WSpinner` | progress | + +## Where things come from + +`src/styles/tokens.css`, `tokens.json`, `fonts/`, the 20 brand icons in +`src/icons/` and the logo paths in `src/logos/` are copies from the brand +package (`warp-brand/design-system`). Change them there and copy them over. +The other icons (`home`, `globe`, `key`, `trash`, ...) were drawn for the +apps on the same grid: 24px, 1.75 stroke, round caps and joins. + +The fonts are Outfit, Figtree and JetBrains Mono under the SIL Open Font +License. + +The Matrix store (Pinia over matrix-js-sdk) joins this package with the web +client. diff --git a/apps/ui/package.json b/apps/ui/package.json new file mode 100644 index 0000000..e3c82ed --- /dev/null +++ b/apps/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@warp/ui", + "version": "0.0.0", + "private": true, + "description": "Warp's design system for Vue 3 + Tailwind: tokens, fonts, icons and components", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles.css": "./src/styles/index.css", + "./tokens.json": "./src/styles/tokens.json" + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.5.0", + "tailwindcss": "^4.1.0" + }, + "devDependencies": { + "tailwindcss": "^4.1.14", + "typescript": "~5.9.3", + "vite": "^7.1.9", + "vue": "^3.5.22", + "vue-tsc": "^3.1.1" + } +} diff --git a/apps/ui/src/components/WAvatar.vue b/apps/ui/src/components/WAvatar.vue new file mode 100644 index 0000000..7cd9a51 --- /dev/null +++ b/apps/ui/src/components/WAvatar.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/ui/src/components/WBadge.vue b/apps/ui/src/components/WBadge.vue new file mode 100644 index 0000000..3d8eb01 --- /dev/null +++ b/apps/ui/src/components/WBadge.vue @@ -0,0 +1,24 @@ + + + diff --git a/apps/ui/src/components/WBanner.vue b/apps/ui/src/components/WBanner.vue new file mode 100644 index 0000000..8d92de7 --- /dev/null +++ b/apps/ui/src/components/WBanner.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/ui/src/components/WButton.vue b/apps/ui/src/components/WButton.vue new file mode 100644 index 0000000..1bcbf36 --- /dev/null +++ b/apps/ui/src/components/WButton.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/ui/src/components/WCheckbox.vue b/apps/ui/src/components/WCheckbox.vue new file mode 100644 index 0000000..078c841 --- /dev/null +++ b/apps/ui/src/components/WCheckbox.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/ui/src/components/WDialog.vue b/apps/ui/src/components/WDialog.vue new file mode 100644 index 0000000..980375c --- /dev/null +++ b/apps/ui/src/components/WDialog.vue @@ -0,0 +1,69 @@ + + + diff --git a/apps/ui/src/components/WIcon.vue b/apps/ui/src/components/WIcon.vue new file mode 100644 index 0000000..a3772b3 --- /dev/null +++ b/apps/ui/src/components/WIcon.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/ui/src/components/WIconButton.vue b/apps/ui/src/components/WIconButton.vue new file mode 100644 index 0000000..7447489 --- /dev/null +++ b/apps/ui/src/components/WIconButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/ui/src/components/WLogo.vue b/apps/ui/src/components/WLogo.vue new file mode 100644 index 0000000..fae9ae6 --- /dev/null +++ b/apps/ui/src/components/WLogo.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/ui/src/components/WSelect.vue b/apps/ui/src/components/WSelect.vue new file mode 100644 index 0000000..ed123b8 --- /dev/null +++ b/apps/ui/src/components/WSelect.vue @@ -0,0 +1,24 @@ + + + diff --git a/apps/ui/src/components/WSpinner.vue b/apps/ui/src/components/WSpinner.vue new file mode 100644 index 0000000..a210316 --- /dev/null +++ b/apps/ui/src/components/WSpinner.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/ui/src/components/WSwitch.vue b/apps/ui/src/components/WSwitch.vue new file mode 100644 index 0000000..934559b --- /dev/null +++ b/apps/ui/src/components/WSwitch.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/ui/src/components/WTextArea.vue b/apps/ui/src/components/WTextArea.vue new file mode 100644 index 0000000..c5f9b6a --- /dev/null +++ b/apps/ui/src/components/WTextArea.vue @@ -0,0 +1,37 @@ + + +