feat: complete admin invite and user creation and recovery flows
This commit is contained in:
@@ -10,6 +10,10 @@ definePageMeta({
|
||||
|
||||
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 ?? '/');
|
||||
}
|
||||
@@ -236,6 +240,20 @@ const submitTwoFactor = async () => {
|
||||
</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">
|
||||
@@ -308,7 +326,14 @@ const submitTwoFactor = async () => {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const token = useRoute().params.token as string;
|
||||
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const { data: link } = await useAsyncData(`recovery_${token}`, () =>
|
||||
$fetch(`/api/recover/${encodeURIComponent(token)}`));
|
||||
|
||||
// set when the link turns out to be used or expired on submit
|
||||
const rejected = ref(false);
|
||||
|
||||
const valid = computed(() => !rejected.value && link.value?.valid ? link.value : null);
|
||||
|
||||
// An account an admin created has no password yet. Its owner picks the first
|
||||
// one here, and nothing is lost by that.
|
||||
const setup = computed(() => valid.value?.newAccount ? valid.value : null);
|
||||
// someone who forgot their password, which costs them their saved API keys
|
||||
const recovery = computed(() => valid.value && !valid.value.newAccount ? valid.value : null);
|
||||
|
||||
const keyCount = computed(() => recovery.value?.keyProviders.length ?? 0);
|
||||
|
||||
const form = reactive({
|
||||
password: '',
|
||||
confirm: '',
|
||||
});
|
||||
const understood = ref(false);
|
||||
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const confirmInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// nothing to agree to when there are no keys to lose
|
||||
const ready = computed(() => keyCount.value === 0 || understood.value);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || !ready.value) return;
|
||||
|
||||
if (form.password !== form.confirm) {
|
||||
confirmInputEl.value!.setCustomValidity('Passwords do not match');
|
||||
confirmInputEl.value!.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(`/api/recover/${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
body: { password: form.password },
|
||||
});
|
||||
} catch (err: any) {
|
||||
busy.value = false;
|
||||
|
||||
if (err?.data?.data?.code === 'RECOVERY_LINK_INVALID') {
|
||||
rejected.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Failed to reset password:', err);
|
||||
error.value = setup.value
|
||||
? 'The password could not be set. Try again.'
|
||||
: 'The password could not be changed. Nothing was deleted, try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
// a key left in this browser was derived from some other password
|
||||
localStorage.removeItem('encryptionKey');
|
||||
|
||||
// A new account goes straight in: the password is still in memory, which is
|
||||
// the only time the key for their API keys can be derived.
|
||||
if (setup.value) {
|
||||
const res = await signIn(setup.value.email, form.password);
|
||||
|
||||
if (res.ok && !res.data.twoFactorRequired) {
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo('/welcome');
|
||||
}
|
||||
|
||||
// the password is set, so logging in by hand still works
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
// the login page confirms the change, there is nothing left to do here
|
||||
return navigateTo(`/auth/login?recovered=${keyCount.value > 0 ? 'keys' : 'done'}`);
|
||||
};
|
||||
</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)]">
|
||||
<template v-if="setup">Welcome, {{ setup.name }}</template>
|
||||
<template v-else-if="recovery">Set a new password for {{ recovery.email }}</template>
|
||||
<template v-else>This link doesn't work</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="!valid">
|
||||
<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)]">
|
||||
This link doesn't work anymore. It was used already, has expired, or was replaced by a
|
||||
newer one. Ask whoever runs this instance for a new one.
|
||||
</p>
|
||||
<p class="text-sm text-center text-[var(--text-secondary)]">
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Back to log in
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="setup" class="text-sm leading-normal text-center text-[var(--text-secondary)]">
|
||||
An account was created for you as <strong class="font-medium text-[var(--text-primary)]">{{
|
||||
setup.email }}</strong>. Choose a password to start using it.
|
||||
</p>
|
||||
|
||||
<div v-if="recovery" role="note"
|
||||
class="flex flex-col gap-2 px-3 py-3 rounded-lg border text-sm leading-normal"
|
||||
:class="keyCount > 0 ? 'border-red-400/40 bg-red-600/12' : 'border-[var(--color-border)]'">
|
||||
<template v-if="keyCount > 0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-tabler-alert-triangle text-4.5 flex-shrink-0 text-red-400"
|
||||
aria-hidden="true"></span>
|
||||
<strong class="font-semibold">
|
||||
Your {{ keyCount }} saved API {{ keyCount === 1 ? 'key' : 'keys' }} will be deleted
|
||||
</strong>
|
||||
</div>
|
||||
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||
They were encrypted with your old password. Without it nobody can decrypt them, not
|
||||
you, not an admin, not the server. There is no way to get them back.
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="text-[13px] text-[var(--text-secondary)]">
|
||||
You have no saved API keys, so nothing of yours is lost.
|
||||
</p>
|
||||
|
||||
<ul class="flex flex-col gap-1 text-[13px]">
|
||||
<li v-if="keyCount > 0" class="flex items-start gap-2">
|
||||
<span class="i-tabler-trash text-4 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
|
||||
<span>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
|
||||
</li>
|
||||
<li v-if="recovery.resetSecurity" class="flex items-start gap-2">
|
||||
<span class="i-tabler-shield-off text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
|
||||
aria-hidden="true"></span>
|
||||
<span>Reset: two-factor sign-in, all active sessions</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="i-tabler-circle-check text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
|
||||
aria-hidden="true"></span>
|
||||
<span>Kept: chats, agents, files, provider and model lists, settings</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<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">{{
|
||||
setup ? 'Password' : 'New password' }}</label>
|
||||
<input required minlength="8" maxlength="128" type="password" autocomplete="new-password"
|
||||
id="password" v-model="form.password" placeholder="••••••••" :disabled="busy"
|
||||
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="confirmInputEl" type="password"
|
||||
autocomplete="new-password" id="confirmPassword" v-model="form.confirm"
|
||||
placeholder="••••••••" :disabled="busy" @input="confirmInputEl?.setCustomValidity('')"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<label v-if="keyCount > 0" class="flex items-start gap-2.5 mt-1 text-[13px] leading-normal cursor-pointer">
|
||||
<!-- a plain checkbox: the auth layout sizes every input as a text field -->
|
||||
<input v-model="understood" type="checkbox" :disabled="busy"
|
||||
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
|
||||
<span>
|
||||
I understand my API keys will be permanently deleted and I'll need to add them again.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="setup" 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="busy || !ready" type="submit"
|
||||
class="accent w-full mt-2 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>{{ setup ? 'Set password and continue' : keyCount > 0 ? 'Reset password and delete keys' : 'Reset password' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="recovery" class="text-sm text-center text-[var(--text-secondary)]">
|
||||
Remember your old password after all?
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Log in instead
|
||||
</NuxtLink>
|
||||
and change it from Settings to keep your keys.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
</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)]">Forgot your password?</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||
This instance doesn't send email. An administrator can create a one-time recovery link for
|
||||
you.
|
||||
</p>
|
||||
|
||||
<ol class="flex flex-col gap-2.5 text-sm">
|
||||
<li v-for="(step, index) in [
|
||||
'Contact whoever runs this instance.',
|
||||
'They send you a link that works once and expires in 24 hours.',
|
||||
'Open it here and choose a new password.',
|
||||
]" :key="step" class="flex items-start gap-3">
|
||||
<span aria-hidden="true"
|
||||
class="flex items-center justify-center w-6 h-6 flex-shrink-0 rounded-md border border-[var(--color-border-active)] text-xs text-[var(--text-secondary)]">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="mt-0.5">{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-[13px] leading-normal text-[var(--text-secondary)]">
|
||||
Operators locked out of the only admin account can run
|
||||
<code class="px-1.5 py-0.5 rounded bg-[var(--bg-container)] font-mono text-xs text-[var(--text-primary)] whitespace-nowrap">bun run recover <email></code>
|
||||
on the server to print a link.
|
||||
</p>
|
||||
|
||||
<div role="note"
|
||||
class="flex gap-2.5 px-3.5 py-3 rounded-lg bg-red-600/12 border border-red-400/40 text-sm leading-normal">
|
||||
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400"
|
||||
aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong class="font-semibold">Recovery deletes your saved API keys.</strong>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
They are encrypted with your current password and can't be read without it. Your
|
||||
chats, agents and files are kept.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-center text-[var(--text-secondary)]">
|
||||
Remembered it?
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Back to log in
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -157,7 +157,7 @@ const submit = async () => {
|
||||
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">
|
||||
<NuxtLink to="/auth/login" class="w-full h-11 flex items-center justify-center font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
|
||||
Go to log in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ type UserRow = {
|
||||
role: 'admin' | 'user';
|
||||
banned: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
// created by an admin and has not set a password yet
|
||||
pending: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt: string | null;
|
||||
};
|
||||
@@ -139,6 +141,12 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
|
||||
return !error;
|
||||
});
|
||||
|
||||
// The dialog makes the link and shows it, nothing in the list changes. For an
|
||||
// account that has no password yet it is a new setup link instead.
|
||||
const recoverAccount = (user: UserRow) => {
|
||||
openDialog(DialogType.RecoverAccount, undefined, { user });
|
||||
};
|
||||
|
||||
const resetTwoFactor = (user: UserRow) => confirmThen({
|
||||
title: `Reset two-factor for ${user.name}?`,
|
||||
message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.',
|
||||
@@ -196,9 +204,8 @@ const createInvite = () => {
|
||||
};
|
||||
|
||||
const openCreateUser = () => {
|
||||
openDialog(DialogType.CreateUser, async (result) => {
|
||||
if (result.ok === false) return;
|
||||
|
||||
// the dialog can be closed after the account was made, so always reload
|
||||
openDialog(DialogType.CreateUser, async () => {
|
||||
await loadUsers();
|
||||
});
|
||||
};
|
||||
@@ -278,9 +285,9 @@ const openCreateUser = () => {
|
||||
</span>
|
||||
|
||||
<span role="cell" class="flex items-center gap-1.5 text-[13px]"
|
||||
:class="u.banned ? 'text-red-400' : 'text-green-400'">
|
||||
:class="u.banned ? 'text-red-400' : u.pending ? 'text-amber-400' : 'text-green-400'">
|
||||
<span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span>
|
||||
{{ u.banned ? 'Disabled' : 'Active' }}
|
||||
{{ u.banned ? 'Disabled' : u.pending ? 'Invited' : 'Active' }}
|
||||
</span>
|
||||
|
||||
<span role="cell" class="text-[13px]"
|
||||
@@ -323,6 +330,11 @@ const openCreateUser = () => {
|
||||
<span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span>
|
||||
Make admin
|
||||
</button>
|
||||
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); recoverAccount(u)">
|
||||
<span class="i-tabler-key text-4.5" aria-hidden="true"></span>
|
||||
{{ u.pending ? 'New setup link…' : 'Recover account…' }}
|
||||
</button>
|
||||
<button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); resetTwoFactor(u)">
|
||||
<span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span>
|
||||
@@ -365,7 +377,8 @@ const openCreateUser = () => {
|
||||
|
||||
<p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]">
|
||||
Admins manage accounts, not content: chats, agents, files and API keys of other users are never
|
||||
shown here. Disabled accounts can't sign in and nothing of theirs is deleted. Your own account is
|
||||
shown here. Invited accounts have no password until their owner opens the setup link. Disabled
|
||||
accounts can't sign in and nothing of theirs is deleted. Your own account is
|
||||
managed from Profile and Security, and you can't remove your own admin role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user