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:
1 parent
487e0f410b
commit
7e5e38c921
137 files changed
+6895
-15
No files matched your search
@@ -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>
|
||||
@@ -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)}`)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
public?: boolean
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@warp/ui/styles.css";
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user