349 lines
14 KiB
Vue
349 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import CodeInput from '~/components/CodeInput.vue';
|
|
import { deriveKey } from '~/utils/crypto';
|
|
|
|
const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
|
|
|
|
definePageMeta({
|
|
layout: 'auth',
|
|
});
|
|
|
|
const to = useRoute().query.to as string | undefined;
|
|
|
|
// set by the recovery page after a new password was chosen: 'keys' when saved
|
|
// API keys were deleted along the way, anything else when there were none
|
|
const recovered = useRoute().query.recovered 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' });
|
|
|
|
const form = reactive({
|
|
email: '',
|
|
password: '',
|
|
});
|
|
const loading = ref(false);
|
|
|
|
// second step, only shown for accounts with two-factor authentication
|
|
const step = ref<'credentials' | 'twoFactor'>('credentials');
|
|
const twoFactor = reactive({
|
|
code: '',
|
|
useBackupCode: false,
|
|
trustDevice: false,
|
|
error: '',
|
|
});
|
|
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
|
|
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
|
|
|
|
const focusCodeInput = async () => {
|
|
await nextTick();
|
|
|
|
if (twoFactor.useBackupCode) {
|
|
backupCodeInputEl.value?.focus();
|
|
} else {
|
|
codeInputEl.value?.focus();
|
|
}
|
|
};
|
|
|
|
const emailInputEl = ref<HTMLInputElement | null>(null);
|
|
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
|
|
|
const tempForm = {
|
|
email: '',
|
|
password: '',
|
|
};
|
|
|
|
// prevent text fields from clearing on hydration
|
|
onBeforeMount(() => {
|
|
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
|
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
|
});
|
|
|
|
const hydrated = ref(false);
|
|
|
|
onMounted(() => {
|
|
form.email = tempForm.email;
|
|
form.password = tempForm.password;
|
|
hydrated.value = true;
|
|
|
|
emailInputEl.value!.addEventListener('input', () => {
|
|
emailInputEl.value!.setCustomValidity('');
|
|
});
|
|
|
|
passwordInputEl.value!.addEventListener('input', () => {
|
|
passwordInputEl.value!.setCustomValidity('');
|
|
});
|
|
});
|
|
|
|
const submit = async () => {
|
|
const inputs = [emailInputEl, passwordInputEl];
|
|
|
|
for (const input of inputs) {
|
|
if (input.value?.validity.valid === false) {
|
|
input.value!.reportValidity();
|
|
return;
|
|
}
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
const res = await signIn(form.email, form.password);
|
|
loading.value = false;
|
|
|
|
if (!res.ok) {
|
|
if (!res.error.data) {
|
|
console.error('Failed to sign in:', 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 'INVALID_PASSWORD':
|
|
passwordInputEl.value!.setCustomValidity(res.error.data.message!);
|
|
passwordInputEl.value!.reportValidity();
|
|
break;
|
|
case 'ACCOUNT_NOT_FOUND':
|
|
case 'USER_NOT_FOUND':
|
|
case 'USER_EMAIL_NOT_FOUND':
|
|
emailInputEl.value!.setCustomValidity(res.error.data.message!);
|
|
emailInputEl.value!.reportValidity();
|
|
break;
|
|
case 'BANNED_USER':
|
|
emailInputEl.value!.setCustomValidity('This account has been disabled by an admin of this instance.');
|
|
emailInputEl.value!.reportValidity();
|
|
break;
|
|
default:
|
|
console.log(res.error);
|
|
alert(`Something went wrong. ${res.error.data.message}`);
|
|
break;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (res.data.twoFactorRequired) {
|
|
twoFactor.code = '';
|
|
twoFactor.error = '';
|
|
twoFactor.useBackupCode = false;
|
|
step.value = 'twoFactor';
|
|
|
|
await focusCodeInput();
|
|
return;
|
|
}
|
|
|
|
return finishSignIn(res.data.user.id);
|
|
};
|
|
|
|
// The password is still in memory at this point, which is the only time we
|
|
// can derive the key that encrypts the user's API keys.
|
|
const finishSignIn = async (userId: string) => {
|
|
const key = await deriveKey(form.password, userId);
|
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
|
|
|
return navigateTo(to ?? '/');
|
|
};
|
|
|
|
const backToCredentials = (error = '') => {
|
|
step.value = 'credentials';
|
|
twoFactor.code = '';
|
|
twoFactor.error = '';
|
|
|
|
if (error) {
|
|
passwordInputEl.value!.setCustomValidity(error);
|
|
passwordInputEl.value!.reportValidity();
|
|
}
|
|
};
|
|
|
|
const toggleBackupCode = async () => {
|
|
twoFactor.useBackupCode = !twoFactor.useBackupCode;
|
|
twoFactor.code = '';
|
|
twoFactor.error = '';
|
|
|
|
await focusCodeInput();
|
|
};
|
|
|
|
const submitTwoFactor = async () => {
|
|
const code = twoFactor.code.trim();
|
|
if (code === '' || loading.value) return;
|
|
|
|
loading.value = true;
|
|
twoFactor.error = '';
|
|
|
|
const res = await verifyTwoFactor(code, {
|
|
backupCode: twoFactor.useBackupCode,
|
|
trustDevice: twoFactor.trustDevice,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
loading.value = false;
|
|
|
|
if (res.error.data?.status === 429) {
|
|
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
|
|
return;
|
|
}
|
|
|
|
switch (res.error.data?.code) {
|
|
case 'INVALID_CODE':
|
|
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
|
|
break;
|
|
case 'INVALID_BACKUP_CODE':
|
|
twoFactor.error = 'That backup code is not valid, or it was already used.';
|
|
break;
|
|
case 'ACCOUNT_TEMPORARILY_LOCKED':
|
|
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
|
|
break;
|
|
case 'INVALID_TWO_FACTOR_COOKIE':
|
|
// the 10 minute window to enter a code ran out
|
|
backToCredentials('Sign in timed out. Enter your password again.');
|
|
break;
|
|
default:
|
|
twoFactor.error = 'Something went wrong. Try again.';
|
|
break;
|
|
}
|
|
|
|
twoFactor.code = '';
|
|
await focusCodeInput();
|
|
return;
|
|
}
|
|
|
|
await finishSignIn(res.data.user.id);
|
|
loading.value = false;
|
|
};
|
|
</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 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
|
|
</div>
|
|
<div
|
|
class="absolute -bottom-24 -right-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)]">
|
|
{{ step === 'credentials'
|
|
? 'Sign in to your account'
|
|
: twoFactor.useBackupCode
|
|
? 'Enter one of your backup codes'
|
|
: 'Enter the code from your authenticator app' }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="recovered !== undefined && step === 'credentials'" role="status"
|
|
class="flex gap-2.5 mb-4 px-3.5 py-3 rounded-lg bg-green-600/12 border border-green-400/40 text-sm leading-normal">
|
|
<span class="i-tabler-circle-check text-4.5 mt-0.5 flex-shrink-0 text-green-400" aria-hidden="true"></span>
|
|
<span>
|
|
<strong class="font-semibold">Your password is changed.</strong>
|
|
<span class="text-[var(--text-secondary)]">
|
|
Log in with the new one.
|
|
<template v-if="recovered === 'keys'">
|
|
Your saved API keys were deleted, add them again under Settings → AI Providers.
|
|
</template>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="rounded-2xl">
|
|
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
|
<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="current-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>
|
|
|
|
<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>Sign In</span>
|
|
</button>
|
|
</form>
|
|
|
|
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
|
|
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
|
|
<label for="two-factor-code"
|
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
|
Backup code
|
|
</label>
|
|
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
|
|
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
|
|
placeholder="xxxxx-xxxxx"
|
|
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
|
</div>
|
|
<fieldset v-else>
|
|
<legend
|
|
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
|
6-digit code
|
|
</legend>
|
|
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
|
|
@complete="submitTwoFactor" />
|
|
</fieldset>
|
|
|
|
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
|
|
{{ twoFactor.error }}
|
|
</p>
|
|
|
|
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
|
|
<input type="checkbox" v-model="twoFactor.trustDevice"
|
|
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
|
|
<span>Trust this browser for 30 days</span>
|
|
</label>
|
|
|
|
<button class="accent w-full 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>Verify</span>
|
|
</button>
|
|
|
|
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
|
<button type="button" @click="toggleBackupCode"
|
|
class="text-[var(--color-accent)] font-medium @hover:underline">
|
|
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
|
</button>
|
|
<button type="button" @click="backToCredentials()" class="@hover:underline">
|
|
Back to log in
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<p v-if="step === 'credentials'" class="text-sm text-center mt-3">
|
|
<NuxtLink to="/auth/recover"
|
|
class="text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:underline">
|
|
Forgot your password?
|
|
</NuxtLink>
|
|
</p>
|
|
|
|
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-1.5 text-[var(--text-secondary)]">
|
|
New to Veridian?
|
|
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
|
class="text-[var(--color-accent)] font-medium @hover:underline">
|
|
Create an account
|
|
</NuxtLink>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="absolute bottom-4 right-4">
|
|
<ThemeSwitcher size="medium" />
|
|
</div>
|
|
</div>
|
|
</template> |