Files
Warp/apps/admin/src/views/RegistrationView.vue
T
tajniak81andClaude Opus 5.5 7e5e38c921 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>
2026-10-18 15:05:16 +02:00

270 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>