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 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 15:05:16 +02:00
1 parent 487e0f410b
commit 7e5e38c921
137 files changed
+6895 -15

No files matched your search

+14
View File
@@ -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
+82
View File
@@ -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 '<div id="app">'
curl -fsS http://127.0.0.1:8009/users | grep -q '<div id="app">'
# 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"
+37
View File
@@ -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 .
+6 -1
View File
@@ -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/
```
+12 -9
View File
@@ -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
```
+52
View File
@@ -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"]
+119
View File
@@ -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 <http://localhost:8009>. 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).
+115
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/dariuszjaniak/warp/apps/admin
go 1.26.4
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Warp admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+207
View File
@@ -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 = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warp admin</title>
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
<body><h1 style="font-size:20px">The admin panel isn’t built into this binary</h1>
<p>This warp-admin was compiled before the frontend. Build it, then the binary:</p>
<p><code>pnpm --filter @warp/admin build</code><br><code>make admin</code></p></body></html>
`
+236
View File
@@ -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("<!doctype html><title>panel</title>")},
"favicon.svg": {Data: []byte("<svg/>")},
"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", "<svg", "public, max-age=3600", 200},
{"/assets/gone-b2.js", "", "", 404},
{"/robots.txt", "", "", 404},
}
for _, c := range cases {
rec := do(h, http.MethodGet, c.path, nil)
if rec.Code != c.wantCode {
t.Errorf("%s: status %d, want %d", c.path, rec.Code, c.wantCode)
continue
}
if c.wantCode != 200 {
continue
}
body, _ := io.ReadAll(rec.Body)
if !strings.Contains(string(body), c.wantBody) {
t.Errorf("%s: body %q", c.path, body)
}
if got := rec.Header().Get("Cache-Control"); got != c.wantCache {
t.Errorf("%s: Cache-Control %q, want %q", c.path, got, c.wantCache)
}
if rec.Header().Get("Content-Security-Policy") == "" {
t.Errorf("%s: no CSP", c.path)
}
if rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s: X-Frame-Options %q", c.path, rec.Header().Get("X-Frame-Options"))
}
}
if rec := do(h, http.MethodPost, "/", nil); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /: status %d, want 405", rec.Code)
}
}
func TestNotBuiltPanel(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, false)
rec := do(h, http.MethodGet, "/", nil)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "isn’t built") {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
// The proxy still works without the panel.
if rec := do(h, http.MethodGet, "/_warp/admin/v1/stats", nil); rec.Code != http.StatusOK {
t.Errorf("proxy status %d", rec.Code)
}
}
func TestHealthz(t *testing.T) {
up := newUpstream(t)
h := newHandler(t, up, true)
if rec := do(h, http.MethodGet, "/healthz", nil); rec.Code != 200 || rec.Body.String() != "ok\n" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
}
func TestNewRejectsRelativeHomeserver(t *testing.T) {
u, _ := url.Parse("warp:8008")
if _, err := New(Options{Homeserver: u}); err == nil {
t.Fatal("want an error for a URL without a scheme and host")
}
}
@@ -0,0 +1,4 @@
`pnpm build` in `apps/admin` writes the panel to `dist/` here, and
`internal/panel` embeds it into the `warp-admin` binary. This file keeps the
directory in git so the Go code compiles before the frontend is built; such a
binary serves a page saying the panel has not been built.
+23
View File
@@ -0,0 +1,23 @@
// Package panel holds the built admin panel, embedded into the binary.
package panel
import (
"embed"
"io/fs"
)
//go:embed all:build
var build embed.FS
// FS returns the built panel, rooted at its index.html. ok is false when
// the binary was compiled before `pnpm build`, and the panel is missing.
func FS() (files fs.FS, ok bool) {
sub, err := fs.Sub(build, "build/dist")
if err != nil {
return nil, false
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil, false
}
return sub, true
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@warp/admin",
"version": "0.0.0",
"private": true,
"description": "Warp admin panel: the operator's web UI for /_warp/admin/v1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@warp/ui": "workspace:*",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.18.10",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.9.3",
"vite": "^7.1.9",
"vue-tsc": "^3.1.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256"><title>Warp</title><g transform="translate(-23.3 -22.11) scale(1.182)"><path fill="#00A389" fill-rule="evenodd" d="M73.5 23.1 L67.4 23.9 L60.1 26 L54.6 28.5 L48.2 32.6 L42.6 37.7 L37.9 43.6 L34.2 50.3 L32.1 56 L30.7 61.9 L30.1 67.9 L30.1 105.8 L30.3 110.3 L31.3 116.3 L33 122.2 L35.5 127.7 L38.8 132.9 L43.7 138.6 L47 141.7 L50.7 144.4 L54.6 146.8 L58.7 148.7 L66 151 L70.4 151.8 L75 152.2 L112.9 152.2 L117.4 152 L124.9 150.7 L130.6 148.7 L136.1 146 L142.3 141.7 L146.8 137.5 L151.5 131.6 L155.2 125 L157.7 117.9 L174.2 117.9 L176.6 118.3 L179.1 119.3 L181.7 121.2 L183.7 123.6 L185 126.5 L185.5 129.6 L185.5 167.6 L184.9 170.3 L183.9 172.4 L182.5 174.4 L180.8 176 L177.7 177.7 L174.2 178.5 L135.9 178.4 L133.6 178 L131.7 177.2 L129.7 176 L128 174.4 L126.6 172.4 L125.8 170.6 L125.1 168.4 L125 166.1 L125 167.2 L117.2 168.2 L90.7 168.4 L91 172.4 L91.9 176.9 L94.2 184.2 L96.1 188.3 L99.3 193.4 L102.2 197 L105.3 200.3 L108.8 203.2 L113.9 206.6 L117.9 208.7 L122.2 210.4 L128 211.9 L135.6 212.8 L173.4 212.8 L180.6 212.3 L225.9 231 L217.3 181.8 L219 175.4 L219.9 167.9 L219.9 130 L219.5 124 L218.3 118 L216.9 113.7 L214.4 108.1 L211.2 103 L206.3 97.2 L202.9 94.1 L199.2 91.4 L195.3 89.1 L191.2 87.1 L184 84.8 L179.5 84 L175 83.6 L137.1 83.6 L132.5 83.8 L125.1 85.2 L119.3 87.1 L112.6 90.6 L107.6 94.1 L102.2 99.4 L98.5 104.2 L94.8 110.9 L92.3 117.9 L75.7 117.9 L73 117.4 L70.4 116.3 L68.2 114.6 L66.4 112.5 L65.2 110.1 L64.5 107.4 L64.4 69.7 L64.8 66.3 L66 63.4 L67.9 60.9 L70.8 58.8 L73.4 57.7 L76.1 57.3 L112.6 57.3 L115.2 57.6 L117.1 58.1 L118.9 59 L120.8 60.3 L122.2 61.8 L123.3 63.4 L124.3 65.6 L124.8 67.5 L125 69.7 L125 68.6 L132.7 67.6 L159.3 67.4 L158.7 61.9 L157.3 56 L155.2 50.3 L152.3 44.9 L148.8 40 L144.6 35.5 L139.9 31.7 L134.7 28.5 L130.6 26.5 L126.3 25 L121.9 23.9 L117.4 23.2 L112.9 23Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTheme } from '@warp/ui'
import ToastList from './components/ToastList.vue'
import { useSession } from './stores/session'
useTheme()
const session = useSession()
const router = useRouter()
// A 401 anywhere clears the session; send the admin back to sign in.
watch(
() => session.signedIn,
(signedIn) => {
if (!signedIn && router.currentRoute.value.name !== 'login') router.replace({ name: 'login' })
},
)
</script>
<template>
<RouterView />
<ToastList />
</template>
+138
View File
@@ -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, string | number | undefined>): 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<LoginResponse>(
'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<object>('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<Stats>('GET', `${base}/stats`)
// --- Federation ---
export const getFederation = () => request<Federation>('GET', `${base}/federation`)
export const setFederation = (enabled: boolean) => request<Federation>('PUT', `${base}/federation`, { enabled })
export const setFederationLists = (allowlist: string[], denylist: string[]) =>
request<Federation>('PUT', `${base}/federation/lists`, { allowlist, denylist })
export const resetFederationLists = () => request<Federation>('DELETE', `${base}/federation/lists`)
export const getDestinations = () => request<Destinations>('GET', `${base}/federation/destinations`)
export const resetDestination = (serverName: string) =>
request<object>('POST', `${base}/federation/destinations/${enc(serverName)}/reset`)
// --- Registration ---
export const getRegistration = () => request<Switch>('GET', `${base}/registration`)
export const setRegistration = (enabled: boolean) => request<Switch>('PUT', `${base}/registration`, { enabled })
export const listTokens = () => request<RegistrationTokenList>('GET', `${base}/registration_tokens`)
export const createToken = (body: {
token?: string
length?: number
uses_allowed?: number | null
expiry_time_ms?: number | null
}) => request<RegistrationToken>('POST', `${base}/registration_tokens`, body)
export const updateToken = (token: string, body: { uses_allowed?: number | null; expiry_time_ms?: number | null }) =>
request<RegistrationToken>('PUT', `${base}/registration_tokens/${enc(token)}`, body)
export const deleteToken = (token: string) => request<object>('DELETE', `${base}/registration_tokens/${enc(token)}`)
// --- Users ---
export const listUsers = (search: string, limit: number, offset: number) =>
request<UserList>('GET', `${base}/users${query({ search, limit, offset })}`)
export const createUser = (body: { username: string; password: string; display_name?: string; admin: boolean }) =>
request<UserDetail>('POST', `${base}/users`, body)
const user = (id: string) => `${base}/users/${enc(id)}`
export const getUser = (id: string) => request<UserDetail>('GET', user(id))
export const setAdmin = (id: string, admin: boolean) => request<UserDetail>('PUT', `${user(id)}/admin`, { admin })
export const deactivateUser = (id: string, erase: boolean) =>
request<UserDetail>('POST', `${user(id)}/deactivate`, { erase })
export const reactivateUser = (id: string) => request<UserDetail>('POST', `${user(id)}/reactivate`)
export const resetPassword = (id: string, password: string, logoutDevices: boolean) =>
request<UserDetail>('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<object>('DELETE', `${user(id)}/devices/${enc(deviceId)}`)
export const deleteAllDevices = (id: string) => request<object>('DELETE', `${user(id)}/devices`)
export const getMedia = (id: string) => request<MediaUsage>('GET', `${user(id)}/media`)
export const setQuota = (id: string, maxBytes: number) =>
request<MediaUsage>('PUT', `${user(id)}/media/quota`, { max_bytes: maxBytes })
export const resetQuota = (id: string) => request<MediaUsage>('DELETE', `${user(id)}/media/quota`)
// --- Rooms ---
export const listRooms = (search: string, limit: number, offset: number) =>
request<RoomList>('GET', `${base}/rooms${query({ search, limit, offset })}`)
export const getRoom = (id: string) => request<RoomDetail>('GET', `${base}/rooms/${enc(id)}`)
export const purgeRoom = (id: string) => request<object>('DELETE', `${base}/rooms/${enc(id)}`)
export const forceLeave = (id: string, userId: string) =>
request<object>('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<AuditLog>('GET', `${base}/audit_log${query(params)}`)
+90
View File
@@ -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<T>(method: Method, path: string, body?: unknown, auth = true): Promise<T> {
const headers: Record<string, string> = { 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
+162
View File
@@ -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<string, unknown>
}
export interface AuditLog {
entries: AuditEntry[]
next_before?: number
}
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { WBanner, WButton, WDialog, WTextField } from '@warp/ui'
// Confirmation for the actions that can't be taken back. With `typeToConfirm`
// the admin types the name of what goes (a room ID, a user ID) before the
// button unlocks, so a slip on a table can't purge the wrong thing.
const props = withDefaults(
defineProps<{
title: string
confirmLabel: string
danger?: boolean
typeToConfirm?: string
busy?: boolean
error?: string
}>(),
{ danger: true, typeToConfirm: undefined, busy: false, error: '' },
)
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ confirm: [] }>()
const typed = ref('')
watch(open, (v) => {
if (v) typed.value = ''
})
const ready = computed(() => !props.typeToConfirm || typed.value.trim() === props.typeToConfirm)
</script>
<template>
<WDialog v-model:open="open" :title="title" :persistent="busy">
<div class="body flex flex-col gap-3">
<slot />
</div>
<WTextField
v-if="typeToConfirm"
v-model="typed"
wide
mono
:label="`Type ${typeToConfirm} to confirm`"
autocomplete="off"
spellcheck="false"
/>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<template #actions>
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
<WButton :variant="danger ? 'danger-solid' : 'primary'" :disabled="!ready" :loading="busy" @click="emit('confirm')">
{{ confirmLabel }}
</WButton>
</template>
</WDialog>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { WBanner, WButton, WSpinner } from '@warp/ui'
// What a view shows before its data is in: a spinner on the first load, or
// the error with a way to try again.
defineProps<{ loading: boolean; error: string; hasData: boolean }>()
const emit = defineEmits<{ retry: [] }>()
</script>
<template>
<WBanner v-if="error" tone="danger" title="Couldn’t load this" class="mb-4">
{{ error }}
<template #action>
<WButton size="sm" icon="refresh" @click="emit('retry')">Try again</WButton>
</template>
</WBanner>
<div v-else-if="loading && !hasData" class="flex items-center gap-3 py-12 text-ink-muted">
<WSpinner label="Loading" />
Loading…
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
import { WIcon } from '@warp/ui'
// A Matrix ID, alias, server name or key: always monospace, with a copy
// button for the ones an admin pastes into a terminal.
const props = withDefaults(defineProps<{ value: string; copy?: boolean }>(), { copy: false })
const copied = ref(false)
async function doCopy() {
try {
await navigator.clipboard.writeText(props.value)
copied.value = true
window.setTimeout(() => (copied.value = false), 1500)
} catch {
// Clipboard blocked; the text is still selectable.
}
}
</script>
<template>
<span class="inline-flex max-w-full min-w-0 items-center gap-1">
<span class="mxid truncate" :title="value">{{ value }}</span>
<button
v-if="copy"
type="button"
class="inline-flex size-6 flex-none cursor-pointer items-center justify-center rounded-xs border-0 bg-transparent text-ink-muted hover:bg-line hover:text-ink"
:aria-label="copied ? 'Copied' : `Copy ${value}`"
:title="copied ? 'Copied' : 'Copy'"
@click.stop="doCopy"
>
<WIcon :name="copied ? 'check' : 'copy'" :size="14" />
</button>
</span>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { WIcon } from '@warp/ui'
defineProps<{
title: string
description?: string
/** A link back to the list this page belongs to. */
back?: { to: string; label: string }
}>()
</script>
<template>
<header class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<RouterLink
v-if="back"
:to="back.to"
class="small mb-2 inline-flex items-center gap-1 font-semibold text-teal-text no-underline hover:underline"
>
<WIcon name="chevron-left" :size="16" />
{{ back.label }}
</RouterLink>
<h1 class="title m-0 break-words">
<slot name="title">{{ title }}</slot>
</h1>
<p v-if="description || $slots.description" class="body mt-1 max-w-[65ch] text-ink-muted">
<slot name="description">{{ description }}</slot>
</p>
</div>
<div v-if="$slots.actions" class="flex flex-wrap gap-2">
<slot name="actions" />
</div>
</header>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { WButton } from '@warp/ui'
import { formatNumber } from '@/lib/format'
const props = defineProps<{ total: number; limit: number; offset: number; noun: string }>()
const emit = defineEmits<{ go: [offset: number] }>()
const from = computed(() => (props.total === 0 ? 0 : props.offset + 1))
const to = computed(() => Math.min(props.offset + props.limit, props.total))
</script>
<template>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="small flex-1 text-ink-muted">
<template v-if="total === 0">No {{ noun }}</template>
<template v-else>{{ formatNumber(from) }}–{{ formatNumber(to) }} of {{ formatNumber(total) }} {{ noun }}</template>
</p>
<div class="flex gap-2">
<WButton size="sm" icon="chevron-left" :disabled="offset === 0" @click="emit('go', Math.max(0, offset - limit))">
Previous
</WButton>
<WButton size="sm" :disabled="offset + limit >= total" @click="emit('go', offset + limit)">Next</WButton>
</div>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { WIcon, type IconName } from '@warp/ui'
defineProps<{ label: string; value: string; icon: IconName; detail?: string; to?: string }>()
</script>
<template>
<component
:is="to ? 'RouterLink' : 'div'"
:to="to"
:class="[
'wp-card flex flex-col gap-1 p-4 text-ink no-underline',
to && 'transition-colors hover:border-line-strong',
]"
>
<span class="small flex items-center gap-2 font-semibold text-ink-muted">
<WIcon :name="icon" :size="16" />
{{ label }}
</span>
<span class="font-display text-[28px] leading-[34px] font-semibold tabular-nums">{{ value }}</span>
<span v-if="detail" class="caption text-ink-muted">{{ detail }}</span>
</component>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { WIcon, WIconButton } from '@warp/ui'
import { useToasts } from '@/stores/toasts'
const toasts = useToasts()
const icon = { success: 'shield-check', danger: 'shield-alert', info: 'server' } as const
const tone = {
success: 'bg-teal-soft text-teal-text',
danger: 'bg-danger-soft text-danger',
info: 'bg-info-soft text-info',
} as const
</script>
<template>
<div
class="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex flex-col items-end gap-2 sm:left-auto sm:w-96"
aria-live="polite"
>
<div
v-for="t in toasts.items"
:key="t.id"
:class="[
'pointer-events-auto flex w-full items-center gap-3 rounded-md border border-line py-2 pr-1 pl-4 shadow-float',
tone[t.tone],
]"
:role="t.tone === 'danger' ? 'alert' : 'status'"
>
<WIcon :name="icon[t.tone]" />
<p class="flex-1 text-ink">{{ t.text }}</p>
<WIconButton icon="close" label="Dismiss" @click="toasts.dismiss(t.id)" />
</div>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
public?: boolean
}
}
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WAvatar, WIcon, WIconButton, WLogo, useTheme, type IconName, type ThemeChoice } from '@warp/ui'
import { localpart } from '@/lib/format'
import { useSession } from '@/stores/session'
const session = useSession()
const route = useRoute()
const router = useRouter()
const theme = useTheme()
const navOpen = ref(false)
const nav: { to: string; label: string; icon: IconName }[] = [
{ to: '/', label: 'Dashboard', icon: 'home' },
{ to: '/users', label: 'Users', icon: 'user' },
{ to: '/rooms', label: 'Rooms', icon: 'room' },
{ to: '/federation', label: 'Federation', icon: 'globe' },
{ to: '/registration', label: 'Registration', icon: 'key' },
{ to: '/notices', label: 'Server notices', icon: 'megaphone' },
{ to: '/appservices', label: 'Application services', icon: 'plug' },
{ to: '/audit', label: 'Audit log', icon: 'history' },
]
const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
{ value: 'light', label: 'Light', icon: 'sun' },
{ value: 'dark', label: 'Dark', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'settings' },
]
function isActive(to: string) {
return to === '/' ? route.path === '/' : route.path === to || route.path.startsWith(`${to}/`)
}
watch(
() => route.fullPath,
() => (navOpen.value = false),
)
async function signOut() {
await session.signOut()
router.replace({ name: 'login' })
}
</script>
<template>
<div class="flex min-h-dvh bg-surface">
<!-- Sidebar: a rail on desktop, a drawer on a phone -->
<div v-if="navOpen" class="fixed inset-0 z-30 bg-[#0f121399] lg:hidden" aria-hidden="true" @click="navOpen = false" />
<aside
:class="[
'fixed inset-y-0 left-0 z-30 flex w-72 flex-col border-r border-line bg-surface-sunken transition-transform duration-150 ease-out lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0',
navOpen ? 'translate-x-0' : '-translate-x-full',
]"
aria-label="Admin navigation"
>
<div class="flex h-16 items-center gap-2 px-5">
<RouterLink to="/" class="text-ink" aria-label="Warp admin, dashboard">
<WLogo :height="28" />
</RouterLink>
<span class="caption ml-1 rounded-xs bg-line px-1.5 py-0.5 text-ink">Admin</span>
<WIconButton class="ml-auto lg:hidden" icon="close" label="Close menu" @click="navOpen = false" />
</div>
<nav class="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 py-2">
<RouterLink
v-for="item in nav"
:key="item.to"
:to="item.to"
:class="['wp-nav', isActive(item.to) && 'wp-nav-active']"
:aria-current="isActive(item.to) ? 'page' : undefined"
>
<WIcon :name="item.icon" />
{{ item.label }}
</RouterLink>
</nav>
<div class="flex flex-col gap-3 border-t border-line p-4">
<div class="wp-segmented self-start" role="group" aria-label="Theme">
<button
v-for="t in themes"
:key="t.value"
type="button"
:aria-pressed="theme === t.value"
:title="t.label"
@click="theme = t.value"
>
<WIcon :name="t.icon" :size="16" />
{{ t.label }}
</button>
</div>
<div class="flex items-center gap-3">
<WAvatar :name="localpart(session.userId)" :size="32" />
<div class="min-w-0 flex-1">
<p class="body-strong truncate">{{ localpart(session.userId) }}</p>
<p class="mxid truncate text-ink-muted">{{ session.userId }}</p>
</div>
<WIconButton icon="logout" label="Sign out" @click="signOut" />
</div>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
<header class="sticky top-0 z-20 flex h-14 items-center gap-2 border-b border-line bg-surface-sunken px-2 lg:hidden">
<WIconButton icon="menu" label="Open menu" @click="navOpen = true" />
<WLogo :height="24" />
</header>
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-6 sm:px-8 sm:py-8">
<RouterView :key="route.path" />
</main>
</div>
</div>
</template>
+60
View File
@@ -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<string, number> = { '': 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
}
+58
View File
@@ -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<T>(fn: () => Promise<T>) {
const data = shallowRef<T>() as Ref<T | undefined>
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<unknown>): Promise<boolean> {
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 }
}
+7
View File
@@ -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')
+71
View File
@@ -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'
})
+110
View File
@@ -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 }
})
+34
View File
@@ -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<Toast[]>([])
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,
}
})
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@warp/ui/styles.css";
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { WBadge, WBanner, WButton } from '@warp/ui'
import { listAppServices } from '@/api/admin'
import type { Namespace } from '@/api/types'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { formatNumber, formatRelative } from '@/lib/format'
import { useLoad } from '@/lib/useLoad'
const services = useLoad(listAppServices)
const now = ref(Date.now())
onMounted(services.load)
function reload() {
now.value = Date.now()
services.load()
}
const kinds: { key: 'users' | 'aliases' | 'rooms'; label: string }[] = [
{ key: 'users', label: 'Users' },
{ key: 'aliases', label: 'Aliases' },
{ key: 'rooms', label: 'Rooms' },
]
function list(ns: Namespace[] | undefined) {
return ns ?? []
}
</script>
<template>
<PageHeader
title="Application services"
description="Bridges and bots registered with this homeserver. Registrations are files read at startup, so this is a view, not an editor."
>
<template #actions>
<WButton icon="refresh" :loading="services.loading.value" @click="reload">Refresh</WButton>
</template>
</PageHeader>
<LoadState
:loading="services.loading.value"
:error="services.error.value"
:has-data="!!services.data.value"
@retry="services.load"
/>
<template v-if="services.data.value">
<p v-if="services.data.value.appservices.length === 0" class="wp-card p-8 text-center text-ink-muted">
No application services. Add registration files under <code class="code">app_service_api.config_files</code> and
restart.
</p>
<div class="flex flex-col gap-4">
<section v-for="s in services.data.value.appservices" :key="s.id" class="wp-card flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1">
<h2 class="subheading m-0">{{ s.id }}</h2>
<Mxid :value="s.sender" class="text-ink-muted" />
</div>
<div class="flex flex-wrap gap-1">
<WBadge v-if="s.rate_limited" tone="neutral">Rate limited</WBadge>
<template v-if="s.delivery">
<WBadge v-if="s.delivery.failures === 0" tone="success" icon="shield-check">Delivering</WBadge>
<WBadge v-else tone="warning" icon="shield-alert">{{ s.delivery.failures }} failed in a row</WBadge>
</template>
<WBadge v-else tone="neutral">No URL, sent nothing</WBadge>
</div>
</div>
<WBanner v-if="s.delivery && s.delivery.failures > 0" tone="warning">
{{ s.delivery.last_error || 'Delivery is failing.' }}
<template v-if="s.delivery.retry_at_ms">Next try {{ formatRelative(s.delivery.retry_at_ms, now) }}.</template>
</WBanner>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
<div v-if="s.url" class="col-span-2">
<dt class="caption text-ink-muted">URL</dt>
<dd class="m-0"><Mxid :value="s.url" /></dd>
</div>
<template v-if="s.delivery">
<div>
<dt class="caption text-ink-muted">Events delivered</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.events_delivered) }}</dd>
</div>
<div>
<dt class="caption text-ink-muted">Behind</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.behind) }} positions</dd>
</div>
<div>
<dt class="caption text-ink-muted">Last success</dt>
<dd class="m-0">{{ formatRelative(s.delivery.last_success_ms, now) }}</dd>
</div>
<div>
<dt class="caption text-ink-muted">Transaction</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(s.delivery.txn_id) }}</dd>
</div>
</template>
</dl>
<div class="grid gap-4 border-t border-line pt-4 md:grid-cols-3">
<div v-for="k in kinds" :key="k.key">
<p class="caption mb-1 text-ink-muted">{{ k.label }} namespace</p>
<ul v-if="list(s.namespaces[k.key]).length" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="n in list(s.namespaces[k.key])" :key="n.regex" class="flex items-center gap-2">
<code class="code truncate" :title="n.regex">{{ n.regex }}</code>
<WBadge v-if="n.exclusive" tone="info">Exclusive</WBadge>
</li>
</ul>
<p v-else class="small text-ink-muted">None</p>
</div>
</div>
</section>
</div>
</template>
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { WButton, WSelect, WTextField } from '@warp/ui'
import { getAuditLog } from '@/api/admin'
import type { AuditEntry } from '@/api/types'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { errorMessage, formatDateTime } from '@/lib/format'
// The log is paged by ID, not offset: it grows at the top while you read
// it, so "Older" asks for entries before the last one shown.
const limit = 50
const actions = [
['', 'All actions'],
['federation.switch', 'Federation switched'],
['federation.lists', 'Federation lists saved'],
['federation.lists_reset', 'Federation lists reset'],
['federation.destination_reset', 'Destination retried'],
['registration.switch', 'Registration switched'],
['user.create', 'User created'],
['user.admin', 'Admin flag changed'],
['user.deactivate', 'User deactivated'],
['user.reactivate', 'User reactivated'],
['user.password', 'Password reset'],
['user.device_delete', 'Session signed out'],
['user.media_quota', 'Media quota changed'],
['room.purge', 'Room purged'],
['room.force_leave', 'User made to leave'],
['registration_token.create', 'Token created'],
['registration_token.update', 'Token updated'],
['registration_token.delete', 'Token deleted'],
['server_notice.send', 'Server notice sent'],
].map(([value, label]) => ({ value: value!, label: label! }))
const labels = Object.fromEntries(actions.map((a) => [a.value, a.label]))
const actor = ref('')
const action = ref('')
const entries = ref<AuditEntry[]>([])
const nextBefore = ref<number | undefined>()
const loading = ref(false)
const error = ref('')
const loaded = ref(false)
async function load(more = false) {
loading.value = true
error.value = ''
try {
const res = await getAuditLog({
limit,
before: more ? nextBefore.value : undefined,
actor: actor.value.trim() || undefined,
action: action.value || undefined,
})
entries.value = more ? [...entries.value, ...res.entries] : res.entries
nextBefore.value = res.next_before
loaded.value = true
} catch (e) {
error.value = errorMessage(e)
} finally {
loading.value = false
}
}
onMounted(() => load())
watch(action, () => load())
function details(d: Record<string, unknown> | undefined): string {
if (!d) return ''
return Object.entries(d)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') || '—' : v === null ? 'reset' : String(v)}`)
.join(' · ')
}
</script>
<template>
<PageHeader title="Audit log" description="Every change made through the admin API, newest first." />
<form class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end" @submit.prevent="load()">
<WTextField
v-model="actor"
class="flex-1"
mono
label="Admin"
placeholder="@root:warp.example"
autocomplete="off"
spellcheck="false"
/>
<WSelect v-model="action" class="flex-1" label="Action" :options="actions" />
<WButton type="submit" icon="search">Filter</WButton>
</form>
<LoadState :loading="loading" :error="error" :has-data="loaded" @retry="load()" />
<template v-if="loaded">
<div class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>When</th>
<th>What</th>
<th class="hidden md:table-cell">Admin</th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.id">
<td class="whitespace-nowrap text-ink-muted">{{ formatDateTime(e.ts_ms) }}</td>
<td class="max-w-0 w-full">
<p class="body-strong">
{{ labels[e.action] ?? e.action }}
<code class="caption ml-1 font-mono text-ink-muted">{{ e.action }}</code>
</p>
<Mxid v-if="e.target" :value="e.target" />
<p v-if="details(e.details)" class="small truncate text-ink-muted" :title="details(e.details)">
{{ details(e.details) }}
</p>
<Mxid :value="e.actor" class="text-ink-muted md:hidden" />
</td>
<td class="hidden md:table-cell"><Mxid :value="e.actor" /></td>
</tr>
<tr v-if="entries.length === 0">
<td colspan="3" class="py-10 text-center text-ink-muted">No entries match.</td>
</tr>
</tbody>
</table>
</div>
<div v-if="nextBefore" class="mt-3">
<WButton :loading="loading" @click="load(true)">Show older</WButton>
</div>
</template>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { WBadge, WButton, WIcon } from '@warp/ui'
import { getStats } from '@/api/admin'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import StatTile from '@/components/StatTile.vue'
import { formatNumber } from '@/lib/format'
import { useLoad } from '@/lib/useLoad'
const stats = useLoad(getStats)
onMounted(stats.load)
const s = computed(() => stats.data.value)
const active = computed(() => (s.value ? s.value.users - s.value.deactivated : 0))
</script>
<template>
<PageHeader title="Dashboard" description="How this homeserver is doing right now.">
<template #actions>
<WButton icon="refresh" :loading="stats.loading.value" @click="stats.load">Refresh</WButton>
</template>
</PageHeader>
<LoadState :loading="stats.loading.value" :error="stats.error.value" :has-data="!!s" @retry="stats.load" />
<template v-if="s">
<section class="wp-card mb-6 flex flex-wrap items-center gap-x-8 gap-y-3 p-4" aria-label="Server">
<div class="flex items-center gap-3">
<span class="flex size-10 items-center justify-center rounded-md bg-teal-soft text-teal-text">
<WIcon name="server" />
</span>
<div>
<p class="caption text-ink-muted">Homeserver</p>
<Mxid :value="s.server_name" copy />
</div>
</div>
<div>
<p class="caption text-ink-muted">Version</p>
<p class="mxid">{{ s.version }}</p>
</div>
<div>
<p class="caption text-ink-muted">Database</p>
<p class="body-strong">{{ s.database }}</p>
</div>
<div class="flex flex-wrap gap-2 sm:ml-auto">
<RouterLink to="/federation" class="no-underline">
<WBadge :tone="s.federation_enabled ? 'success' : 'neutral'" icon="globe">
Federation {{ s.federation_enabled ? 'on' : 'off' }}
</WBadge>
</RouterLink>
<RouterLink to="/registration" class="no-underline">
<WBadge :tone="s.registration_enabled ? 'warning' : 'neutral'" icon="key">
Registration {{ s.registration_enabled ? 'open' : 'closed' }}
</WBadge>
</RouterLink>
</div>
</section>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatTile
to="/users"
icon="user"
label="Users"
:value="formatNumber(s.users)"
:detail="`${formatNumber(active)} active · ${formatNumber(s.deactivated)} deactivated`"
/>
<StatTile icon="shield-check" label="Admins" :value="formatNumber(s.admins)" detail="Accounts with the admin flag" />
<StatTile icon="lock" label="Sessions" :value="formatNumber(s.devices)" detail="Signed-in devices across all users" />
<StatTile
to="/rooms"
icon="room"
label="Rooms"
:value="formatNumber(s.rooms)"
:detail="`${formatNumber(s.published_rooms)} in the public directory`"
/>
<StatTile icon="send" label="Events" :value="formatNumber(s.events)" detail="Stored in all rooms" />
</div>
</template>
</template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { WBadge, WBanner, WButton, WSwitch, WTextArea } from '@warp/ui'
import * as api from '@/api/admin'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { formatNumber, formatRelative } from '@/lib/format'
import { useAction, useLoad } from '@/lib/useLoad'
import { useToasts } from '@/stores/toasts'
const toasts = useToasts()
const fed = useLoad(api.getFederation)
const dest = useLoad(api.getDestinations)
onMounted(() => {
fed.load()
dest.load()
})
const f = computed(() => fed.data.value)
// --- Switch ---
const toggle = useAction()
async function setEnabled(enabled: boolean) {
const ok = await toggle.run(async () => {
fed.data.value = await api.setFederation(enabled)
})
if (ok) {
toasts.success(enabled ? 'Federation is on' : 'Federation is off')
dest.load()
} else toasts.error(toggle.error.value)
}
// --- Lists ---
const allow = ref('')
const deny = ref('')
watch(
f,
(v) => {
if (!v) return
allow.value = v.allowlist.join('\n')
deny.value = v.denylist.join('\n')
},
{ immediate: true },
)
function parse(text: string): string[] {
return [
...new Set(
text
.split(/[\s,]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
),
].sort()
}
const dirty = computed(() => {
const v = f.value
if (!v) return false
return parse(allow.value).join() !== v.allowlist.join() || parse(deny.value).join() !== v.denylist.join()
})
const lists = useAction()
async function saveLists() {
const ok = await lists.run(async () => {
fed.data.value = await api.setFederationLists(parse(allow.value), parse(deny.value))
})
if (ok) toasts.success('Lists saved. They apply to the next request.')
}
const resetOpen = ref(false)
async function resetLists() {
const ok = await lists.run(async () => {
fed.data.value = await api.resetFederationLists()
})
if (ok) {
resetOpen.value = false
toasts.success('Back to the lists in the config file')
}
}
// --- Destinations ---
const now = ref(Date.now())
const resetting = ref('')
async function resetDestination(name: string) {
resetting.value = name
try {
await api.resetDestination(name)
toasts.success(`Retrying ${name} now`)
now.value = Date.now()
dest.load()
} catch (e) {
toasts.error(e instanceof Error ? e.message : String(e))
} finally {
resetting.value = ''
}
}
const sorted = computed(() =>
[...(dest.data.value?.destinations ?? [])].sort(
(a, b) => b.failures - a.failures || a.server_name.localeCompare(b.server_name),
),
)
</script>
<template>
<PageHeader title="Federation" description="Whether this homeserver talks to others, and to which.">
<template #actions>
<WButton icon="refresh" :loading="fed.loading.value || dest.loading.value" @click="(fed.load(), dest.load())">
Refresh
</WButton>
</template>
</PageHeader>
<LoadState :loading="fed.loading.value" :error="fed.error.value" :has-data="!!f" @retry="fed.load" />
<template v-if="f">
<section class="wp-card mb-6 flex items-start gap-4 p-4" aria-labelledby="switch-h">
<div class="flex-1">
<h2 id="switch-h" class="subheading m-0 flex items-center gap-2">
Federation
<WBadge :tone="f.enabled ? 'success' : 'neutral'" :icon="f.enabled ? 'globe' : undefined">
{{ f.enabled ? 'On' : 'Off' }}
</WBadge>
</h2>
<p class="body mt-1 max-w-[65ch] text-ink-muted">
<template v-if="f.toggleable">
Off, this server neither sends to nor accepts from other homeservers. The change applies to the next
request, with no restart.
</template>
<template v-else>
Pinned by <code class="code">federation.allow_runtime_toggle</code> in the config file. Change it there and
restart to switch it here.
</template>
</p>
</div>
<WSwitch
:model-value="f.enabled"
label="Federation"
:disabled="!f.toggleable || toggle.busy.value"
@update:model-value="setEnabled"
/>
</section>
<section class="wp-card mb-6 flex flex-col gap-4 p-4" aria-labelledby="lists-h">
<div>
<h2 id="lists-h" class="subheading m-0 flex items-center gap-2">
Server lists
<WBadge v-if="f.lists_edited" tone="info">Edited here</WBadge>
<WBadge v-else tone="neutral">From config file</WBadge>
</h2>
<p class="body mt-1 max-w-[65ch] text-ink-muted">
One server name per line. With an allowlist, only those servers are reached; the denylist always wins.
Saved lists replace the config file’s until you reset them.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<WTextArea
v-model="allow"
mono
label="Allowlist"
hint="Empty: every server not on the denylist"
placeholder="friend.example"
spellcheck="false"
rows="6"
/>
<WTextArea
v-model="deny"
mono
label="Denylist"
hint="These servers are never reached"
placeholder="spam.example"
spellcheck="false"
rows="6"
/>
</div>
<WBanner v-if="lists.error.value" tone="danger">{{ lists.error.value }}</WBanner>
<div class="flex flex-wrap gap-2">
<WButton variant="primary" :disabled="!dirty" :loading="lists.busy.value" @click="saveLists">Save lists</WButton>
<WButton v-if="f.lists_edited" variant="ghost" :disabled="lists.busy.value" @click="resetOpen = true">
Use config file lists
</WButton>
</div>
</section>
</template>
<section aria-labelledby="dest-h">
<div class="mb-3 flex flex-wrap items-center gap-2">
<h2 id="dest-h" class="heading m-0 flex-1">Delivery to other servers</h2>
<WBadge v-if="dest.data.value" :tone="dest.data.value.running ? 'success' : 'neutral'">
Queue {{ dest.data.value.running ? 'running' : 'stopped' }}
</WBadge>
</div>
<WBanner v-if="dest.status.value === 404" tone="info" title="No federation on this server">
This homeserver started without a signing key, so it has no federation at all.
</WBanner>
<LoadState
v-else
:loading="dest.loading.value"
:error="dest.error.value"
:has-data="!!dest.data.value"
@retry="dest.load"
/>
<div v-if="dest.data.value" class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>Server</th>
<th>Health</th>
<th class="wp-num">Queued</th>
<th class="hidden md:table-cell">Last success</th>
<th><span class="wp-sr">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="d in sorted" :key="d.server_name">
<td><Mxid :value="d.server_name" /></td>
<td>
<WBadge v-if="d.failures === 0" tone="success" icon="shield-check">Healthy</WBadge>
<div v-else class="flex flex-col items-start gap-1">
<WBadge tone="warning" icon="shield-alert">{{ d.failures }} failed in a row</WBadge>
<span v-if="d.retry_at_ms" class="caption text-ink-muted">
Next try {{ formatRelative(d.retry_at_ms, now) }}
</span>
</div>
</td>
<td class="wp-num">
{{ formatNumber(d.queued_pdus) }}
<span class="caption block text-ink-muted">{{ formatNumber(d.queued_edus) }} EDUs</span>
</td>
<td class="hidden text-ink-muted md:table-cell">{{ formatRelative(d.last_success_ms, now) }}</td>
<td class="text-right">
<WButton
v-if="d.failures > 0"
size="sm"
variant="ghost"
icon="refresh"
:loading="resetting === d.server_name"
@click="resetDestination(d.server_name)"
>
Retry now
</WButton>
</td>
</tr>
<tr v-if="sorted.length === 0">
<td colspan="5" class="py-8 text-center text-ink-muted">
Nothing queued for other servers, and no failures.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<ConfirmDialog
v-model:open="resetOpen"
title="Use config file lists"
confirm-label="Use config file lists"
:danger="false"
:busy="lists.busy.value"
:error="lists.error.value"
@confirm="resetLists"
>
<p>The lists saved here are forgotten, and the allowlist and denylist from the config file apply again.</p>
</ConfirmDialog>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WCheckbox, WLogo, WTextField } from '@warp/ui'
import { errorMessage } from '@/lib/format'
import { useSession } from '@/stores/session'
const session = useSession()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const remember = ref(false)
const busy = ref(false)
const error = ref('')
async function submit() {
if (!username.value || !password.value) return
busy.value = true
error.value = ''
try {
await session.signIn(username.value, password.value, remember.value)
password.value = ''
const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') ? route.query.next : '/'
router.replace(next)
} catch (e) {
error.value = errorMessage(e)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="flex min-h-dvh items-center justify-center bg-surface-sunken px-4 py-12">
<div class="w-full max-w-[400px]">
<div class="mb-8 flex justify-center text-ink">
<WLogo :height="40" />
</div>
<form class="wp-card flex flex-col gap-4 p-6 shadow-raised" @submit.prevent="submit">
<div>
<h1 class="heading m-0">Sign in to the admin panel</h1>
<p class="body mt-1 text-ink-muted">Use an account with the admin flag on this homeserver.</p>
</div>
<WBanner v-if="session.endedReason && !error" tone="info">{{ session.endedReason }}</WBanner>
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
<WTextField
v-model="username"
wide
mono
label="Username"
hint="Your localpart, like root, or a full ID like @root:warp.example"
autocomplete="username"
autocapitalize="off"
spellcheck="false"
required
autofocus
/>
<WTextField
v-model="password"
wide
label="Password"
type="password"
autocomplete="current-password"
required
/>
<WCheckbox v-model="remember" label="Stay signed in on this browser" />
<WButton type="submit" variant="primary" :loading="busy" :disabled="!username || !password">Sign in</WButton>
</form>
<p class="small mt-4 text-center text-ink-muted">
No admin yet? On the server, run <code class="code">warp create-account --username root --admin</code>
</p>
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { WBanner, WButton, WTextArea, WTextField } from '@warp/ui'
import { sendNotice } from '@/api/admin'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { useAction } from '@/lib/useLoad'
import { useSession } from '@/stores/session'
const session = useSession()
const user = ref('')
const body = ref('')
const send = useAction()
const last = ref<{ user: string; room: string } | null>(null)
const target = computed(() => {
const v = user.value.trim()
if (!v) return ''
return v.startsWith('@') ? v : `@${v}:${session.serverName}`
})
async function submit() {
let room = ''
const to = target.value
const ok = await send.run(async () => {
room = (await sendNotice(to, body.value.trim())).room_id
})
if (ok) {
last.value = { user: to, room }
body.value = ''
}
}
</script>
<template>
<PageHeader
title="Server notices"
description="Messages from the server to one of its users, in a room they can read but not reply in."
/>
<WBanner v-if="send.error.value" tone="danger" class="mb-4">
{{ send.error.value }}
</WBanner>
<WBanner v-else-if="last" tone="success" title="Notice sent" class="mb-4">
<Mxid :value="last.user" /> will see it in their server notices room <Mxid :value="last.room" />.
</WBanner>
<form class="wp-card flex max-w-2xl flex-col gap-4 p-4" @submit.prevent="submit">
<WTextField
v-model="user"
wide
mono
label="User"
:hint="target && !user.trim().startsWith('@') ? target : 'A localpart or a full Matrix ID on this server'"
:placeholder="`@alice:${session.serverName}`"
autocomplete="off"
spellcheck="false"
required
/>
<WTextArea v-model="body" label="Message" placeholder="Maintenance at 22:00 UTC." required rows="5" />
<p class="small text-ink-muted">
The user doesn’t have to be online: the invite and the message wait for them. Needs
<code class="code">server_notices.enabled</code> and a signing key.
</p>
<div>
<WButton type="submit" variant="primary" icon="send" :loading="send.busy.value" :disabled="!target || !body.trim()">
Send notice
</WButton>
</div>
</form>
</template>
+269
View File
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { WBadge, WBanner, WButton, WDialog, WIconButton, WSwitch, WTextField } from '@warp/ui'
import * as api from '@/api/admin'
import type { RegistrationToken } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { formatDate, formatDateTime } from '@/lib/format'
import { useAction, useLoad } from '@/lib/useLoad'
import { useToasts } from '@/stores/toasts'
const toasts = useToasts()
const reg = useLoad(api.getRegistration)
const tokens = useLoad(api.listTokens)
onMounted(() => {
reg.load()
tokens.load()
})
const toggle = useAction()
async function setEnabled(enabled: boolean) {
const ok = await toggle.run(async () => {
reg.data.value = await api.setRegistration(enabled)
})
if (ok) toasts.success(enabled ? 'Registration is open' : 'Registration is closed')
else toasts.error(toggle.error.value)
}
// --- Token form, shared by create and edit ---
// <input type="datetime-local"> works in local time without a zone.
function toLocalInput(ms: number | null): string {
if (!ms) return ''
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const formOpen = ref(false)
const editing = ref<RegistrationToken | null>(null)
const form = ref({ token: '', uses: '', expiry: '' })
const save = useAction()
const usesError = computed(() => {
const v = form.value.uses.trim()
if (!v) return ''
return /^\d+$/.test(v) ? '' : 'A whole number, or empty for no limit.'
})
const tokenError = computed(() => {
const v = form.value.token.trim()
if (!v || editing.value) return ''
return /^[A-Za-z0-9._~-]{1,64}$/.test(v) ? '' : '1 to 64 of A–Z, a–z, 0–9 and . _ ~ -'
})
function startCreate() {
editing.value = null
form.value = { token: '', uses: '1', expiry: '' }
save.error.value = ''
formOpen.value = true
}
function startEdit(t: RegistrationToken) {
editing.value = t
form.value = {
token: t.token,
uses: t.uses_allowed === null ? '' : String(t.uses_allowed),
expiry: toLocalInput(t.expiry_time_ms),
}
save.error.value = ''
formOpen.value = true
}
let created = ''
async function submitForm() {
const uses = form.value.uses.trim() === '' ? null : Number(form.value.uses)
const expiry = form.value.expiry ? new Date(form.value.expiry).getTime() : null
const ok = await save.run(async () => {
if (editing.value) {
await api.updateToken(editing.value.token, { uses_allowed: uses, expiry_time_ms: expiry })
} else {
const t = await api.createToken({
token: form.value.token.trim() || undefined,
uses_allowed: uses,
expiry_time_ms: expiry,
})
created = t.token
}
})
if (ok) {
formOpen.value = false
toasts.success(editing.value ? `Updated ${editing.value.token}` : `Created ${created}`)
tokens.load()
}
}
// --- Delete ---
const toDelete = ref<RegistrationToken | null>(null)
const deleteOpen = ref(false)
const del = useAction()
function askDelete(t: RegistrationToken) {
toDelete.value = t
del.error.value = ''
deleteOpen.value = true
}
async function submitDelete() {
const t = toDelete.value
if (!t) return
const ok = await del.run(() => api.deleteToken(t.token))
if (ok) {
deleteOpen.value = false
toasts.success(`Deleted ${t.token}`)
tokens.load()
}
}
function uses(t: RegistrationToken) {
return t.uses_allowed === null ? `${t.completed} used, no limit` : `${t.completed} of ${t.uses_allowed} used`
}
</script>
<template>
<PageHeader title="Registration" description="Who can create an account on this homeserver." />
<LoadState :loading="reg.loading.value" :error="reg.error.value" :has-data="!!reg.data.value" @retry="reg.load" />
<section v-if="reg.data.value" class="wp-card mb-6 flex items-start gap-4 p-4" aria-labelledby="reg-h">
<div class="flex-1">
<h2 id="reg-h" class="subheading m-0 flex items-center gap-2">
Open registration
<WBadge :tone="reg.data.value.enabled ? 'warning' : 'neutral'">
{{ reg.data.value.enabled ? 'Open' : 'Closed' }}
</WBadge>
</h2>
<p class="body mt-1 max-w-[65ch] text-ink-muted">
<template v-if="reg.data.value.toggleable">
Open, anyone who can reach this server can sign up, or anyone with a token when tokens are required. Closed,
only you can create accounts, from Users.
</template>
<template v-else>Pinned in the config file. Change it there and restart to switch it here.</template>
</p>
</div>
<WSwitch
:model-value="reg.data.value.enabled"
label="Open registration"
:disabled="!reg.data.value.toggleable || toggle.busy.value"
@update:model-value="setEnabled"
/>
</section>
<section aria-labelledby="tokens-h">
<div class="mb-3 flex flex-wrap items-center gap-2">
<h2 id="tokens-h" class="heading m-0 flex-1">Registration tokens</h2>
<WButton variant="primary" icon="plus" @click="startCreate">Create token</WButton>
</div>
<WBanner v-if="tokens.data.value && !tokens.data.value.required" tone="info" class="mb-3">
Sign-up doesn’t ask for a token on this server, so these aren’t needed yet. Turn on
<code class="code">registration.require_token</code> to require one.
</WBanner>
<LoadState
:loading="tokens.loading.value"
:error="tokens.error.value"
:has-data="!!tokens.data.value"
@retry="tokens.load"
/>
<div v-if="tokens.data.value" class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>Token</th>
<th>Uses</th>
<th class="hidden md:table-cell">Expires</th>
<th class="hidden lg:table-cell">Created</th>
<th><span class="wp-sr">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tokens.data.value.registration_tokens" :key="t.token">
<td>
<div class="flex flex-col items-start gap-1">
<Mxid :value="t.token" copy />
<WBadge v-if="t.valid" tone="success" icon="shield-check">Valid</WBadge>
<WBadge v-else tone="neutral">Used up or expired</WBadge>
</div>
</td>
<td class="tabular-nums">{{ uses(t) }}</td>
<td class="hidden md:table-cell">{{ t.expiry_time_ms ? formatDateTime(t.expiry_time_ms) : 'Never' }}</td>
<td class="hidden text-ink-muted lg:table-cell">{{ formatDate(t.created_at_ms) }}</td>
<td class="text-right whitespace-nowrap">
<WIconButton icon="edit" :label="`Edit ${t.token}`" @click="startEdit(t)" />
<WIconButton icon="trash" :label="`Delete ${t.token}`" @click="askDelete(t)" />
</td>
</tr>
<tr v-if="tokens.data.value.registration_tokens.length === 0">
<td colspan="5" class="py-8 text-center text-ink-muted">
No tokens. Tokens in the config file work too, but aren’t listed here.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<WDialog
v-model:open="formOpen"
:title="editing ? 'Edit token' : 'Create token'"
:persistent="save.busy.value"
>
<form id="token-form" class="flex flex-col gap-4" @submit.prevent="submitForm">
<WTextField
v-if="!editing"
v-model="form.token"
wide
mono
label="Token"
:error="tokenError || undefined"
hint="Leave empty to generate 16 random characters"
autocomplete="off"
spellcheck="false"
/>
<p v-else class="body">Token <Mxid :value="editing.token" /></p>
<WTextField
v-model="form.uses"
wide
label="Uses allowed"
inputmode="numeric"
:error="usesError || undefined"
hint="Empty for no limit. Raising it makes a used-up token valid again."
autocomplete="off"
/>
<WTextField
v-model="form.expiry"
wide
label="Expires"
type="datetime-local"
hint="Empty for never. In your local time."
/>
<WBanner v-if="save.error.value" tone="danger">{{ save.error.value }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="save.busy.value" @click="formOpen = false">Cancel</WButton>
<WButton
type="submit"
form="token-form"
variant="primary"
:loading="save.busy.value"
:disabled="!!usesError || !!tokenError"
>
{{ editing ? 'Save token' : 'Create token' }}
</WButton>
</template>
</WDialog>
<ConfirmDialog
v-model:open="deleteOpen"
title="Delete token"
confirm-label="Delete token"
:busy="del.busy.value"
:error="del.error.value"
@confirm="submitDelete"
>
<p v-if="toDelete">
<Mxid :value="toDelete.token" /> stops working for new sign-ups. Accounts already made with it aren’t affected.
</p>
</ConfirmDialog>
</template>
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { WBadge, WBanner, WButton, WTextField } from '@warp/ui'
import * as api from '@/api/admin'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { formatDateTime, formatNumber } from '@/lib/format'
import { useAction, useLoad } from '@/lib/useLoad'
import { useSession } from '@/stores/session'
import { useToasts } from '@/stores/toasts'
const props = defineProps<{ id: string }>()
const router = useRouter()
const session = useSession()
const toasts = useToasts()
const room = useLoad(() => api.getRoom(props.id))
onMounted(room.load)
const r = computed(() => room.data.value)
const title = computed(() => r.value?.name || r.value?.canonical_alias || 'Unnamed room')
// --- Force leave ---
const leaveUser = ref('')
const leaveOpen = ref(false)
const leave = useAction()
const leaveTarget = computed(() => {
const v = leaveUser.value.trim()
if (!v) return ''
return v.startsWith('@') ? v : `@${v}:${session.serverName}`
})
async function submitLeave() {
const target = leaveTarget.value
const ok = await leave.run(() => api.forceLeave(props.id, target))
if (ok) {
leaveOpen.value = false
leaveUser.value = ''
toasts.success(`${target} left the room`)
room.load()
}
}
// --- Purge ---
const purgeOpen = ref(false)
const purge = useAction()
async function submitPurge() {
const ok = await purge.run(() => api.purgeRoom(props.id))
if (ok) {
purgeOpen.value = false
toasts.success(`Purged ${props.id}`)
router.replace({ name: 'rooms' })
}
}
</script>
<template>
<PageHeader :title="title" :back="{ to: '/rooms', label: 'Rooms' }">
<template #description>
<Mxid :value="id" copy />
</template>
<template v-if="r" #actions>
<WButton variant="danger" icon="trash" @click="(purge.error.value = ''), (purgeOpen = true)">Purge room</WButton>
</template>
</PageHeader>
<LoadState :loading="room.loading.value" :error="room.error.value" :has-data="!!r" @retry="room.load" />
<template v-if="r">
<WBanner
v-if="r.forward_extremities.length > 1"
tone="warning"
title="This room has forked"
class="mb-6"
>
Its event graph ends in {{ r.forward_extremities.length }} places instead of one. The next event sent to the room
will usually join them back together.
</WBanner>
<div class="grid gap-6 lg:grid-cols-2">
<section class="wp-card p-4" aria-labelledby="about-h">
<h2 id="about-h" class="subheading m-0 mb-3">About</h2>
<p v-if="r.topic" class="body mb-4 max-w-[65ch]">{{ r.topic }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<dt class="text-ink-muted">Creator</dt>
<dd class="m-0 min-w-0"><Mxid :value="r.creator" copy /></dd>
<dt class="text-ink-muted">Created</dt>
<dd class="m-0">{{ formatDateTime(r.created_at_ms) }}</dd>
<dt class="text-ink-muted">Room version</dt>
<dd class="m-0"><span class="mxid">{{ r.room_version }}</span></dd>
<dt class="text-ink-muted">Join rule</dt>
<dd class="m-0">{{ r.join_rule || '—' }}</dd>
<dt class="text-ink-muted">History</dt>
<dd class="m-0">{{ r.history_visibility || '—' }}</dd>
<dt class="text-ink-muted">Guests</dt>
<dd class="m-0">{{ r.guest_access || 'forbidden' }}</dd>
<dt class="text-ink-muted">Directory</dt>
<dd class="m-0">
<WBadge v-if="r.published" tone="info" icon="globe">Published</WBadge>
<span v-else>Not published</span>
</dd>
</dl>
</section>
<section class="flex flex-col gap-6">
<div class="wp-card p-4" aria-labelledby="members-h">
<h2 id="members-h" class="subheading m-0 mb-3">Members</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<p class="font-display text-[28px] leading-[34px] font-semibold tabular-nums">
{{ formatNumber(r.joined_members) }}
</p>
<p class="caption text-ink-muted">Joined</p>
</div>
<div>
<p class="font-display text-[28px] leading-[34px] font-semibold tabular-nums">
{{ formatNumber(r.invited_members) }}
</p>
<p class="caption text-ink-muted">Invited</p>
</div>
<div>
<p class="font-display text-[28px] leading-[34px] font-semibold tabular-nums">
{{ formatNumber(r.members) }}
</p>
<p class="caption text-ink-muted">Any membership</p>
</div>
</div>
</div>
<div class="wp-card p-4" aria-labelledby="aliases-h">
<h2 id="aliases-h" class="subheading m-0 mb-3">Aliases</h2>
<ul v-if="r.aliases.length" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="a in r.aliases" :key="a">
<Mxid :value="a" copy />
<WBadge v-if="a === r.canonical_alias" tone="success" class="ml-2">Main</WBadge>
</li>
</ul>
<p v-else class="text-ink-muted">No aliases.</p>
</div>
</section>
</div>
<section class="wp-card mt-6 p-4" aria-labelledby="leave-h">
<h2 id="leave-h" class="subheading m-0">Force a user to leave</h2>
<p class="body mt-1 mb-4 max-w-[65ch] text-ink-muted">
For a user stuck in a room whose power levels are broken or whose last moderator has gone. It skips the
room’s rules, but it’s an ordinary leave event, so the other members see it.
</p>
<form class="flex flex-col gap-3 sm:flex-row sm:items-start" @submit.prevent="(leave.error.value = ''), (leaveOpen = true)">
<WTextField
v-model="leaveUser"
class="flex-1"
mono
label="User"
:placeholder="`@alice:${session.serverName}`"
autocomplete="off"
spellcheck="false"
/>
<WButton type="submit" class="sm:mt-[22px]" :disabled="!leaveTarget">Make user leave</WButton>
</form>
</section>
<details class="mt-6">
<summary class="small cursor-pointer font-semibold text-ink-muted">Forward extremities</summary>
<ul class="m-0 mt-2 flex list-none flex-col gap-1 p-0">
<li v-for="e in r.forward_extremities" :key="e"><Mxid :value="e" copy /></li>
</ul>
</details>
</template>
<ConfirmDialog
v-model:open="leaveOpen"
title="Make user leave"
confirm-label="Make user leave"
:busy="leave.busy.value"
:error="leave.error.value"
@confirm="submitLeave"
>
<p><Mxid :value="leaveTarget" /> leaves <span class="body-strong">{{ title }}</span>. They can rejoin if the room lets them.</p>
</ConfirmDialog>
<ConfirmDialog
v-model:open="purgeOpen"
title="Purge room"
confirm-label="Purge room"
:type-to-confirm="id"
:busy="purge.busy.value"
:error="purge.error.value"
@confirm="submitPurge"
>
<p>
Every event, the state and the aliases of <span class="body-strong">{{ title }}</span> are deleted from this
server. There is no undo.
</p>
<p>The members aren’t told: there is no room left to tell them in.</p>
</ConfirmDialog>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBadge, WBanner, WIcon } from '@warp/ui'
import { listRooms } from '@/api/admin'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import Pager from '@/components/Pager.vue'
import { formatDate, formatNumber } from '@/lib/format'
import { useLoad } from '@/lib/useLoad'
const limit = 50
const route = useRoute()
const router = useRouter()
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
const offset = ref(Number(route.query.offset) || 0)
const rooms = useLoad(() => listRooms(search.value.trim(), limit, offset.value))
onMounted(rooms.load)
let timer = 0
watch(search, () => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
offset.value = 0
sync()
}, 250)
})
function go(o: number) {
offset.value = o
sync()
}
function sync() {
router.replace({ query: { q: search.value || undefined, offset: offset.value || undefined } })
rooms.load()
}
function open(roomId: string) {
router.push({ name: 'room', params: { id: roomId } })
}
</script>
<template>
<PageHeader title="Rooms" description="Every room this homeserver takes part in, biggest first." />
<div class="mb-4">
<label class="relative block max-w-md">
<span class="wp-sr">Search rooms</span>
<WIcon name="search" class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-ink-muted" :size="18" />
<input
v-model="search"
type="search"
class="wp-input w-full pl-10"
placeholder="Search by name, topic, alias or room ID"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<WBanner v-if="rooms.status.value === 503" tone="info" title="No rooms on this server" class="mb-4">
This homeserver started without a signing key, so it has no room server. Generate a key with
<code class="code">warp generate-keys</code> and restart it.
</WBanner>
<LoadState
v-else
:loading="rooms.loading.value"
:error="rooms.error.value"
:has-data="!!rooms.data.value"
@retry="rooms.load"
/>
<template v-if="rooms.data.value">
<div class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>Room</th>
<th class="wp-num">Members</th>
<th class="hidden md:table-cell">Access</th>
<th class="hidden lg:table-cell">Created</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in rooms.data.value.rooms"
:key="r.room_id"
class="wp-row-link"
tabindex="0"
@click="open(r.room_id)"
@keydown.enter="open(r.room_id)"
>
<td class="max-w-0 w-full">
<p class="body-strong flex items-center gap-1 truncate">
<WIcon name="room" :size="16" class="text-ink-muted" />
<span class="truncate">{{ r.name || r.canonical_alias || 'Unnamed room' }}</span>
</p>
<Mxid :value="r.canonical_alias || r.room_id" class="text-ink-muted" />
<p v-if="r.topic" class="small truncate text-ink-muted">{{ r.topic }}</p>
</td>
<td class="wp-num">{{ formatNumber(r.joined_members) }}</td>
<td class="hidden md:table-cell">
<div class="flex flex-wrap gap-1">
<WBadge v-if="r.published" tone="info" icon="globe">Directory</WBadge>
<WBadge :tone="r.join_rule === 'public' ? 'warning' : 'neutral'">{{ r.join_rule || 'invite' }}</WBadge>
</div>
</td>
<td class="hidden whitespace-nowrap text-ink-muted lg:table-cell">{{ formatDate(r.created_at_ms) }}</td>
</tr>
<tr v-if="rooms.data.value.rooms.length === 0">
<td colspan="4" class="py-10 text-center text-ink-muted">
{{ search ? `No rooms match “${search}”.` : 'No rooms yet.' }}
</td>
</tr>
</tbody>
</table>
</div>
<Pager :total="rooms.data.value.total" :limit="limit" :offset="offset" noun="rooms" @go="go" />
</template>
</template>
+477
View File
@@ -0,0 +1,477 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { WAvatar, WBadge, WBanner, WButton, WCheckbox, WDialog, WSwitch, WTextArea, WTextField } from '@warp/ui'
import * as api from '@/api/admin'
import type { Device } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import { formatBytes, formatDateTime, formatNumber, formatRelative, localpart, parseBytes } from '@/lib/format'
import { useAction, useLoad } from '@/lib/useLoad'
import { useSession } from '@/stores/session'
import { useToasts } from '@/stores/toasts'
const props = defineProps<{ id: string }>()
const session = useSession()
const toasts = useToasts()
const user = useLoad(() => api.getUser(props.id))
const devices = useLoad(() => api.listDevices(props.id))
onMounted(() => {
user.load()
devices.load()
})
const u = computed(() => user.data.value)
const isSelf = computed(() => props.id === session.userId)
const name = computed(() => u.value?.display_name || localpart(props.id))
// --- Admin flag ---
const adminAction = useAction()
async function toggleAdmin(value: boolean) {
const ok = await adminAction.run(async () => {
user.data.value = await api.setAdmin(props.id, value)
})
if (ok) toasts.success(value ? `${props.id} is now an admin` : `${props.id} is no longer an admin`)
else toasts.error(adminAction.error.value)
}
// --- Password ---
const pwOpen = ref(false)
const pw = ref({ password: '', logout: true })
const pwAction = useAction()
function startPassword() {
pw.value = { password: '', logout: true }
pwAction.error.value = ''
pwOpen.value = true
}
async function submitPassword() {
const ok = await pwAction.run(async () => {
user.data.value = await api.resetPassword(props.id, pw.value.password, pw.value.logout)
})
if (ok) {
pwOpen.value = false
toasts.success(`Password reset for ${props.id}`)
if (pw.value.logout) devices.load()
}
}
// --- Deactivate / reactivate ---
const deactivateOpen = ref(false)
const erase = ref(false)
const deactivate = useAction()
async function submitDeactivate() {
const ok = await deactivate.run(async () => {
user.data.value = await api.deactivateUser(props.id, erase.value)
})
if (ok) {
deactivateOpen.value = false
toasts.success(`Deactivated ${props.id}`)
devices.load()
}
}
const reactivate = useAction()
async function doReactivate() {
const ok = await reactivate.run(async () => {
user.data.value = await api.reactivateUser(props.id)
})
if (ok) toasts.success(`${props.id} can sign in again`)
else toasts.error(reactivate.error.value)
}
// --- Devices ---
const deviceToDelete = ref<Device | null>(null)
const deleteDeviceOpen = ref(false)
const deleteAllOpen = ref(false)
const deviceAction = useAction()
function askDeleteDevice(d: Device) {
deviceToDelete.value = d
deviceAction.error.value = ''
deleteDeviceOpen.value = true
}
async function submitDeleteDevice() {
const d = deviceToDelete.value
if (!d) return
const ok = await deviceAction.run(() => api.deleteDevice(props.id, d.device_id))
if (ok) {
deleteDeviceOpen.value = false
toasts.success(`Signed out ${d.display_name || d.device_id}`)
devices.load()
user.load()
}
}
async function submitDeleteAll() {
const ok = await deviceAction.run(() => api.deleteAllDevices(props.id))
if (ok) {
deleteAllOpen.value = false
toasts.success(`Signed out every session of ${props.id}`)
devices.load()
user.load()
}
}
// --- Media quota ---
const quotaOpen = ref(false)
const quotaInput = ref('')
const quotaAction = useAction()
const quotaParsed = computed(() => parseBytes(quotaInput.value))
const quotaUse = computed(() => {
const m = u.value?.media
if (!m || !m.quota_bytes) return null
return Math.min(100, Math.round((m.bytes / m.quota_bytes) * 100))
})
function startQuota() {
const m = u.value?.media
quotaInput.value = m && m.quota_bytes ? formatBytes(m.quota_bytes) : '0'
quotaAction.error.value = ''
quotaOpen.value = true
}
async function submitQuota() {
if (quotaParsed.value === null) return
const bytes = quotaParsed.value
const ok = await quotaAction.run(async () => {
const media = await api.setQuota(props.id, bytes)
if (user.data.value) user.data.value = { ...user.data.value, media }
})
if (ok) {
quotaOpen.value = false
toasts.success(bytes ? `Quota set to ${formatBytes(bytes)}` : 'Quota removed: no limit for this user')
}
}
async function resetQuota() {
const ok = await quotaAction.run(async () => {
const media = await api.resetQuota(props.id)
if (user.data.value) user.data.value = { ...user.data.value, media }
})
if (ok) {
quotaOpen.value = false
toasts.success('Back on the configured quota')
}
}
// --- Server notice ---
const noticeOpen = ref(false)
const noticeBody = ref('')
const notice = useAction()
function startNotice() {
noticeBody.value = ''
notice.error.value = ''
noticeOpen.value = true
}
async function submitNotice() {
const ok = await notice.run(() => api.sendNotice(props.id, noticeBody.value.trim()))
if (ok) {
noticeOpen.value = false
toasts.success(`Notice sent to ${props.id}`)
}
}
</script>
<template>
<PageHeader :title="name" :back="{ to: '/users', label: 'Users' }">
<template #title>
<span class="flex items-center gap-3">
<WAvatar :name="name" :size="40" />
<span class="min-w-0 truncate">{{ name }}</span>
</span>
</template>
<template #description>
<Mxid :value="id" copy />
</template>
<template v-if="u" #actions>
<WButton icon="megaphone" :disabled="u.deactivated" @click="startNotice">Send notice</WButton>
<WButton icon="key" @click="startPassword">Reset password</WButton>
<WButton v-if="u.deactivated" variant="primary" :loading="reactivate.busy.value" @click="doReactivate">
Reactivate
</WButton>
<WButton
v-else
variant="danger"
icon="shield-alert"
:disabled="isSelf"
:title="isSelf ? 'You can’t deactivate yourself' : undefined"
@click="(erase = false), (deactivate.error.value = ''), (deactivateOpen = true)"
>
Deactivate
</WButton>
</template>
</PageHeader>
<LoadState :loading="user.loading.value" :error="user.error.value" :has-data="!!u" @retry="user.load" />
<template v-if="u">
<WBanner v-if="u.deactivated" tone="danger" title="This account is deactivated" class="mb-6">
It can’t sign in, and it has left every room. Reactivating lets it sign in again; the rooms and sessions don’t
come back.
</WBanner>
<div class="grid gap-6 lg:grid-cols-3">
<section class="wp-card flex flex-col gap-4 p-4 lg:col-span-1" aria-labelledby="account-h">
<h2 id="account-h" class="subheading m-0">Account</h2>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<dt class="text-ink-muted">Status</dt>
<dd class="m-0">
<WBadge v-if="u.deactivated" tone="danger" icon="shield-alert">Deactivated</WBadge>
<WBadge v-else tone="success" icon="shield-check">Active</WBadge>
</dd>
<dt class="text-ink-muted">Created</dt>
<dd class="m-0">{{ formatDateTime(u.created_at_ms) }}</dd>
<dt class="text-ink-muted">Sessions</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(u.devices) }}</dd>
<template v-if="u.joined_rooms !== undefined">
<dt class="text-ink-muted">Rooms</dt>
<dd class="m-0 tabular-nums">{{ formatNumber(u.joined_rooms) }} joined</dd>
</template>
</dl>
<div class="flex items-start gap-3 border-t border-line pt-4">
<div class="flex-1">
<p class="body-strong" id="admin-label">Admin</p>
<p class="small text-ink-muted">
{{
isSelf
? 'You can’t remove your own admin flag. Another admin can.'
: 'Admins can use this panel and the admin API.'
}}
</p>
</div>
<WSwitch
:model-value="u.admin"
label="Admin"
:disabled="isSelf || adminAction.busy.value"
@update:model-value="toggleAdmin"
/>
</div>
</section>
<section class="wp-card flex flex-col gap-3 p-4 lg:col-span-2" aria-labelledby="media-h">
<div class="flex items-center gap-2">
<h2 id="media-h" class="subheading m-0 flex-1">Media</h2>
<WButton size="sm" icon="edit" @click="startQuota">Change quota</WButton>
</div>
<p class="body">
<span class="body-strong tabular-nums">{{ formatBytes(u.media.bytes) }}</span>
in {{ formatNumber(u.media.uploads) }} uploads<template v-if="u.media.quota_bytes">
of a <span class="body-strong">{{ formatBytes(u.media.quota_bytes) }}</span> quota</template
><template v-else>, no quota</template>
<WBadge v-if="u.media.custom_quota" tone="info" class="ml-1">Set for this user</WBadge>
</p>
<div
v-if="quotaUse !== null"
class="h-2 overflow-hidden rounded-full bg-line"
role="progressbar"
:aria-valuenow="quotaUse"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Quota used"
>
<div
:class="['h-full rounded-full', quotaUse >= 90 ? 'bg-danger' : quotaUse >= 75 ? 'bg-warning' : 'bg-teal']"
:style="{ width: `${quotaUse}%` }"
/>
</div>
<p v-if="quotaUse !== null && quotaUse >= 100" class="small flex items-center gap-1 text-danger">
Over quota: further uploads are refused until this user is back under it.
</p>
</section>
</div>
<section class="mt-6" aria-labelledby="devices-h">
<div class="mb-3 flex items-center gap-2">
<h2 id="devices-h" class="heading m-0 flex-1">Sessions</h2>
<WButton
size="sm"
variant="danger"
icon="logout"
:disabled="!devices.data.value?.devices.length"
@click="(deviceAction.error.value = ''), (deleteAllOpen = true)"
>
Sign out all
</WButton>
</div>
<LoadState
:loading="devices.loading.value"
:error="devices.error.value"
:has-data="!!devices.data.value"
@retry="devices.load"
/>
<div v-if="devices.data.value" class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>Session</th>
<th class="hidden md:table-cell">Last seen</th>
<th class="hidden lg:table-cell">Created</th>
<th><span class="wp-sr">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices.data.value.devices" :key="d.device_id">
<td>
<p class="body-strong">{{ d.display_name || 'Unnamed session' }}</p>
<Mxid :value="d.device_id" class="text-ink-muted" />
<p v-if="d.user_agent" class="caption truncate text-ink-muted" :title="d.user_agent">{{ d.user_agent }}</p>
</td>
<td class="hidden md:table-cell">
<p>{{ formatRelative(d.last_seen_ms) }}</p>
<p v-if="d.last_seen_ip" class="mxid text-ink-muted">{{ d.last_seen_ip }}</p>
</td>
<td class="hidden text-ink-muted lg:table-cell">{{ formatDateTime(d.created_at_ms) }}</td>
<td class="text-right">
<WButton size="sm" variant="ghost" icon="logout" @click="askDeleteDevice(d)">Sign out</WButton>
</td>
</tr>
<tr v-if="devices.data.value.devices.length === 0">
<td colspan="4" class="py-8 text-center text-ink-muted">No sessions. This user isn’t signed in anywhere.</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<!-- Dialogs -->
<WDialog v-model:open="pwOpen" title="Reset password" :persistent="pwAction.busy.value">
<form id="pw-form" class="flex flex-col gap-4" @submit.prevent="submitPassword">
<p class="body text-ink-muted">Set a new password for <Mxid :value="id" />.</p>
<WTextField
v-model="pw.password"
wide
label="New password"
type="password"
autocomplete="new-password"
required
/>
<WCheckbox v-model="pw.logout" label="Sign out every session" />
<p class="small -mt-2 text-ink-muted">
Usually right: a reset is often because someone else has the account.
</p>
<WBanner v-if="pwAction.error.value" tone="danger">{{ pwAction.error.value }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="pwAction.busy.value" @click="pwOpen = false">Cancel</WButton>
<WButton type="submit" form="pw-form" variant="primary" :loading="pwAction.busy.value" :disabled="!pw.password">
Reset password
</WButton>
</template>
</WDialog>
<ConfirmDialog
v-model:open="deactivateOpen"
title="Deactivate account"
confirm-label="Deactivate account"
:type-to-confirm="id"
:busy="deactivate.busy.value"
:error="deactivate.error.value"
@confirm="submitDeactivate"
>
<p>
Every session of <Mxid :value="id" /> is signed out at once, and the account leaves every room, rejects its
invites and withdraws its knocks.
</p>
<p>The username stays taken. You can reactivate the account later, but the rooms and sessions don’t come back.</p>
<WCheckbox v-model="erase" label="Also erase the display name and avatar" />
</ConfirmDialog>
<ConfirmDialog
v-model:open="deleteDeviceOpen"
title="Sign out session"
confirm-label="Sign out session"
:busy="deviceAction.busy.value"
:error="deviceAction.error.value"
@confirm="submitDeleteDevice"
>
<p v-if="deviceToDelete">
<span class="body-strong">{{ deviceToDelete.display_name || 'This session' }}</span>
(<Mxid :value="deviceToDelete.device_id" />) is signed out, and its encryption keys and pushers go with it.
</p>
</ConfirmDialog>
<ConfirmDialog
v-model:open="deleteAllOpen"
title="Sign out every session"
confirm-label="Sign out all"
:busy="deviceAction.busy.value"
:error="deviceAction.error.value"
@confirm="submitDeleteAll"
>
<p>
Every session of <Mxid :value="id" /> is signed out, with its encryption keys and pushers. Messages in
end-to-end encrypted rooms may become unreadable unless the user has key backup.
</p>
</ConfirmDialog>
<WDialog v-model:open="quotaOpen" title="Media quota" :persistent="quotaAction.busy.value">
<form id="quota-form" class="flex flex-col gap-4" @submit.prevent="submitQuota">
<WTextField
v-model="quotaInput"
wide
label="Quota"
:error="quotaInput && quotaParsed === null ? 'Use a size like 500 MB or 2 GB, or 0 for no limit.' : undefined"
:hint="
quotaParsed !== null ? (quotaParsed === 0 ? 'No limit' : `${formatNumber(quotaParsed)} bytes`) : undefined
"
autocomplete="off"
/>
<p class="small text-ink-muted">
A quota below what the user has already uploaded deletes nothing. It stops further uploads until they’re back
under it.
</p>
<WBanner v-if="quotaAction.error.value" tone="danger">{{ quotaAction.error.value }}</WBanner>
</form>
<template #actions>
<WButton
v-if="u?.media.custom_quota"
variant="ghost"
class="mr-auto"
:disabled="quotaAction.busy.value"
@click="resetQuota"
>
Use configured quota
</WButton>
<WButton variant="ghost" :disabled="quotaAction.busy.value" @click="quotaOpen = false">Cancel</WButton>
<WButton
type="submit"
form="quota-form"
variant="primary"
:loading="quotaAction.busy.value"
:disabled="quotaParsed === null"
>
Set quota
</WButton>
</template>
</WDialog>
<WDialog v-model:open="noticeOpen" title="Send server notice" :persistent="notice.busy.value">
<form id="notice-form" class="flex flex-col gap-4" @submit.prevent="submitNotice">
<p class="body text-ink-muted">
A message from the server to <Mxid :value="id" />, in their server notices room. They can’t reply.
</p>
<WTextArea v-model="noticeBody" label="Message" required />
<WBanner v-if="notice.error.value" tone="danger">{{ notice.error.value }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="notice.busy.value" @click="noticeOpen = false">Cancel</WButton>
<WButton
type="submit"
form="notice-form"
variant="primary"
icon="send"
:loading="notice.busy.value"
:disabled="!noticeBody.trim()"
>
Send notice
</WButton>
</template>
</WDialog>
</template>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WAvatar, WBadge, WBanner, WButton, WCheckbox, WDialog, WIcon, WTextField } from '@warp/ui'
import { createUser, listUsers } from '@/api/admin'
import LoadState from '@/components/LoadState.vue'
import Mxid from '@/components/Mxid.vue'
import PageHeader from '@/components/PageHeader.vue'
import Pager from '@/components/Pager.vue'
import { formatDate, localpart } from '@/lib/format'
import { useAction, useLoad } from '@/lib/useLoad'
import { useSession } from '@/stores/session'
import { useToasts } from '@/stores/toasts'
const limit = 50
const route = useRoute()
const router = useRouter()
const session = useSession()
const toasts = useToasts()
// Search and page live in the URL, so Back returns to the same list.
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
const offset = ref(Number(route.query.offset) || 0)
const users = useLoad(() => listUsers(search.value.trim(), limit, offset.value))
onMounted(users.load)
let timer = 0
watch(search, () => {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
offset.value = 0
sync()
}, 250)
})
function go(o: number) {
offset.value = o
sync()
}
function sync() {
router.replace({ query: { q: search.value || undefined, offset: offset.value || undefined } })
users.load()
}
function open(userId: string) {
router.push({ name: 'user', params: { id: userId } })
}
// --- Create user ---
const createOpen = ref(false)
const form = ref({ username: '', password: '', display_name: '', admin: false })
const create = useAction()
function startCreate() {
form.value = { username: '', password: '', display_name: '', admin: false }
create.error.value = ''
createOpen.value = true
}
async function submitCreate() {
let created = ''
const ok = await create.run(async () => {
const u = await createUser({
username: form.value.username.trim(),
password: form.value.password,
display_name: form.value.display_name.trim() || undefined,
admin: form.value.admin,
})
created = u.user_id
})
if (ok) {
createOpen.value = false
toasts.success(`Created ${created}`)
open(created)
}
}
</script>
<template>
<PageHeader title="Users" description="Accounts on this homeserver.">
<template #actions>
<WButton variant="primary" icon="plus" @click="startCreate">Create user</WButton>
</template>
</PageHeader>
<div class="mb-4">
<label class="relative block max-w-md">
<span class="wp-sr">Search users</span>
<WIcon name="search" class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-ink-muted" :size="18" />
<input
v-model="search"
type="search"
class="wp-input w-full pl-10"
placeholder="Search by username or display name"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<LoadState :loading="users.loading.value" :error="users.error.value" :has-data="!!users.data.value" @retry="users.load" />
<template v-if="users.data.value">
<div class="wp-table-wrap">
<table class="wp-table">
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th class="hidden md:table-cell">Created</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users.data.value.users"
:key="u.user_id"
class="wp-row-link"
tabindex="0"
@click="open(u.user_id)"
@keydown.enter="open(u.user_id)"
>
<td>
<div class="flex min-w-0 items-center gap-3">
<WAvatar :name="u.display_name || localpart(u.user_id)" :size="32" />
<div class="min-w-0">
<p class="body-strong truncate">
{{ u.display_name || localpart(u.user_id) }}
<span v-if="u.user_id === session.userId" class="caption text-ink-muted">(you)</span>
</p>
<Mxid :value="u.user_id" class="text-ink-muted" />
</div>
</div>
</td>
<td>
<div class="flex flex-wrap gap-1">
<WBadge v-if="u.admin" tone="brand" icon="shield-check">Admin</WBadge>
<WBadge v-if="u.deactivated" tone="danger" icon="shield-alert">Deactivated</WBadge>
<WBadge v-if="!u.admin && !u.deactivated" tone="success">Active</WBadge>
</div>
</td>
<td class="hidden text-ink-muted md:table-cell">{{ formatDate(u.created_at_ms) }}</td>
</tr>
<tr v-if="users.data.value.users.length === 0">
<td colspan="3" class="py-10 text-center text-ink-muted">
{{ search ? `No users match “${search}”.` : 'No users yet.' }}
</td>
</tr>
</tbody>
</table>
</div>
<Pager :total="users.data.value.total" :limit="limit" :offset="offset" noun="users" @go="go" />
</template>
<WDialog v-model:open="createOpen" title="Create user" :persistent="create.busy.value">
<form id="create-user" class="flex flex-col gap-4" @submit.prevent="submitCreate">
<p class="body text-ink-muted">
This works with registration closed: it’s your action as an admin, not a public sign-up.
</p>
<WTextField
v-model="form.username"
wide
mono
label="Username"
:hint="`The localpart: ${form.username ? `@${form.username.toLowerCase()}:${session.serverName}` : 'lower-case letters, digits and . _ = - /'}`"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
required
/>
<WTextField v-model="form.display_name" wide label="Display name" hint="Optional" autocomplete="off" />
<WTextField
v-model="form.password"
wide
label="Password"
type="password"
autocomplete="new-password"
required
/>
<WCheckbox v-model="form.admin" label="Make this user an admin" />
<WBanner v-if="create.error.value" tone="danger">{{ create.error.value }}</WBanner>
</form>
<template #actions>
<WButton variant="ghost" :disabled="create.busy.value" @click="createOpen = false">Cancel</WButton>
<WButton
type="submit"
form="create-user"
variant="primary"
:loading="create.busy.value"
:disabled="!form.username.trim() || !form.password"
>
Create user
</WButton>
</template>
</WDialog>
</template>
+18
View File
@@ -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"]
}
+29
View File
@@ -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 },
},
},
})
+62
View File
@@ -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 `<html>` (`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 `<dialog>` |
| `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.
+26
View File
@@ -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"
}
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** Display name or Matrix ID; the initial is taken from it. */
name: string
src?: string
/** px */
size?: number
presence?: 'online' | 'away' | 'busy' | 'offline'
}>(),
{ src: undefined, size: 40, presence: undefined },
)
const initial = computed(() => {
const n = props.name.replace(/^[@#!+]/, '').trim()
return (Array.from(n)[0] ?? '?').toUpperCase()
})
const dot = computed(() => Math.max(8, Math.round(props.size / 4)))
</script>
<template>
<span
class="wp-avatar"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.42)}px` }"
:title="name"
>
<img v-if="src" :src="src" alt="" />
<span v-else aria-hidden="true">{{ initial }}</span>
<span
v-if="presence"
:class="['wp-presence', `wp-presence-${presence}`]"
:style="{ width: `${dot}px`, height: `${dot}px` }"
/>
</span>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
withDefaults(
defineProps<{
tone?: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'info'
/** Unread count; renders a round counter, 99+ above 99. */
count?: number
icon?: IconName
}>(),
{ tone: 'neutral', count: undefined, icon: undefined },
)
</script>
<template>
<span :class="['wp-badge', `wp-badge-${tone}`, count !== undefined && 'wp-badge-count']">
<template v-if="count !== undefined">{{ count > 99 ? '99+' : count }}</template>
<template v-else>
<WIcon v-if="icon" :name="icon" :size="14" />
<slot />
</template>
</span>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
const props = withDefaults(
defineProps<{
tone?: 'info' | 'success' | 'warning' | 'danger'
title?: string
/** Defaults to the tone's own icon: status is never colour alone. */
icon?: IconName
}>(),
{ tone: 'info', title: undefined, icon: undefined },
)
const toneIcon: Record<'info' | 'success' | 'warning' | 'danger', IconName> = {
info: 'server',
success: 'shield-check',
warning: 'shield-alert',
danger: 'shield-alert',
}
const shown = computed(() => props.icon ?? toneIcon[props.tone])
</script>
<template>
<div :class="['wp-banner', `wp-banner-${tone}`]" :role="tone === 'danger' ? 'alert' : 'status'">
<WIcon :name="shown" />
<div class="wp-banner-body">
<p v-if="title" class="wp-banner-title">{{ title }}</p>
<p v-if="$slots.default" class="wp-banner-text"><slot /></p>
</div>
<div v-if="$slots.action" class="wp-banner-action"><slot name="action" /></div>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import WIcon from './WIcon.vue'
import WSpinner from './WSpinner.vue'
import type { IconName } from '../icons'
withDefaults(
defineProps<{
/** One primary action per view. `danger` is the soft destructive style,
* `danger-solid` the final confirmation inside a dialog. */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid'
size?: 'md' | 'sm'
icon?: IconName
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
/** Shows a spinner and disables the button while an action runs. */
loading?: boolean
}>(),
{ variant: 'secondary', size: 'md', icon: undefined, type: 'button', disabled: false, loading: false },
)
</script>
<template>
<button
:type="type"
:class="['wp-btn', `wp-btn-${size}`, `wp-btn-${variant}`]"
:disabled="disabled || loading"
:aria-busy="loading || undefined"
>
<WSpinner v-if="loading" :size="size === 'sm' ? 14 : 16" />
<WIcon v-else-if="icon" :name="icon" :size="size === 'sm' ? 16 : 18" />
<slot />
</button>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ label: string; disabled?: boolean }>()
const model = defineModel<boolean>({ default: false })
</script>
<template>
<label class="wp-check">
<input v-model="model" type="checkbox" :disabled="disabled" />
<span>{{ label }}</span>
</label>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import WIconButton from './WIconButton.vue'
// A modal on the native <dialog>: the browser traps focus, closes it on
// Escape and gives focus back to whatever opened it.
const props = withDefaults(
defineProps<{
title: string
/** Wider body for tables and long forms. */
wide?: boolean
/** Keep the dialog open on Escape, e.g. while a request is running. */
persistent?: boolean
}>(),
{ wide: false, persistent: false },
)
const open = defineModel<boolean>('open', { default: false })
const el = ref<HTMLDialogElement>()
const titleId = useId()
watch(
open,
async (value) => {
await nextTick()
const d = el.value
if (!d) return
if (value && !d.open) d.showModal()
if (!value && d.open) d.close()
},
{ immediate: true },
)
function onCancel(e: Event) {
if (props.persistent) e.preventDefault()
else open.value = false
}
function onClose() {
open.value = false
}
onBeforeUnmount(() => {
if (el.value?.open) el.value.close()
})
</script>
<template>
<dialog
ref="el"
class="wp-dialog"
:style="wide ? { maxWidth: '720px' } : undefined"
:aria-labelledby="titleId"
@cancel="onCancel"
@close="onClose"
>
<template v-if="open">
<div class="wp-dialog-head">
<h2 :id="titleId" class="wp-dialog-title">{{ title }}</h2>
<WIconButton icon="close" label="Close" :disabled="persistent" @click="open = false" />
</div>
<slot />
<div v-if="$slots.actions" class="wp-dialog-actions">
<slot name="actions" />
</div>
</template>
</dialog>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { iconBody, type IconName } from '../icons'
const props = withDefaults(
defineProps<{
name: IconName
/** px */
size?: number
/** Accessible name; leave out for a decorative icon. */
label?: string
}>(),
{ size: 20, label: undefined },
)
const body = computed(() => iconBody(props.name))
</script>
<template>
<svg
class="wp-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
:width="size"
:height="size"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
:role="label ? 'img' : undefined"
:aria-label="label"
:aria-hidden="label ? undefined : 'true'"
focusable="false"
v-html="body"
/>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import WIcon from './WIcon.vue'
import type { IconName } from '../icons'
withDefaults(
defineProps<{
icon: IconName
/** Required accessible name, also the tooltip. */
label: string
variant?: 'ghost' | 'solid'
type?: 'button' | 'submit'
disabled?: boolean
}>(),
{ variant: 'ghost', type: 'button', disabled: false },
)
</script>
<template>
<button
:type="type"
:class="['wp-iconbtn', variant === 'solid' && 'wp-iconbtn-solid']"
:aria-label="label"
:title="label"
:disabled="disabled"
>
<WIcon :name="icon" />
</button>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { symbolPath, symbolSmallPath, wordmarkPath, wordmarkTransform } from '../logos'
const props = withDefaults(
defineProps<{
/** "horizontal" (symbol + wordmark, the default lockup) or "symbol". */
variant?: 'horizontal' | 'symbol'
/** Rendered height in px. */
height?: number
/** One colour: the symbol takes CSS `color` too. */
mono?: boolean
}>(),
{ variant: 'horizontal', height: 32, mono: false },
)
const width = computed(() => (props.variant === 'horizontal' ? props.height * 3 : props.height))
// Under 32px the small cut keeps the gap between the frames open.
const symbol = computed(() => (props.variant === 'symbol' && props.height < 32 ? symbolSmallPath : symbolPath))
const symbolFill = computed(() => (props.mono ? 'currentColor' : 'var(--teal)'))
</script>
<template>
<svg
class="wp-logo"
xmlns="http://www.w3.org/2000/svg"
:viewBox="variant === 'horizontal' ? '0 0 768 256' : '0 0 256 256'"
:width="width"
:height="height"
role="img"
aria-label="Warp"
>
<path :fill="symbolFill" fill-rule="evenodd" :d="symbol" />
<path v-if="variant === 'horizontal'" fill="currentColor" :transform="wordmarkTransform" :d="wordmarkPath" />
</svg>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
label: string
options: { value: string; label: string }[]
hint?: string
}>()
const model = defineModel<string>({ default: '' })
const id = useId()
</script>
<template>
<div class="wp-field">
<label class="wp-field-label" :for="id">{{ label }}</label>
<select :id="id" v-model="model" class="wp-select" v-bind="$attrs">
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<p v-if="hint" class="wp-field-hint">{{ hint }}</p>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
withDefaults(defineProps<{ size?: number; label?: string }>(), { size: 20, label: undefined })
</script>
<template>
<span
class="wp-spinner"
:style="{ width: `${size}px`, height: `${size}px` }"
:role="label ? 'status' : undefined"
:aria-label="label"
:aria-hidden="label ? undefined : 'true'"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{
/** Accessible name when no visible label is tied to the switch. */
label?: string
disabled?: boolean
}>()
const model = defineModel<boolean>({ default: false })
</script>
<template>
<button
type="button"
role="switch"
class="wp-switch"
:aria-checked="model ? 'true' : 'false'"
:aria-label="label"
:disabled="disabled"
@click="model = !model"
/>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useId } from 'vue'
import WIcon from './WIcon.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
label: string
hint?: string
error?: string
mono?: boolean
}>(),
{ hint: undefined, error: undefined, mono: false },
)
const model = defineModel<string>({ default: '' })
const id = useId()
</script>
<template>
<div :class="['wp-field wp-field-wide', error && 'wp-field-error']">
<label class="wp-field-label" :for="id">{{ label }}</label>
<textarea
:id="id"
v-model="model"
:class="['wp-textarea', mono && 'wp-input-mono']"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error || hint ? `${id}-hint` : undefined"
v-bind="$attrs"
/>
<p v-if="error || hint" :id="`${id}-hint`" class="wp-field-hint">
<WIcon v-if="error" name="shield-alert" :size="14" />
{{ error || hint }}
</p>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useId } from 'vue'
import WIcon from './WIcon.vue'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
label: string
hint?: string
error?: string
/** Monospace input for Matrix IDs, server names, keys. */
mono?: boolean
/** Fill the container instead of stopping at 360px. */
wide?: boolean
}>(),
{ hint: undefined, error: undefined, mono: false, wide: false },
)
const model = defineModel<string | number>({ default: '' })
const id = useId()
</script>
<template>
<div :class="['wp-field', error && 'wp-field-error', wide && 'wp-field-wide']">
<label class="wp-field-label" :for="id">{{ label }}</label>
<input
:id="id"
v-model="model"
:class="['wp-input', mono && 'wp-input-mono']"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error || hint ? `${id}-hint` : undefined"
v-bind="$attrs"
/>
<p v-if="error || hint" :id="`${id}-hint`" class="wp-field-hint">
<WIcon v-if="error" name="shield-alert" :size="14" />
{{ error || hint }}
</p>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>attach</title><path d="M19.5 11.2l-7.6 7.6a4.8 4.8 0 0 1-6.8-6.8l8.1-8.1a3.2 3.2 0 0 1 4.5 4.5l-8 8a1.6 1.6 0 0 1-2.3-2.3l7.4-7.4"/></svg>

After

Width:  |  Height:  |  Size: 306 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>bell</title><path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>check</title><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>

After

Width:  |  Height:  |  Size: 221 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>chevron-left</title><path d="M14.5 6 8.5 12l6 6"/></svg>

After

Width:  |  Height:  |  Size: 225 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>chevron-right</title><path d="M9.5 6l6 6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 222 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>close</title><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg>

After

Width:  |  Height:  |  Size: 241 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>copy</title><rect x="8.5" y="8.5" width="11" height="11" rx="2"/><path d="M15.5 8.5V6a1.5 1.5 0 0 0-1.5-1.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/></svg>

After

Width:  |  Height:  |  Size: 334 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>edit</title><path d="M15 5.5l3.5 3.5"/><path d="M5 19l1-4.5L16.5 4a1.4 1.4 0 0 1 2 0l1.5 1.5a1.4 1.4 0 0 1 0 2L9.5 18 5 19z"/></svg>

After

Width:  |  Height:  |  Size: 301 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>globe</title><circle cx="12" cy="12" r="8"/><path d="M4 12h16"/><path d="M12 4c2.2 2.3 3.3 5 3.3 8s-1.1 5.7-3.3 8c-2.2-2.3-3.3-5-3.3-8s1.1-5.7 3.3-8z"/></svg>

After

Width:  |  Height:  |  Size: 327 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>history</title><path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3"/><path d="M4.5 4.5v4h4"/><path d="M12 8v4l2.5 2.5"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>home</title><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v9.5a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V9"/><path d="M10 20v-5h4v5"/></svg>

After

Width:  |  Height:  |  Size: 310 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>image</title><rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.5"/><path d="M20 15.5l-4.5-4.5L7 19"/></svg>

After

Width:  |  Height:  |  Size: 305 B

+60
View File
@@ -0,0 +1,60 @@
// The stroke icons: the brand package's 20 plus the ones drawn for the apps
// on the same grid (24px, 1.75 stroke, round caps, currentColor).
export type IconName =
| 'attach'
| 'bell'
| 'check'
| 'chevron-left'
| 'chevron-right'
| 'close'
| 'copy'
| 'edit'
| 'globe'
| 'history'
| 'home'
| 'image'
| 'key'
| 'link'
| 'lock'
| 'logout'
| 'megaphone'
| 'menu'
| 'moon'
| 'more'
| 'plug'
| 'plus'
| 'react'
| 'read'
| 'refresh'
| 'reply'
| 'room'
| 'search'
| 'send'
| 'server'
| 'settings'
| 'shield-alert'
| 'shield-check'
| 'sun'
| 'trash'
| 'user'
| 'video'
const files = import.meta.glob<string>('./*.svg', { query: '?raw', import: 'default', eager: true })
// Only the drawing is kept: the outer <svg> is rendered by the Icon
// component, and the <title> would give a decorative icon a name.
const bodies: Record<string, string> = {}
for (const [path, svg] of Object.entries(files)) {
const name = path.slice(2, -4)
bodies[name] = svg
.replace(/^[\s\S]*?<svg[^>]*>/, '')
.replace(/<\/svg>\s*$/, '')
.replace(/<title>[\s\S]*?<\/title>/, '')
}
export function iconBody(name: IconName): string {
return bodies[name] ?? ''
}
export const iconNames = Object.keys(bodies).sort() as IconName[]
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>key</title><circle cx="8" cy="15" r="3.5"/><path d="M10.5 12.5 19 4"/><path d="M15.5 7.5l2.5 2.5"/><path d="M17.5 5.5 20 8"/></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>link</title><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>

After

Width:  |  Height:  |  Size: 308 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>lock</title><rect x="5" y="11" width="14" height="9.5" rx="2.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>logout</title><path d="M10 4.5H7A1.5 1.5 0 0 0 5.5 6v12A1.5 1.5 0 0 0 7 19.5h3"/><path d="M14 8l4 4-4 4"/><path d="M18 12H9.5"/></svg>

After

Width:  |  Height:  |  Size: 303 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>megaphone</title><path d="M4.5 10v4a1 1 0 0 0 1 1H8l7 4V5L8 9H5.5a1 1 0 0 0-1 1z"/><path d="M8 15l1 4.5"/><path d="M18.5 9.5a3.5 3.5 0 0 1 0 5"/></svg>

After

Width:  |  Height:  |  Size: 320 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>menu</title><path d="M4.5 7h15"/><path d="M4.5 12h15"/><path d="M4.5 17h15"/></svg>

After

Width:  |  Height:  |  Size: 252 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>moon</title><path d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z"/></svg>

After

Width:  |  Height:  |  Size: 252 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>more</title><path d="M6 12h.01"/><path d="M12 12h.01"/><path d="M18 12h.01"/></svg>

After

Width:  |  Height:  |  Size: 251 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>plug</title><path d="M9 4v4"/><path d="M15 4v4"/><path d="M6.5 8h11v3a5.5 5.5 0 0 1-11 0z"/><path d="M12 16.5V20"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>plus</title><path d="M12 5v14"/><path d="M5 12h14"/></svg>

After

Width:  |  Height:  |  Size: 226 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>react</title><circle cx="12" cy="12" r="8.5"/><path d="M8.8 14.2c.8 1.2 1.9 1.8 3.2 1.8s2.4-.6 3.2-1.8"/><path d="M9.3 9.8v.2"/><path d="M14.7 9.8v.2"/></svg>

After

Width:  |  Height:  |  Size: 326 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>read</title><path d="M2.5 12.5l4.2 4.2L15 8.4"/><path d="M11.6 15.4l1.3 1.3L21.2 8.4"/></svg>

After

Width:  |  Height:  |  Size: 261 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>refresh</title><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></svg>

After

Width:  |  Height:  |  Size: 258 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>reply</title><path d="M9.5 6.5L4.5 11.5l5 5"/><path d="M4.5 11.5H14a5.5 5.5 0 0 1 5.5 5.5v1"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>room</title><path d="M5 9h14"/><path d="M5 15h14"/><path d="M10 4.5L8.5 19.5"/><path d="M15.5 4.5L14 19.5"/></svg>

After

Width:  |  Height:  |  Size: 282 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>search</title><circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/></svg>

After

Width:  |  Height:  |  Size: 245 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>send</title><path d="M4.5 12h14"/><path d="M13 6.5l5.5 5.5-5.5 5.5"/></svg>

After

Width:  |  Height:  |  Size: 243 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>server</title><rect x="4" y="4.5" width="16" height="6.5" rx="2"/><rect x="4" y="13" width="16" height="6.5" rx="2"/><path d="M7.5 7.75h.01"/><path d="M7.5 16.25h.01"/></svg>

After

Width:  |  Height:  |  Size: 342 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>settings</title><path d="M4 7h9"/><path d="M18 7h2"/><path d="M4 17h3"/><path d="M12 17h8"/><circle cx="15.5" cy="7" r="2.3"/><circle cx="9.5" cy="17" r="2.3"/></svg>

After

Width:  |  Height:  |  Size: 334 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>shield-alert</title><path d="M12 3.2l7 2.8v5.6c0 4.4-2.9 7.6-7 9.2-4.1-1.6-7-4.8-7-9.2V6z"/><path d="M12 8v4.6"/><path d="M12 15.8v.2"/></svg>

After

Width:  |  Height:  |  Size: 310 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>shield-check</title><path d="M12 3.2l7 2.8v5.6c0 4.4-2.9 7.6-7 9.2-4.1-1.6-7-4.8-7-9.2V6z"/><path d="M8.8 12.2l2.2 2.2 4.2-4.4"/></svg>

After

Width:  |  Height:  |  Size: 303 B

Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more