feat: instance invites
This commit is contained in:
@@ -178,7 +178,12 @@ watch(() => props.agent?.defaultModelId, (newModelId) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// sending needs a model, and there are none until a provider is set up
|
||||||
|
const noModels = computed(() => allModels.value.length === 0);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (noModels.value) return;
|
||||||
|
|
||||||
if (isUploading.value || hasFailedUploads.value) {
|
if (isUploading.value || hasFailedUploads.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -454,8 +459,8 @@ onUnmounted(() => {
|
|||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
|
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
|
||||||
<div v-if="allowManualRole" class="flex items-center">
|
<div v-if="allowManualRole" class="flex items-center">
|
||||||
<button aria-label="Send message" @click="handleSubmit"
|
<button :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
||||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
|
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
||||||
:class="[
|
:class="[
|
||||||
'h-8 w-8 rounded-l-lg rounded-r-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
'h-8 w-8 rounded-l-lg rounded-r-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||||
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
||||||
@@ -496,8 +501,8 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
<button v-else aria-label="Send message" @click="handleSubmit"
|
<button v-else :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
||||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
|
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
||||||
:class="[
|
:class="[
|
||||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||||
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const openProviderSettings = async (providerId?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
// nothing to pick from until a provider is set up, so the picker explains that
|
||||||
|
const noModels = computed(() => allModels.value.length === 0);
|
||||||
const dropdownButton = ref<HTMLElement | null>(null);
|
const dropdownButton = ref<HTMLElement | null>(null);
|
||||||
const dropdownContent = ref<HTMLElement | null>(null);
|
const dropdownContent = ref<HTMLElement | null>(null);
|
||||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
@@ -221,11 +224,13 @@ onUnmounted(() => {
|
|||||||
isOpen
|
isOpen
|
||||||
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
||||||
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
|
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
|
||||||
|
noModels ? 'border border-dashed border-[var(--color-border-active)] !text-[var(--text-primary)]' : '',
|
||||||
]">
|
]">
|
||||||
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
|
<span v-if="noModels" class="i-mynaui-api text-4.5" aria-hidden="true"></span>
|
||||||
|
<ModelIcon v-if="selectedModel && !noModels" class="text-white" :avatar="true" variant="color"
|
||||||
:model-id="selectedModel.externalId" size="22" />
|
:model-id="selectedModel.externalId" size="22" />
|
||||||
<span class="max-w-full truncate">
|
<span class="max-w-full truncate">
|
||||||
{{ selectedModel ? selectedModel.name : 'Select a model' }}
|
{{ noModels ? 'No models yet' : selectedModel ? selectedModel.name : 'Select a model' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
|
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
|
||||||
:class="{ 'rotate-180': isOpen }"></span>
|
:class="{ 'rotate-180': isOpen }"></span>
|
||||||
@@ -246,9 +251,21 @@ onUnmounted(() => {
|
|||||||
? 'hidden'
|
? 'hidden'
|
||||||
: 'visible',
|
: 'visible',
|
||||||
}"
|
}"
|
||||||
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
|
:class="[transformOrigin, noModels ? 'max-w-80' : 'max-w-[420px]']"
|
||||||
:class="transformOrigin">
|
class="fixed z-100 w-full min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden">
|
||||||
|
|
||||||
|
<div v-if="noModels" class="flex flex-col gap-2.5 p-4">
|
||||||
|
<div class="font-semibold">No models yet</div>
|
||||||
|
<p class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||||
|
Veridian uses your own API keys. Add a provider and its models show up here.
|
||||||
|
</p>
|
||||||
|
<button type="button" @click="openProviderSettings()"
|
||||||
|
class="self-start flex items-center h-8.5 px-3 text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
|
||||||
|
Add a provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span
|
<span
|
||||||
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
|
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
|
||||||
@@ -305,6 +322,7 @@ onUnmounted(() => {
|
|||||||
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
|
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -162,7 +162,9 @@ export const useAuth = () => {
|
|||||||
const signUp = async (
|
const signUp = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
name: string
|
name: string,
|
||||||
|
// needed while the instance only takes invited people
|
||||||
|
inviteToken?: string,
|
||||||
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
||||||
sessionFetching.value = true;
|
sessionFetching.value = true;
|
||||||
|
|
||||||
@@ -171,6 +173,8 @@ export const useAuth = () => {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
name,
|
name,
|
||||||
|
}, {
|
||||||
|
headers: inviteToken ? { 'x-invite-token': inviteToken } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -12,13 +12,42 @@ if (session.value !== null) {
|
|||||||
await navigateTo(to ?? '/');
|
await navigateTo(to ?? '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// who may register is an instance setting, admins change it while it runs
|
// who may register is an instance setting, admins change it while it runs
|
||||||
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
||||||
|
|
||||||
if (instance.value && instance.value.registration !== 'open') {
|
if (instance.value?.registration === 'closed') {
|
||||||
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteOnly = computed(() => instance.value?.registration === 'invite');
|
||||||
|
|
||||||
|
// invite links look like /auth/register?invite=<token>
|
||||||
|
const inviteToken = typeof useRoute().query.invite === 'string' ? useRoute().query.invite as string : undefined;
|
||||||
|
|
||||||
|
const { data: invite } = await useAsyncData(`invite_${inviteToken ?? 'none'}`, async () => {
|
||||||
|
if (inviteToken === undefined) return null;
|
||||||
|
|
||||||
|
return await $fetch(`/api/invite/${encodeURIComponent(inviteToken)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// set when the invite turns out to be used up or expired on submit
|
||||||
|
const inviteRejected = ref(false);
|
||||||
|
|
||||||
|
const validInvite = computed(() => !inviteRejected.value && invite.value?.valid ? invite.value : null);
|
||||||
|
|
||||||
|
// an invite only instance takes nobody without a working link
|
||||||
|
const blocked = computed(() => inviteOnly.value && validInvite.value === null);
|
||||||
|
|
||||||
|
const inviteValidFor = computed(() => {
|
||||||
|
if (!validInvite.value) return '';
|
||||||
|
|
||||||
|
const days = Math.max(1, Math.ceil((new Date(validInvite.value.expiresAt).getTime() - Date.now()) / DAY_MS));
|
||||||
|
|
||||||
|
return `Invite valid for ${days} more ${days === 1 ? 'day' : 'days'}${validInvite.value.singleUse ? ' · single use' : ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -56,6 +85,9 @@ onMounted(() => {
|
|||||||
form.confirmPassword = tempForm.confirmPassword;
|
form.confirmPassword = tempForm.confirmPassword;
|
||||||
hydrated.value = true;
|
hydrated.value = true;
|
||||||
|
|
||||||
|
// there is no form when the invite does not work
|
||||||
|
if (blocked.value) return;
|
||||||
|
|
||||||
nameInputEl.value!.addEventListener('input', () => {
|
nameInputEl.value!.addEventListener('input', () => {
|
||||||
nameInputEl.value!.setCustomValidity('');
|
nameInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
@@ -95,7 +127,7 @@ const submit = async () => {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const res = await signUp(form.email, form.password, form.name);
|
const res = await signUp(form.email, form.password, form.name, inviteOnly.value ? inviteToken : undefined);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (!res.error.data) {
|
if (!res.error.data) {
|
||||||
@@ -122,6 +154,10 @@ const submit = async () => {
|
|||||||
passwordInputEl.value!.setCustomValidity(res.error.data.message!);
|
passwordInputEl.value!.setCustomValidity(res.error.data.message!);
|
||||||
passwordInputEl.value!.reportValidity();
|
passwordInputEl.value!.reportValidity();
|
||||||
break;
|
break;
|
||||||
|
case 'INVITE_INVALID':
|
||||||
|
// used by someone else or expired while this page was open
|
||||||
|
inviteRejected.value = true;
|
||||||
|
break;
|
||||||
case 'REGISTRATION_CLOSED':
|
case 'REGISTRATION_CLOSED':
|
||||||
// an admin closed registration while this page was open
|
// an admin closed registration while this page was open
|
||||||
alert('Registration is closed on this instance. Ask an admin for an account.');
|
alert('Registration is closed on this instance. Ask an admin for an account.');
|
||||||
@@ -138,7 +174,8 @@ const submit = async () => {
|
|||||||
const key = await deriveKey(form.password, res.data.user.id);
|
const key = await deriveKey(form.password, res.data.user.id);
|
||||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||||
|
|
||||||
return navigateTo(to ?? '/');
|
// connecting a provider comes next, there is nothing to chat with before that
|
||||||
|
return navigateTo(to ? `/welcome?to=${to}` : '/welcome');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -158,7 +195,30 @@ const submit = async () => {
|
|||||||
<p class="text-[var(--text-secondary)]">Create your account</p>
|
<p class="text-[var(--text-secondary)]">Create your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl">
|
<p v-if="blocked" role="alert"
|
||||||
|
class="px-3 py-2.5 mb-1 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||||
|
<template v-if="inviteToken === undefined">
|
||||||
|
This instance is invite only. Ask an admin for an invite link to create an account.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This invite link doesn't work anymore. It was used already, has expired, or was revoked.
|
||||||
|
Ask an admin for a new one.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="inviteOnly && validInvite"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 mb-5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
|
||||||
|
<UserAvatar :user="validInvite.inviter" class="w-8 h-8 rounded-lg text-sm"></UserAvatar>
|
||||||
|
<div class="flex flex-col gap-0.25 text-sm min-w-0">
|
||||||
|
<span class="overflow-hidden text-ellipsis">
|
||||||
|
<strong class="font-semibold">{{ validInvite.inviter.name }}</strong> invited you to this
|
||||||
|
instance
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">{{ inviteValidFor }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!blocked" class="rounded-2xl">
|
||||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="name"
|
<label for="name"
|
||||||
@@ -194,6 +254,14 @@ const submit = async () => {
|
|||||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
|
||||||
|
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
Your password also encrypts your API keys in the browser. If you forget it, saved
|
||||||
|
keys can't be recovered and have to be re-entered.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<button :disabled="!hydrated"
|
<button :disabled="!hydrated"
|
||||||
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
|
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
|
||||||
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ const submit = async () => {
|
|||||||
const key = await deriveKey(form.password, res.data.user.id);
|
const key = await deriveKey(form.password, res.data.user.id);
|
||||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||||
|
|
||||||
return navigateTo('/');
|
// straight on to connecting a provider, the instance is useless without one
|
||||||
|
return navigateTo('/welcome');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; descriptio
|
|||||||
{
|
{
|
||||||
value: 'invite',
|
value: 'invite',
|
||||||
label: 'Invite only',
|
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',
|
value: 'closed',
|
||||||
@@ -34,6 +34,23 @@ const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance
|
|||||||
key: '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 saving = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
@@ -60,6 +77,69 @@ const setRegistration = async (registration: RegistrationMode) => {
|
|||||||
|
|
||||||
saving.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -98,6 +178,41 @@ const setRegistration = async (registration: RegistrationMode) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -178,6 +178,49 @@ const deleteUser = (user: UserRow) => confirmThen({
|
|||||||
return true;
|
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 = () => {
|
const openCreateUser = () => {
|
||||||
openDialog(DialogType.CreateUser, async (result) => {
|
openDialog(DialogType.CreateUser, async (result) => {
|
||||||
if (result.ok === false) return;
|
if (result.ok === false) return;
|
||||||
@@ -208,10 +251,29 @@ const openCreateUser = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
<button type="button" @click="openCreateUser"
|
<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">
|
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
|
Create user
|
||||||
</button>
|
</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>
|
</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="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Model } from '~/composables/useModels';
|
||||||
|
import { encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
|
||||||
|
import { providerIcons } from '~/utils/model-mapping';
|
||||||
|
import { sortByReleaseDate } from '~/utils/sort';
|
||||||
|
|
||||||
|
// the providers offered up front, the rest sit behind "More…"
|
||||||
|
const FEATURED_PROVIDERS = ['openrouter', 'anthropic', 'openai', 'google', 'ollama'];
|
||||||
|
// these run on your own machine and are reached by address, not by key
|
||||||
|
const LOCAL_PROVIDERS = ['ollama', 'vllm'];
|
||||||
|
// A provider can list hundreds of models, so only the newest few are turned on
|
||||||
|
// to start with. The rest are a click away in the provider's settings.
|
||||||
|
const MODELS_ENABLED_AT_START = 8;
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
openrouter: 'OpenRouter',
|
||||||
|
anthropic: 'Anthropic',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
google: 'Google',
|
||||||
|
ollama: 'Ollama',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
openrouter: 'sk-or-…',
|
||||||
|
anthropic: 'sk-ant-…',
|
||||||
|
openai: 'sk-…',
|
||||||
|
};
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth',
|
||||||
|
});
|
||||||
|
|
||||||
|
const to = useRoute().query.to as string | undefined;
|
||||||
|
|
||||||
|
const { providers } = await useModels();
|
||||||
|
|
||||||
|
const showAll = ref(false);
|
||||||
|
const selectedType = ref(FEATURED_PROVIDERS.find(type => providers.value.some(p => p.type === type))
|
||||||
|
?? providers.value[0]?.type ?? '');
|
||||||
|
|
||||||
|
const featured = computed(() => FEATURED_PROVIDERS
|
||||||
|
.map(type => providers.value.find(p => p.type === type))
|
||||||
|
.filter(p => p !== undefined));
|
||||||
|
|
||||||
|
const others = computed(() => providers.value.filter(p => !FEATURED_PROVIDERS.includes(p.type)));
|
||||||
|
|
||||||
|
const shownProviders = computed(() => showAll.value ? [...featured.value, ...others.value] : featured.value);
|
||||||
|
|
||||||
|
const selected = computed(() => providers.value.find(p => p.type === selectedType.value));
|
||||||
|
const isLocal = computed(() => LOCAL_PROVIDERS.includes(selectedType.value));
|
||||||
|
|
||||||
|
const apiKey = ref('');
|
||||||
|
const apiKeyVisible = ref(false);
|
||||||
|
const baseUrl = ref('');
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const ready = computed(() => isLocal.value ? baseUrl.value.trim() !== '' : apiKey.value.trim() !== '');
|
||||||
|
|
||||||
|
const selectProvider = (type: string) => {
|
||||||
|
selectedType.value = type;
|
||||||
|
error.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => navigateTo(to ?? '/');
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const provider = selected.value;
|
||||||
|
if (busy.value || !ready.value || !provider) return;
|
||||||
|
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
const storedKey = localStorage.getItem('encryptionKey');
|
||||||
|
if (storedKey === null) {
|
||||||
|
error.value = 'This browser has no encryption key for your account. Log out and back in, then add the provider under Settings.';
|
||||||
|
busy.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = apiKey.value.trim();
|
||||||
|
const config = { ...provider.config };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLocal.value) {
|
||||||
|
config.apiProxyUrl = baseUrl.value.trim();
|
||||||
|
} else {
|
||||||
|
const cryptoKey = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||||
|
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch(`/api/provider/${provider.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { config, enabled: true },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save provider:', err);
|
||||||
|
error.value = 'The provider could not be saved. Try again.';
|
||||||
|
busy.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let models: Model[];
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{ models: Model[] }>(`/api/provider/${provider.id}/models`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { providerApiKey: isLocal.value ? undefined : key },
|
||||||
|
});
|
||||||
|
models = response.models;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch models:', err);
|
||||||
|
error.value = isLocal.value
|
||||||
|
? 'No models could be fetched from that address. Check that it is right and reachable from the server.'
|
||||||
|
: 'No models could be fetched with that key. Check that it is right and try again.';
|
||||||
|
busy.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEnable = models
|
||||||
|
.filter(m => m.outputModalities.includes('text'))
|
||||||
|
.sort(sortByReleaseDate)
|
||||||
|
.slice(0, MODELS_ENABLED_AT_START);
|
||||||
|
|
||||||
|
if (toEnable.length === 0) {
|
||||||
|
error.value = 'The provider answered, but has no chat models to offer. Try another provider.';
|
||||||
|
busy.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a model that fails to turn on here can still be turned on in settings
|
||||||
|
const results = await Promise.allSettled(toEnable.map(m => $fetch(`/api/model/${m.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { enabled: true },
|
||||||
|
})));
|
||||||
|
const enabledIds = new Set(toEnable.filter((_, i) => results[i]!.status === 'fulfilled').map(m => m.id));
|
||||||
|
|
||||||
|
providers.value = providers.value.map(p => p.id === provider.id
|
||||||
|
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
|
||||||
|
: p);
|
||||||
|
|
||||||
|
await finish();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
|
||||||
|
<!-- Background decorative blobs -->
|
||||||
|
<div
|
||||||
|
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
|
||||||
|
<div class="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||||
|
<p class="text-[var(--text-secondary)]">One more thing: connect an AI provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm leading-normal text-center text-[var(--text-secondary)]">
|
||||||
|
Veridian runs on your own API keys. Add one provider now and its models are ready when you
|
||||||
|
land on Home.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||||
|
<fieldset class="flex flex-col">
|
||||||
|
<legend
|
||||||
|
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
|
Provider
|
||||||
|
</legend>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button v-for="p in shownProviders" :key="p.id" type="button"
|
||||||
|
:aria-pressed="selectedType === p.type" :disabled="busy" @click="selectProvider(p.type)"
|
||||||
|
class="flex items-center justify-center gap-2 h-11 px-2 min-w-0 text-sm rounded-lg border disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
|
:class="selectedType === p.type
|
||||||
|
? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 font-medium'
|
||||||
|
: 'border-[var(--color-border)] enabled:@hover:bg-[var(--color-hover)]'">
|
||||||
|
<component v-if="providerIcons[p.type]" :is="providerIcons[p.type]" :color="true"
|
||||||
|
class="w-5.5 h-5.5 flex-shrink-0 text-[var(--text-primary)]" />
|
||||||
|
<span class="case-capital overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{{ PROVIDER_LABELS[p.type] ?? p.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="!showAll && others.length > 0" type="button" :disabled="busy"
|
||||||
|
@click="showAll = true"
|
||||||
|
class="flex items-center justify-center h-11 text-sm rounded-lg border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:cursor-not-allowed transition-colors duration-200">
|
||||||
|
More…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div v-if="isLocal" class="flex flex-col gap-1.5">
|
||||||
|
<label for="baseurl"
|
||||||
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||||
|
Server address
|
||||||
|
</label>
|
||||||
|
<input id="baseurl" v-model="baseUrl" type="url" :disabled="busy" autocomplete="off"
|
||||||
|
spellcheck="false" :placeholder="selected?.defaultBaseUrl || 'http://localhost:11434'"
|
||||||
|
class="text-sm border border-[var(--color-border)]" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-1.5">
|
||||||
|
<label for="apikey"
|
||||||
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||||
|
API key
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md bg-[var(--bg-container)] border border-[var(--color-border)]">
|
||||||
|
<input id="apikey" v-model="apiKey" :type="apiKeyVisible ? 'text' : 'password'"
|
||||||
|
:disabled="busy" autocomplete="off" spellcheck="false"
|
||||||
|
:placeholder="KEY_PLACEHOLDERS[selectedType] ?? 'API key'"
|
||||||
|
class="flex-1 min-w-0 text-sm !bg-transparent" />
|
||||||
|
<button type="button" :aria-label="apiKeyVisible ? 'Hide API key' : 'Show API key'"
|
||||||
|
@click="apiKeyVisible = !apiKeyVisible"
|
||||||
|
class="flex items-center justify-center w-10 h-10 flex-shrink-0 text-[var(--text-secondary)]">
|
||||||
|
<span class="text-4.5" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!isLocal" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
|
||||||
|
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||||
|
<span>
|
||||||
|
Encrypted in this browser with AES-GCM before it is stored. Nobody else on this instance,
|
||||||
|
including admins, can read it.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" :disabled="busy" @click="finish"
|
||||||
|
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
<button type="submit" :disabled="busy || !ready"
|
||||||
|
class="accent flex-1 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||||
|
<span v-else>Fetch models and continue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "invites" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"token" text NOT NULL UNIQUE,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"max_uses" integer DEFAULT 1 NOT NULL,
|
||||||
|
"uses" integer DEFAULT 0 NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_users_id_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -357,3 +357,20 @@ export const verifications = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Links admins hand out so someone can register while the instance is invite
|
||||||
|
// only. The token is the secret part of the link.
|
||||||
|
export const invites = pgTable(
|
||||||
|
"invites",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey().$defaultFn(nanoid),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
createdBy: text("created_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
maxUses: integer("max_uses").notNull().default(1),
|
||||||
|
uses: integer("uses").notNull().default(0),
|
||||||
|
expiresAt: timestamptz("expires_at").notNull(),
|
||||||
|
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
+29
-3
@@ -1,6 +1,6 @@
|
|||||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||||
import { db } from "../server/lib/db";
|
import { db } from "../server/lib/db";
|
||||||
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api';
|
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
|
||||||
import { betterAuth } from 'better-auth/minimal';
|
import { betterAuth } from 'better-auth/minimal';
|
||||||
import { createAccessControl } from 'better-auth/plugins/access';
|
import { createAccessControl } from 'better-auth/plugins/access';
|
||||||
import { admin } from 'better-auth/plugins/admin';
|
import { admin } from 'better-auth/plugins/admin';
|
||||||
@@ -8,6 +8,7 @@ import { defaultStatements } from 'better-auth/plugins/admin/access';
|
|||||||
import { twoFactor } from 'better-auth/plugins/two-factor';
|
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { getRegistrationMode } from '../server/utils/instance';
|
import { getRegistrationMode } from '../server/utils/instance';
|
||||||
|
import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites';
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
@@ -51,14 +52,28 @@ export const auth = betterAuth({
|
|||||||
hooks: {
|
hooks: {
|
||||||
before: createAuthMiddleware(async (ctx) => {
|
before: createAuthMiddleware(async (ctx) => {
|
||||||
// Who may register is an instance setting admins change at runtime.
|
// Who may register is an instance setting admins change at runtime.
|
||||||
// Invite links do not exist yet, so only open instances take sign ups.
|
if (ctx.path === '/sign-up/email') {
|
||||||
if (ctx.path === '/sign-up/email' && await getRegistrationMode() !== 'open') {
|
const mode = await getRegistrationMode();
|
||||||
|
|
||||||
|
if (mode === 'closed') {
|
||||||
throw new APIError('FORBIDDEN', {
|
throw new APIError('FORBIDDEN', {
|
||||||
message: 'Registration is closed on this instance',
|
message: 'Registration is closed on this instance',
|
||||||
code: 'REGISTRATION_CLOSED',
|
code: 'REGISTRATION_CLOSED',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'invite') {
|
||||||
|
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||||
|
|
||||||
|
if (!token || !await claimInvite(token)) {
|
||||||
|
throw new APIError('FORBIDDEN', {
|
||||||
|
message: 'This invite link is not valid anymore',
|
||||||
|
code: 'INVITE_INVALID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// creating a user as an admin skips the sign up password rules
|
// creating a user as an admin skips the sign up password rules
|
||||||
if (ctx.path === '/admin/create-user') {
|
if (ctx.path === '/admin/create-user') {
|
||||||
const password = ctx.body?.password;
|
const password = ctx.body?.password;
|
||||||
@@ -89,6 +104,17 @@ export const auth = betterAuth({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// The invite was claimed before the account was made. When making it
|
||||||
|
// then fails, say because the email is taken, the use is given back.
|
||||||
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
|
if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return;
|
||||||
|
|
||||||
|
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||||
|
if (token && await getRegistrationMode() === 'invite') {
|
||||||
|
await releaseInvite(token);
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { invites } from '~~/drizzle/schema';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectAdminRoute(event);
|
||||||
|
|
||||||
|
await db.delete(invites).where(eq(invites.id, getRouterParam(event, 'id')!));
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { asc } from 'drizzle-orm';
|
||||||
|
import { invites } from '~~/drizzle/schema';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectAdminRoute(event);
|
||||||
|
|
||||||
|
await deleteDeadInvites();
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: invites.id,
|
||||||
|
token: invites.token,
|
||||||
|
maxUses: invites.maxUses,
|
||||||
|
uses: invites.uses,
|
||||||
|
expiresAt: invites.expiresAt,
|
||||||
|
createdAt: invites.createdAt,
|
||||||
|
})
|
||||||
|
.from(invites)
|
||||||
|
.orderBy(asc(invites.createdAt));
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { invites } from '~~/drizzle/schema';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
const TOKEN_LENGTH = 32;
|
||||||
|
|
||||||
|
/** Creates an invite link that lets one person register within a week. */
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectAdminRoute(event);
|
||||||
|
|
||||||
|
const [invite] = await db.insert(invites)
|
||||||
|
.values({
|
||||||
|
token: nanoid(TOKEN_LENGTH),
|
||||||
|
createdBy: event.context.user!.id as string,
|
||||||
|
maxUses: 1,
|
||||||
|
expiresAt: new Date(Date.now() + INVITE_LIFETIME_MS),
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: invites.id,
|
||||||
|
token: invites.token,
|
||||||
|
maxUses: invites.maxUses,
|
||||||
|
uses: invites.uses,
|
||||||
|
expiresAt: invites.expiresAt,
|
||||||
|
createdAt: invites.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invite!;
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Tells the register page whether an invite link still works and who it is
|
||||||
|
* from. Public on purpose, the person opening the link has no account yet.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const invite = await findUsableInvite(getRouterParam(event, 'token')!);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return { valid: false as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true as const,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
singleUse: invite.maxUses === 1,
|
||||||
|
inviter: {
|
||||||
|
id: invite.inviterId,
|
||||||
|
name: invite.inviterName,
|
||||||
|
image: invite.inviterImage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
|
||||||
|
import { invites, users } from '../../drizzle/schema';
|
||||||
|
import { db } from '../lib/db';
|
||||||
|
|
||||||
|
export const INVITE_TOKEN_HEADER = 'x-invite-token';
|
||||||
|
export const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// still has uses left and has not run out
|
||||||
|
const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt, new Date()));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes one use of an invite. It is a single statement, so two people
|
||||||
|
* registering with the same single use link at once cannot both get in.
|
||||||
|
*/
|
||||||
|
export const claimInvite = async (token: string): Promise<boolean> => {
|
||||||
|
const claimed = await db.update(invites)
|
||||||
|
.set({ uses: sql`${invites.uses} + 1` })
|
||||||
|
.where(and(eq(invites.token, token), usable()))
|
||||||
|
.returning({ id: invites.id });
|
||||||
|
|
||||||
|
return claimed.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gives a use back when the registration it was taken for did not go through. */
|
||||||
|
export const releaseInvite = async (token: string) => {
|
||||||
|
await db.update(invites)
|
||||||
|
.set({ uses: sql`${invites.uses} - 1` })
|
||||||
|
.where(and(eq(invites.token, token), gt(invites.uses, 0)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUsableInvite = async (token: string) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
expiresAt: invites.expiresAt,
|
||||||
|
maxUses: invites.maxUses,
|
||||||
|
uses: invites.uses,
|
||||||
|
inviterId: users.id,
|
||||||
|
inviterName: users.name,
|
||||||
|
inviterImage: users.image,
|
||||||
|
})
|
||||||
|
.from(invites)
|
||||||
|
.innerJoin(users, eq(users.id, invites.createdBy))
|
||||||
|
.where(and(eq(invites.token, token), usable()))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Used up and expired invites are of no use to anyone, so they are dropped. */
|
||||||
|
export const deleteDeadInvites = async () => {
|
||||||
|
await db.delete(invites).where(or(lte(invites.expiresAt, new Date()), sql`${invites.uses} >= ${invites.maxUses}`));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user