feat: complete onboarding flow
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { RegistrationMode } from '~~/drizzle/schema';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import { timeLeft } from '~/utils/time';
|
||||
|
||||
type InstanceSettings = {
|
||||
registration: RegistrationMode;
|
||||
@@ -25,6 +27,7 @@ const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; descriptio
|
||||
];
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
if (!isAdmin.value) {
|
||||
await navigateTo('/settings/profile');
|
||||
@@ -43,8 +46,6 @@ type Invite = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
|
||||
key: 'admin_invites',
|
||||
});
|
||||
@@ -89,9 +90,7 @@ 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'}`;
|
||||
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${timeLeft(invite.expiresAt)}`;
|
||||
};
|
||||
|
||||
const copyInvite = async (invite: Invite) => {
|
||||
@@ -107,21 +106,12 @@ const copyInvite = async (invite: Invite) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createInvite = async () => {
|
||||
if (inviteBusy.value) return;
|
||||
|
||||
inviteBusy.value = true;
|
||||
inviteError.value = '';
|
||||
|
||||
try {
|
||||
await $fetch('/api/admin/invites', { method: 'POST' });
|
||||
// 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();
|
||||
} 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) => {
|
||||
@@ -202,16 +192,11 @@ const revokeInvite = async (invite: Invite) => {
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -187,38 +187,12 @@ const { data: instance } = await useFetch<{ registration: string }>('/api/admin/
|
||||
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 dialog asks for the details and shows the link to copy
|
||||
const createInvite = () => {
|
||||
openDialog(DialogType.CreateInvite, () => {
|
||||
// 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 = () => {
|
||||
@@ -256,8 +230,7 @@ const 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"
|
||||
<button v-if="instance?.registration === 'invite'" type="button" @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
|
||||
@@ -265,17 +238,6 @@ const openCreateUser = () => {
|
||||
</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>
|
||||
<p v-if="actionError" role="alert" class="pb-3 text-sm text-red-400">{{ actionError }}</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user