205 lines
8.4 KiB
Vue
205 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import type { RegistrationMode } from '~~/drizzle/schema';
|
|
import { DialogType } from '~/composables/useDialog';
|
|
import { timeLeft } from '~/utils/time';
|
|
|
|
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();
|
|
const { openDialog } = useDialog();
|
|
|
|
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 { 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) => {
|
|
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${timeLeft(invite.expiresAt)}`;
|
|
};
|
|
|
|
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.';
|
|
}
|
|
};
|
|
|
|
// The dialog asks how long the link lasts and for how many people, and shows
|
|
// the link. It may be closed after the link was made, so always reload.
|
|
const createInvite = () => {
|
|
openDialog(DialogType.CreateInvite, async () => {
|
|
await loadInvites();
|
|
});
|
|
};
|
|
|
|
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>
|
|
|
|
<button type="button" @click="createInvite"
|
|
class="self-start flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
|
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
|
|
New invite link
|
|
</button>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|