284 lines
12 KiB
Vue
284 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { deriveKey } from '~/utils/crypto';
|
|
import { timeLeft } from '~/utils/time';
|
|
|
|
const { client: authClient, signUp, session } = useAuth();
|
|
|
|
definePageMeta({
|
|
layout: 'auth',
|
|
});
|
|
const to = useRoute().query.to as string | undefined;
|
|
|
|
if (session.value !== null) {
|
|
await navigateTo(to ?? '/');
|
|
}
|
|
|
|
// who may register is an instance setting, admins change it while it runs
|
|
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
|
|
|
if (instance.value?.registration === 'closed') {
|
|
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 '';
|
|
|
|
return `Invite valid for ${timeLeft(validInvite.value.expiresAt)} more${validInvite.value.singleUse ? ' · single use' : ''}`;
|
|
});
|
|
|
|
const form = reactive({
|
|
name: '',
|
|
email: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
});
|
|
const loading = ref(false);
|
|
|
|
const nameInputEl = ref<HTMLInputElement | null>(null);
|
|
const emailInputEl = ref<HTMLInputElement | null>(null);
|
|
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
|
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
|
|
|
const tempForm = {
|
|
name: '',
|
|
email: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
};
|
|
|
|
// prevent text fields from clearing on hydration
|
|
onBeforeMount(() => {
|
|
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
|
|
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
|
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
|
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
|
|
});
|
|
|
|
const hydrated = ref(false);
|
|
|
|
onMounted(() => {
|
|
form.name = tempForm.name;
|
|
form.email = tempForm.email;
|
|
form.password = tempForm.password;
|
|
form.confirmPassword = tempForm.confirmPassword;
|
|
hydrated.value = true;
|
|
|
|
// there is no form when the invite does not work
|
|
if (blocked.value) return;
|
|
|
|
nameInputEl.value!.addEventListener('input', () => {
|
|
nameInputEl.value!.setCustomValidity('');
|
|
});
|
|
|
|
emailInputEl.value!.addEventListener('input', () => {
|
|
emailInputEl.value!.setCustomValidity('');
|
|
});
|
|
|
|
passwordInputEl.value!.addEventListener('input', () => {
|
|
passwordInputEl.value!.setCustomValidity('');
|
|
});
|
|
|
|
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
|
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
|
|
confirmPasswordInputEl.value!.setCustomValidity('Passwords do not match');
|
|
confirmPasswordInputEl.value!.reportValidity();
|
|
} else {
|
|
confirmPasswordInputEl.value!.setCustomValidity('');
|
|
}
|
|
});
|
|
});
|
|
|
|
const submit = async () => {
|
|
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
|
|
|
|
for (const input of inputs) {
|
|
if (input.value?.validity.valid === false) {
|
|
input.value!.reportValidity();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (form.password !== form.confirmPassword) {
|
|
alert('Passwords do not match');
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
const res = await signUp(form.email, form.password, form.name, inviteOnly.value ? inviteToken : undefined);
|
|
loading.value = false;
|
|
if (!res.ok) {
|
|
if (!res.error.data) {
|
|
console.error('Failed to sign up:', res.error);
|
|
alert('Something went wrong');
|
|
return;
|
|
}
|
|
|
|
const errorCode = res.error.data.code as keyof typeof authClient.$ERROR_CODES;
|
|
|
|
// TODO: i18n
|
|
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
|
switch (errorCode) {
|
|
case 'USER_ALREADY_EXISTS':
|
|
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
|
|
emailInputEl.value!.setCustomValidity('Account with this email already exists');
|
|
emailInputEl.value!.reportValidity();
|
|
break;
|
|
case 'INVALID_EMAIL':
|
|
emailInputEl.value!.setCustomValidity(res.error.data.message!);
|
|
emailInputEl.value!.reportValidity();
|
|
break;
|
|
case 'INVALID_PASSWORD':
|
|
passwordInputEl.value!.setCustomValidity(res.error.data.message!);
|
|
passwordInputEl.value!.reportValidity();
|
|
break;
|
|
case 'INVITE_INVALID':
|
|
// used by someone else or expired while this page was open
|
|
inviteRejected.value = true;
|
|
break;
|
|
case 'REGISTRATION_CLOSED':
|
|
// an admin closed registration while this page was open
|
|
alert('Registration is closed on this instance. Ask an admin for an account.');
|
|
break;
|
|
default:
|
|
console.log(res.error);
|
|
alert('Something went wrong: ' + res.error.data.message);
|
|
break;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const key = await deriveKey(form.password, res.data.user.id);
|
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
|
|
|
// connecting a provider comes next, there is nothing to chat with before that
|
|
return navigateTo(to ? `/welcome?to=${to}` : '/welcome');
|
|
};
|
|
</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-sm w-full">
|
|
<div class="flex flex-col items-center mb-4 text-center">
|
|
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
|
<p class="text-[var(--text-secondary)]">Create your account</p>
|
|
</div>
|
|
|
|
<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">
|
|
<div class="flex flex-col gap-1.5">
|
|
<label for="name"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Name</label>
|
|
<input required ref="nameInputEl" type="text" id="name" v-model="form.name"
|
|
placeholder="John Doe"
|
|
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<label for="email"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
|
|
<input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
|
|
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
|
|
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<label for="password"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Password</label>
|
|
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
|
autocomplete="new-password" id="password" v-model="form.password" placeholder="••••••••"
|
|
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<label for="confirmPassword"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
|
|
Password</label>
|
|
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
|
|
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword"
|
|
placeholder="••••••••"
|
|
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
|
</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"
|
|
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-else>Register</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<p class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
|
Already have an account?
|
|
<NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'"
|
|
class="text-[var(--color-accent)] font-medium @hover:underline">
|
|
Log in here
|
|
</NuxtLink>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="absolute bottom-4 right-4">
|
|
<ThemeSwitcher size="medium" />
|
|
</div>
|
|
</div>
|
|
</template>
|