Files
veridian/app/pages/settings/instance/index.vue
T
2026-10-08 13:50:44 -05:00

220 lines
9.0 KiB
Vue

<script setup lang="ts">
import type { RegistrationMode } from '~~/drizzle/schema';
type InstanceSettings = {
registration: RegistrationMode;
registrationLocked: boolean;
};
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
{
value: 'open',
label: 'Open',
description: 'Anyone who can reach this instance can create an account.',
},
{
value: 'invite',
label: 'Invite only',
description: 'New accounts need an invite link from an admin.',
},
{
value: 'closed',
label: 'Closed',
description: 'Nobody can register. Admins create accounts under Users.',
},
];
const { isAdmin } = useAuth();
if (!isAdmin.value) {
await navigateTo('/settings/profile');
}
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
key: 'admin_instance',
});
type Invite = {
id: string;
token: string;
maxUses: number;
uses: number;
expiresAt: string;
createdAt: string;
};
const DAY_MS = 24 * 60 * 60 * 1000;
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
key: 'admin_invites',
});
const invites = computed(() => inviteData.value ?? []);
const saving = ref(false);
const error = ref('');
const locked = computed(() => !!instance.value?.registrationLocked);
const current = computed(() => REGISTRATION_OPTIONS.find(o => o.value === instance.value?.registration));
const setRegistration = async (registration: RegistrationMode) => {
if (saving.value || locked.value || !instance.value || instance.value.registration === registration) return;
saving.value = true;
error.value = '';
try {
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
method: 'PUT',
body: { registration },
});
// the sign in pages read this to decide whether to offer registering
clearNuxtData('instance');
} catch (err) {
console.error('Failed to change registration:', err);
error.value = 'Registration could not be changed. Try again.';
}
saving.value = false;
};
// ---------------------------------------------------------------------------
// Invite links
// ---------------------------------------------------------------------------
const inviteBusy = ref(false);
const inviteError = ref('');
const copiedId = ref<string | null>(null);
const invitePath = (invite: Invite) => `/auth/register?invite=${invite.token}`;
const describeInvite = (invite: Invite) => {
const days = Math.max(1, Math.ceil((new Date(invite.expiresAt).getTime() - Date.now()) / DAY_MS));
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${days} ${days === 1 ? 'day' : 'days'}`;
};
const copyInvite = async (invite: Invite) => {
try {
await navigator.clipboard.writeText(`${window.location.origin}${invitePath(invite)}`);
copiedId.value = invite.id;
setTimeout(() => {
if (copiedId.value === invite.id) copiedId.value = null;
}, 2000);
} catch (err) {
console.error('Failed to copy invite link:', err);
inviteError.value = 'The link could not be copied. Select it and copy it by hand.';
}
};
const createInvite = async () => {
if (inviteBusy.value) return;
inviteBusy.value = true;
inviteError.value = '';
try {
await $fetch('/api/admin/invites', { method: 'POST' });
await loadInvites();
} catch (err) {
console.error('Failed to create invite:', err);
inviteError.value = 'The invite link could not be created. Try again.';
}
inviteBusy.value = false;
};
const revokeInvite = async (invite: Invite) => {
if (inviteBusy.value) return;
inviteBusy.value = true;
inviteError.value = '';
try {
await $fetch(`/api/admin/invites/${invite.id}`, { method: 'DELETE' });
await loadInvites();
} catch (err) {
console.error('Failed to revoke invite:', err);
inviteError.value = 'The invite link could not be revoked. Try again.';
}
inviteBusy.value = false;
};
</script>
<template>
<div
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-7 w-full max-w-3xl">
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-70">
<h2 class="font-medium">Registration</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
{{ current?.description ?? 'The instance settings could not be loaded.' }}
</p>
</div>
<div role="group" aria-label="Registration mode"
class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)]">
<button v-for="option in REGISTRATION_OPTIONS" :key="option.value" type="button"
:aria-pressed="instance?.registration === option.value"
:disabled="saving || locked || !instance" @click="setRegistration(option.value)"
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
:class="instance?.registration === option.value
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
{{ option.label }}
</button>
</div>
</div>
<p v-if="locked" class="flex gap-2 text-[13px] leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.5 flex-shrink-0" aria-hidden="true"></span>
<span>
Registration is turned off by the <code class="font-mono text-xs">DISABLE_SIGNUP</code>
environment variable. Remove it and restart the server to change this here.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<!-- links only let anyone in while the instance is invite only -->
<template v-if="instance?.registration === 'invite'">
<ul v-if="invites.length > 0" class="flex flex-col gap-1">
<li v-for="invite in invites" :key="invite.id"
class="flex flex-wrap items-center gap-3 min-h-11 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] text-sm">
<span
class="flex-1 min-w-55 font-mono text-[13px] overflow-hidden text-ellipsis whitespace-nowrap select-all">
{{ invitePath(invite) }}
</span>
<span class="text-xs text-[var(--text-secondary)]">{{ describeInvite(invite) }}</span>
<button type="button" @click="copyInvite(invite)"
class="flex items-center h-7 px-2 text-[13px] rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-200">
{{ copiedId === invite.id ? 'Copied' : 'Copy' }}
</button>
<button type="button" :disabled="inviteBusy" @click="revokeInvite(invite)"
class="flex items-center h-7 px-2 text-[13px] rounded-md text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Revoke
</button>
</li>
</ul>
<p v-if="inviteError" role="alert" class="text-sm text-red-400">{{ inviteError }}</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<button type="button" :disabled="inviteBusy" @click="createInvite"
class="flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
New invite link
</button>
<span class="text-xs text-[var(--text-secondary)]">
Each link lets one person register and expires after 7 days.
</span>
</div>
</template>
</section>
</div>
</div>
</template>