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>
270 lines
9.1 KiB
Vue
270 lines
9.1 KiB
Vue
<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>
|