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

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