feat: complete admin invite and user creation and recovery flows

This commit is contained in:
Zoe
2026-10-08 14:24:47 -05:00
parent 61f9bb74b4
commit b6f4d85ed7
23 changed files with 4430 additions and 110 deletions
+98 -94
View File
@@ -1,46 +1,36 @@
<script setup lang="ts">
const MIN_PASSWORD_LENGTH = 8;
const GENERATED_PASSWORD_LENGTH = 16;
// no look-alikes (0/O, 1/l/I), this gets read out or typed by hand
const PASSWORD_ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient } = useAuth();
const form = reactive({
name: '',
email: '',
password: '',
});
const busy = ref(false);
const error = ref('');
// set once the account exists, the dialog then shows its setup link
const link = ref('');
const copied = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
nameInputEl.value?.focus();
});
const ready = computed(() =>
form.name.trim() !== '' && form.email.trim() !== '' && form.password.length >= MIN_PASSWORD_LENGTH);
const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
const generatePassword = () => {
// values past the last full run of the alphabet are skipped, so every
// character stays equally likely
const limit = 256 - (256 % PASSWORD_ALPHABET.length);
let password = '';
while (password.length < GENERATED_PASSWORD_LENGTH) {
for (const value of crypto.getRandomValues(new Uint8Array(GENERATED_PASSWORD_LENGTH))) {
if (value >= limit || password.length === GENERATED_PASSWORD_LENGTH) continue;
password += PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length];
}
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy setup link:', err);
}
form.password = password;
};
const submit = async () => {
@@ -49,17 +39,20 @@ const submit = async () => {
busy.value = true;
error.value = '';
const { error: createError } = await authClient.admin.createUser({
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
role: 'user',
});
try {
const { token } = await $fetch<{ token: string }>('/api/admin/users', {
method: 'POST',
body: {
name: form.name.trim(),
email: form.email.trim(),
},
});
busy.value = false;
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err: any) {
busy.value = false;
if (createError) {
switch (createError.code) {
switch (err?.data?.data?.code as string | undefined) {
case 'USER_ALREADY_EXISTS':
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
error.value = 'An account with this email already exists.';
@@ -67,14 +60,11 @@ const submit = async () => {
case 'INVALID_EMAIL':
error.value = 'That email address is not valid.';
break;
case 'PASSWORD_TOO_SHORT':
error.value = `The password needs at least ${MIN_PASSWORD_LENGTH} characters.`;
break;
case 'PASSWORD_TOO_LONG':
error.value = 'That password is too long.';
case 'INVALID_BODY':
error.value = 'Check the name and the email address.';
break;
default:
console.error('Failed to create user:', createError);
console.error('Failed to create user:', err);
error.value = 'The account could not be created. Try again.';
break;
}
@@ -82,7 +72,7 @@ const submit = async () => {
return;
}
emit('confirm', true);
busy.value = false;
};
</script>
@@ -90,66 +80,80 @@ const submit = async () => {
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="create-user-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="create-user-title" class="text-lg font-semibold">Create user</h2>
<h2 id="create-user-title" class="text-lg font-semibold">
{{ link ? 'Account created' : 'Create user' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Creates the account right away. Nothing is sent to them, so pass the password on yourself.
{{ link
? `Send ${form.name.trim()} this link. They open it, choose a password and are in.`
: 'Creates the account without a password. You get a link for them to choose their own, so you never know it.' }}
</p>
</header>
<div class="flex flex-col gap-1.5">
<label for="create-user-name"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Name
</label>
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required maxlength="128"
:disabled="busy" autocomplete="off"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Temporary password
</label>
<div class="flex gap-2">
<!-- shown in the clear, the admin has to read it to pass it on -->
<input id="create-user-password" v-model="form.password" type="text" required
:minlength="MIN_PASSWORD_LENGTH" maxlength="128" :disabled="busy" autocomplete="off"
autocapitalize="off" spellcheck="false"
class="flex-1 min-w-0 h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)]" />
<button type="button" :disabled="busy" @click="generatePassword"
class="flex items-center h-9 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Generate
</button>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="create-user-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Setup link
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="create-user-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
user's menu. Nobody can sign in to the account until it is used.
</span>
</div>
<p class="text-xs leading-normal text-[var(--text-secondary)]">
At least {{ MIN_PASSWORD_LENGTH }} characters. They can change it under Settings → Security
after signing in.
</p>
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-end mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</footer>
</template>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
<template v-else>
<div class="flex flex-col gap-1.5">
<label for="create-user-name"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Name
</label>
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
maxlength="128" :disabled="busy" autocomplete="off"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
</template>
</form>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
image?: string | null;
twoFactorEnabled?: boolean;
// has no password yet, so this makes a setup link and nothing is lost
pending?: boolean;
};
}>();
const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off and sign every device out when the link is used
const resetSecurity = ref(true);
const busy = ref(false);
const error = ref('');
// set once the link exists, the dialog then shows it instead of the options
const link = ref('');
const copied = ref(false);
const revoked = ref(false);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy recovery link:', err);
}
};
const create = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
const { token } = await $fetch<{ token: string }>(`/api/admin/users/${props.user.id}/recovery`, {
method: 'POST',
body: { resetSecurity: resetSecurity.value },
});
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err) {
console.error('Failed to create recovery link:', err);
error.value = 'The recovery link could not be created. Try again.';
}
busy.value = false;
};
const revoke = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await $fetch(`/api/admin/users/${props.user.id}/recovery`, { method: 'DELETE' });
link.value = '';
revoked.value = true;
} catch (err) {
console.error('Failed to revoke recovery link:', err);
error.value = 'The link could not be revoked. Try again.';
}
busy.value = false;
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="recover-account-title" @submit.prevent="create">
<header class="flex items-center gap-3">
<UserAvatar :user="user" class="w-10 h-10 rounded-lg"></UserAvatar>
<div class="flex flex-col min-w-0">
<h2 id="recover-account-title" class="text-lg font-semibold">
{{ user.pending ? 'Account setup link' : 'Recover account' }}
</h2>
<p class="text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ user.name }} · {{ user.email }}
</p>
</div>
</header>
<p v-if="user.pending && !link" class="text-sm leading-normal text-[var(--text-secondary)]">
This account has no password yet. A new link lets them choose one, and replaces the link they
were given before.
</p>
<div v-if="!user.pending" role="note" class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 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>
<div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
kept.
</span>
</div>
</div>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="recover-account-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
{{ user.pending ? 'Setup link' : 'Recovery link' }}
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="recover-account-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy recovery link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in {{ user.pending ? '7 days' : '24 hours' }}. Shown only now; closing
this dialog hides it for good.
Send it to them yourself, over a channel you trust.
</span>
</div>
</template>
<template v-else>
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer">
<input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" />
<span>Also reset two-factor and sign out all their sessions</span>
</label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
The link was revoked and no longer works.
</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<template v-if="link">
<button type="button" :disabled="busy" @click="revoke"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Revoke link
</button>
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</template>
<template v-else>
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
{{ revoked ? 'Close' : 'Cancel' }}
</button>
<button type="submit" :disabled="busy"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ user.pending ? 'Create setup link' : 'Create recovery link' }}
</button>
</template>
</footer>
</form>
</template>
+3
View File
@@ -7,6 +7,7 @@ import DeleteAccount from './DeleteAccount.vue';
import Confirm from './Confirm.vue';
import CreateUser from './CreateUser.vue';
import CreateInvite from './CreateInvite.vue';
import RecoverAccount from './RecoverAccount.vue';
import { DialogType } from '~/composables/useDialog';
@@ -74,6 +75,8 @@ onUnmounted(() => {
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
<CreateUser v-else-if="page === DialogType.CreateUser" @confirm="confirm" @cancel="close" />
<CreateInvite v-else-if="page === DialogType.CreateInvite" @confirm="confirm" @cancel="close" />
<RecoverAccount v-else-if="page === DialogType.RecoverAccount" :user="data.user" @confirm="confirm"
@cancel="close" />
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
</div>
</div>
+1
View File
@@ -9,6 +9,7 @@ export enum DialogType {
DeleteAccount = 'delete-account',
CreateUser = 'create-user',
CreateInvite = 'create-invite',
RecoverAccount = 'recover-account',
}
interface DialogOptions {
+26 -1
View File
@@ -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">
+231
View File
@@ -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>
+69
View File
@@ -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 &lt;email&gt;</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>
+1 -1
View File
@@ -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>
+19 -6
View File
@@ -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>