feat: complete admin invite and user creation and recovery flows
This commit is contained in:
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
|
||||
# Copy entrypoint script
|
||||
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
|
||||
|
||||
# Copy the account recovery script (bun run recover <email>)
|
||||
COPY --from=build /app/scripts/recover.ts /app/scripts/recover.ts
|
||||
|
||||
# Copy package.json and lockfile for production dependency install
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=build /app/bun.lock* ./
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum DialogType {
|
||||
DeleteAccount = 'delete-account',
|
||||
CreateUser = 'create-user',
|
||||
CreateInvite = 'create-invite',
|
||||
RecoverAccount = 'recover-account',
|
||||
}
|
||||
|
||||
interface DialogOptions {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "recovery_links" (
|
||||
"id" text PRIMARY KEY,
|
||||
"token_hash" text NOT NULL UNIQUE,
|
||||
"user_id" text NOT NULL,
|
||||
"reset_security" boolean DEFAULT true NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "recovery_links_userId_idx" ON "recovery_links" ("user_id");--> statement-breakpoint
|
||||
ALTER TABLE "recovery_links" ADD CONSTRAINT "recovery_links_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -374,3 +374,21 @@ export const invites = pgTable(
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
},
|
||||
);
|
||||
|
||||
// One-time links that let someone who forgot their password set a new one.
|
||||
// Only a hash of the token is kept, the link itself is shown once when made.
|
||||
export const recoveryLinks = pgTable(
|
||||
"recovery_links",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(nanoid),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// also turn two-factor off and sign every device out when it is used
|
||||
resetSecurity: boolean("reset_security").notNull().default(true),
|
||||
expiresAt: timestamptz("expires_at").notNull(),
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("recovery_links_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
+9
-5
@@ -18,10 +18,12 @@ const accessControl = createAccessControl(defaultStatements);
|
||||
// another user's password or email are left out on purpose: the first would
|
||||
// show their chats, the others would lock them out of their encrypted API keys.
|
||||
// Deleting is left out too, because Better Auth would only remove the account
|
||||
// and leave its agents, topics and files behind. /api/admin/users does that.
|
||||
// and leave its agents, topics and files behind. Creating is left out so new
|
||||
// accounts are always made without a password and with a setup link.
|
||||
// /api/admin/users does both.
|
||||
const roles = {
|
||||
admin: accessControl.newRole({
|
||||
user: ['create', 'list', 'set-role', 'ban', 'get', 'update'],
|
||||
user: ['list', 'set-role', 'ban', 'get', 'update'],
|
||||
session: ['list', 'revoke', 'delete'],
|
||||
}),
|
||||
user: accessControl.newRole({
|
||||
@@ -74,9 +76,11 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// creating a user as an admin skips the sign up password rules
|
||||
if (ctx.path === '/admin/create-user') {
|
||||
const password = ctx.body?.password;
|
||||
// Creating a user as an admin skips the sign up password rules. No
|
||||
// password at all is fine: that account can't be signed in to until
|
||||
// its owner sets one with a setup link.
|
||||
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
|
||||
const password = ctx.body.password;
|
||||
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new APIError('BAD_REQUEST', {
|
||||
message: 'Password is too short',
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"recover": "bun scripts/recover.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.71",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Prints a one-time recovery link for an account. Meant for whoever runs the
|
||||
* server when nobody can sign in as an admin anymore:
|
||||
*
|
||||
* bun run recover <email>
|
||||
*
|
||||
* It only talks to the database, the server does not have to be running. The
|
||||
* link works like the ones admins make under Settings → Users: it lets the
|
||||
* account set a new password, deletes its saved API keys, turns two-factor
|
||||
* off and signs every device out.
|
||||
*
|
||||
* Kept free of imports from the app so it also runs in the production image.
|
||||
* The link has to be made the same way as in server/utils/recovery.ts.
|
||||
*/
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const main = async (): Promise<number> => {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
console.error('Usage: bun run recover <email>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
|
||||
});
|
||||
|
||||
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
|
||||
const user = found.rows[0] as { id: string; name: string } | undefined;
|
||||
|
||||
if (!user) {
|
||||
console.error(`No account with the email ${email}.`);
|
||||
await pool.end();
|
||||
return 1;
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + LIFETIME_MS);
|
||||
|
||||
// only one link works at a time
|
||||
await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
|
||||
await pool.query(
|
||||
'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
|
||||
[randomUUID(), tokenHash, user.id, expiresAt],
|
||||
);
|
||||
await pool.end();
|
||||
|
||||
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
||||
|
||||
console.log(`Recovery link for ${user.name} <${email}>:`);
|
||||
console.log('');
|
||||
console.log(` ${baseUrl}/auth/recover/${token}`);
|
||||
console.log('');
|
||||
console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
|
||||
console.log('of this account, turns two-factor off and signs every device out.');
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
process.exit(await main());
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { recoveryLinks } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/** Cancels the recovery link of a user, if there is one. */
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
await db.delete(recoveryLinks).where(eq(recoveryLinks.userId, getRouterParam(event, 'id')!));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as z from 'zod';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Makes a one-time link the user can set a new password with, or their first
|
||||
* one if the account was created by an admin and never had a password. The link is
|
||||
* only returned here, afterwards nobody can read it back, admins included.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
// also turn two-factor off and sign every device out
|
||||
resetSecurity: z.boolean(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await createRecoveryLink(id, result.data.resetSecurity);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, eq, max } from 'drizzle-orm';
|
||||
import { sessions, users } from '~~/drizzle/schema';
|
||||
import { asc, eq, max, sql } from 'drizzle-orm';
|
||||
import { accounts, sessions, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,9 @@ export default defineEventHandler(async (event) => {
|
||||
// sessions are touched as they are used, the newest one is the
|
||||
// closest thing there is to a last seen time
|
||||
lastActiveAt: max(sessions.updatedAt),
|
||||
// no password yet: created by an admin and still waiting for its
|
||||
// owner to open the setup link
|
||||
pending: sql<boolean>`not exists (select 1 from ${accounts} where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'credential')`,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(sessions, eq(sessions.userId, users.id))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as z from 'zod';
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
/**
|
||||
* Creates an account for someone, without a password. Nobody can sign in to
|
||||
* it until its owner opens the setup link returned here and picks one, so the
|
||||
* admin never knows a password of theirs. The link is only returned here.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
email: z.email(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email } = result.data;
|
||||
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
// called without the request, the admin check already happened above
|
||||
const { user } = await auth.api.createUser({
|
||||
body: {
|
||||
name,
|
||||
email,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
userId = user.id;
|
||||
} catch (error: any) {
|
||||
const code = error?.body?.code as string | undefined;
|
||||
|
||||
if (code === undefined) {
|
||||
console.error('Failed to create user:', error);
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: code === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to create user',
|
||||
data: {
|
||||
code: code ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await createRecoveryLink(userId, false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { providers } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Tells the recovery page whether a link still works and what using it will
|
||||
* do: set the first password of a new account, or replace a forgotten one. Public on purpose: whoever opens it cannot sign in, that is the point.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const link = await findRecoveryLink(getRouterParam(event, 'token')!);
|
||||
|
||||
if (!link) {
|
||||
return { valid: false as const };
|
||||
}
|
||||
|
||||
// an account that never had a password is being set up, not recovered
|
||||
if (!await hasPassword(link.userId)) {
|
||||
return {
|
||||
valid: true as const,
|
||||
newAccount: true as const,
|
||||
email: link.email,
|
||||
name: link.name,
|
||||
};
|
||||
}
|
||||
|
||||
const withKey = await db
|
||||
.select({ name: providers.name })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
|
||||
|
||||
return {
|
||||
valid: true as const,
|
||||
newAccount: false as const,
|
||||
email: link.email,
|
||||
// the providers whose saved key is lost when the link is used
|
||||
keyProviders: withKey.map(p => p.name),
|
||||
resetSecurity: link.resetSecurity,
|
||||
twoFactorEnabled: !!link.twoFactorEnabled,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Sets a password with a one-time link.
|
||||
*
|
||||
* For an account an admin just created this is its first password, and there
|
||||
* is nothing else to do. For a forgotten password it is a recovery:
|
||||
*
|
||||
* The saved API keys are encrypted with a key derived from the old password.
|
||||
* Nobody has that password anymore, so the keys can never be read again and
|
||||
* are deleted here. Chats, agents, files and everything else stay.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
|
||||
const { password } = result.data;
|
||||
|
||||
const { password: hasher, internalAdapter } = await auth.$context;
|
||||
const passwordHash = await hasher.hash(password);
|
||||
|
||||
const recovered = await db.transaction(async (tx) => {
|
||||
// deleting the link is what makes it work once: of two requests with
|
||||
// the same link only one gets a row back
|
||||
const [link] = await tx.delete(recoveryLinks)
|
||||
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
|
||||
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
|
||||
|
||||
if (!link) return null;
|
||||
|
||||
// if either of these fails the link comes back with the rest of the
|
||||
// transaction
|
||||
if (!await hasPassword(link.userId)) {
|
||||
await internalAdapter.linkAccount({
|
||||
providerId: 'credential',
|
||||
accountId: link.userId,
|
||||
password: passwordHash,
|
||||
userId: link.userId,
|
||||
});
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
await internalAdapter.updatePassword(link.userId, passwordHash);
|
||||
|
||||
// A provider without its key can't be used, so it is switched off
|
||||
// until the key is entered again. The provider and its models stay.
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: sql`${providers.config} - 'apiKey'`,
|
||||
enabled: false,
|
||||
})
|
||||
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
|
||||
|
||||
if (link.resetSecurity) {
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
|
||||
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
|
||||
}
|
||||
|
||||
return 'recovered';
|
||||
});
|
||||
|
||||
if (recovered === null) {
|
||||
throw createError({
|
||||
statusCode: 410,
|
||||
statusMessage: 'This recovery link is not valid anymore',
|
||||
data: {
|
||||
code: 'RECOVERY_LINK_INVALID',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { newAccount: recovered === 'created' };
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, lte, sql } from 'drizzle-orm';
|
||||
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
// a new person may take a while to get round to it
|
||||
export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* An account an admin created has no password until its owner sets one with
|
||||
* a setup link. Until then nobody can sign in to it.
|
||||
*/
|
||||
export const hasPassword = async (userId: string): Promise<boolean> => {
|
||||
const rows = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
// scripts/recover.ts makes links the same way, keep the two in step
|
||||
export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
/**
|
||||
* Makes a one-time link a user can set a password with and returns its token:
|
||||
* a recovery link for someone who forgot theirs, a setup link for an account
|
||||
* that never had one. Only one link works at a time, making a new one cancels
|
||||
* the ones before it.
|
||||
*/
|
||||
export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
|
||||
const expiresAt = new Date(Date.now() + lifetime);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
|
||||
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
|
||||
await tx.insert(recoveryLinks).values({
|
||||
tokenHash: hashRecoveryToken(token),
|
||||
userId,
|
||||
resetSecurity,
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
export const findRecoveryLink = async (token: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: recoveryLinks.id,
|
||||
userId: recoveryLinks.userId,
|
||||
resetSecurity: recoveryLinks.resetSecurity,
|
||||
expiresAt: recoveryLinks.expiresAt,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(recoveryLinks)
|
||||
.innerJoin(users, eq(users.id, recoveryLinks.userId))
|
||||
.where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
Reference in New Issue
Block a user