feat: setup and roles
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
const { signIn } = useAuth();
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
// the auth middleware only lets this page render while no account exists
|
||||
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const alreadySetUp = 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;
|
||||
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
const checkPasswordsMatch = () => {
|
||||
const matches = passwordInputEl.value!.value === confirmPasswordInputEl.value!.value;
|
||||
confirmPasswordInputEl.value!.setCustomValidity(matches ? '' : 'Passwords do not match');
|
||||
};
|
||||
|
||||
passwordInputEl.value!.addEventListener('input', checkPasswordsMatch);
|
||||
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
||||
checkPasswordsMatch();
|
||||
confirmPasswordInputEl.value!.reportValidity();
|
||||
});
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.value?.validity.valid === false) {
|
||||
input.value!.reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
loading.value = false;
|
||||
|
||||
// TODO: i18n
|
||||
switch (err?.data?.data?.code as string | undefined) {
|
||||
case 'SETUP_ALREADY_DONE':
|
||||
// someone else created the first account in the meantime
|
||||
setupNeeded.value = false;
|
||||
alreadySetUp.value = true;
|
||||
break;
|
||||
case 'INVALID_EMAIL':
|
||||
emailInputEl.value!.setCustomValidity('That email address is not valid');
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVALID_BODY':
|
||||
case 'PASSWORD_TOO_LONG':
|
||||
error.value = err.data.message ?? 'Check the form and try again.';
|
||||
break;
|
||||
default:
|
||||
console.error('Failed to set up the instance:', err);
|
||||
error.value = 'The admin account could not be created. Try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setupNeeded.value = false;
|
||||
|
||||
const res = await signIn(form.email, form.password);
|
||||
if (!res.ok || res.data.twoFactorRequired) {
|
||||
// the account exists, so logging in by hand still works
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo('/');
|
||||
};
|
||||
</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)]">Set up this instance</p>
|
||||
</div>
|
||||
|
||||
<div v-if="alreadySetUp" class="flex flex-col gap-4">
|
||||
<p role="alert"
|
||||
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
Someone else just created the first account, so this instance already has its
|
||||
administrator. Nothing was created for you.
|
||||
</p>
|
||||
<NuxtLink to="/auth/login" class="accent w-full h-11 flex items-center justify-center font-semibold">
|
||||
Go to log in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p
|
||||
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
No accounts exist yet. The account you create here becomes the
|
||||
<strong class="font-medium text-[var(--text-primary)]">administrator</strong> of this instance.
|
||||
</p>
|
||||
|
||||
<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 maxlength="128" ref="nameInputEl" type="text" autocomplete="name" 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=".{1,}@.{1,}\..{2,3}" 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>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<button :disabled="!hydrated || loading"
|
||||
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>Create admin account</span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4">
|
||||
<ThemeSwitcher size="medium" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user