feat: instance invites
This commit is contained in:
@@ -15,7 +15,7 @@ const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; descriptio
|
||||
{
|
||||
value: 'invite',
|
||||
label: 'Invite only',
|
||||
description: 'New accounts need an invite link from an admin. Invite links are not available yet, so until then nobody can register.',
|
||||
description: 'New accounts need an invite link from an admin.',
|
||||
},
|
||||
{
|
||||
value: 'closed',
|
||||
@@ -34,6 +34,23 @@ 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('');
|
||||
|
||||
@@ -60,6 +77,69 @@ const setRegistration = async (registration: RegistrationMode) => {
|
||||
|
||||
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>
|
||||
@@ -98,6 +178,41 @@ const setRegistration = async (registration: RegistrationMode) => {
|
||||
</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>
|
||||
|
||||
@@ -178,6 +178,49 @@ const deleteUser = (user: UserRow) => confirmThen({
|
||||
return true;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// only offered while the instance is invite only, links do nothing otherwise
|
||||
const { data: instance } = await useFetch<{ registration: string }>('/api/admin/instance', {
|
||||
key: 'admin_instance',
|
||||
});
|
||||
|
||||
const inviteBusy = ref(false);
|
||||
const inviteLink = ref('');
|
||||
const inviteCopied = ref(false);
|
||||
|
||||
const createInvite = async () => {
|
||||
if (inviteBusy.value) return;
|
||||
|
||||
inviteBusy.value = true;
|
||||
actionError.value = '';
|
||||
inviteCopied.value = false;
|
||||
|
||||
try {
|
||||
const invite = await $fetch<{ token: string }>('/api/admin/invites', { method: 'POST' });
|
||||
inviteLink.value = `${window.location.origin}/auth/register?invite=${invite.token}`;
|
||||
// the Instance page lists the links, so it has to load the new one
|
||||
clearNuxtData('admin_invites');
|
||||
} catch (error) {
|
||||
console.error('Failed to create invite:', error);
|
||||
actionError.value = 'The invite link could not be created. Try again.';
|
||||
inviteBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// copying can be refused by the browser, the link is shown either way
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink.value);
|
||||
inviteCopied.value = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy invite link:', error);
|
||||
}
|
||||
|
||||
inviteBusy.value = false;
|
||||
};
|
||||
|
||||
const openCreateUser = () => {
|
||||
openDialog(DialogType.CreateUser, async (result) => {
|
||||
if (result.ok === false) return;
|
||||
@@ -208,10 +251,29 @@ const openCreateUser = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" @click="openCreateUser"
|
||||
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||
Create user
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="openCreateUser"
|
||||
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||
Create user
|
||||
</button>
|
||||
<button v-if="instance?.registration === 'invite'" type="button" :disabled="inviteBusy"
|
||||
@click="createInvite"
|
||||
class="flex items-center gap-1.5 h-8 px-3 text-[13px] font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span class="i-mynaui-link text-4" aria-hidden="true"></span>
|
||||
Invite link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="inviteLink" role="status"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-3 px-3 py-2 rounded-lg bg-[var(--bg-container)] text-sm">
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
{{ inviteCopied ? 'Invite link copied:' : 'New invite link:' }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-55 font-mono text-[13px] overflow-hidden text-ellipsis whitespace-nowrap select-all">
|
||||
{{ inviteLink }}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">one use · expires in 7 days</span>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
|
||||
|
||||
Reference in New Issue
Block a user