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 entrypoint script
|
||||||
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
|
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 package.json and lockfile for production dependency install
|
||||||
COPY --from=build /app/package.json /app/package.json
|
COPY --from=build /app/package.json /app/package.json
|
||||||
COPY --from=build /app/bun.lock* ./
|
COPY --from=build /app/bun.lock* ./
|
||||||
|
|||||||
@@ -1,46 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<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 emit = defineEmits(['confirm', 'cancel']);
|
||||||
|
|
||||||
const { client: authClient } = useAuth();
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref('');
|
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);
|
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nameInputEl.value?.focus();
|
nameInputEl.value?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ready = computed(() =>
|
const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
|
||||||
form.name.trim() !== '' && form.email.trim() !== '' && form.password.length >= MIN_PASSWORD_LENGTH);
|
|
||||||
|
|
||||||
const generatePassword = () => {
|
const copyLink = async () => {
|
||||||
// values past the last full run of the alphabet are skipped, so every
|
try {
|
||||||
// character stays equally likely
|
await navigator.clipboard.writeText(link.value);
|
||||||
const limit = 256 - (256 % PASSWORD_ALPHABET.length);
|
copied.value = true;
|
||||||
let password = '';
|
setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
while (password.length < GENERATED_PASSWORD_LENGTH) {
|
}, 2000);
|
||||||
for (const value of crypto.getRandomValues(new Uint8Array(GENERATED_PASSWORD_LENGTH))) {
|
} catch (err) {
|
||||||
if (value >= limit || password.length === GENERATED_PASSWORD_LENGTH) continue;
|
console.error('Failed to copy setup link:', err);
|
||||||
|
|
||||||
password += PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form.password = password;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -49,17 +39,20 @@ const submit = async () => {
|
|||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
|
||||||
const { error: createError } = await authClient.admin.createUser({
|
try {
|
||||||
name: form.name.trim(),
|
const { token } = await $fetch<{ token: string }>('/api/admin/users', {
|
||||||
email: form.email.trim(),
|
method: 'POST',
|
||||||
password: form.password,
|
body: {
|
||||||
role: 'user',
|
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 (err?.data?.data?.code as string | undefined) {
|
||||||
switch (createError.code) {
|
|
||||||
case 'USER_ALREADY_EXISTS':
|
case 'USER_ALREADY_EXISTS':
|
||||||
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
|
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
|
||||||
error.value = 'An account with this email already exists.';
|
error.value = 'An account with this email already exists.';
|
||||||
@@ -67,14 +60,11 @@ const submit = async () => {
|
|||||||
case 'INVALID_EMAIL':
|
case 'INVALID_EMAIL':
|
||||||
error.value = 'That email address is not valid.';
|
error.value = 'That email address is not valid.';
|
||||||
break;
|
break;
|
||||||
case 'PASSWORD_TOO_SHORT':
|
case 'INVALID_BODY':
|
||||||
error.value = `The password needs at least ${MIN_PASSWORD_LENGTH} characters.`;
|
error.value = 'Check the name and the email address.';
|
||||||
break;
|
|
||||||
case 'PASSWORD_TOO_LONG':
|
|
||||||
error.value = 'That password is too long.';
|
|
||||||
break;
|
break;
|
||||||
default:
|
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.';
|
error.value = 'The account could not be created. Try again.';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -82,7 +72,7 @@ const submit = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('confirm', true);
|
busy.value = false;
|
||||||
};
|
};
|
||||||
</script>
|
</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"
|
<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">
|
aria-labelledby="create-user-title" @submit.prevent="submit">
|
||||||
<header class="flex flex-col gap-1">
|
<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)]">
|
<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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<template v-if="link">
|
||||||
<label for="create-user-name"
|
<div class="flex flex-col gap-1.5">
|
||||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
<label for="create-user-link"
|
||||||
Name
|
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
</label>
|
Setup link
|
||||||
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required maxlength="128"
|
</label>
|
||||||
:disabled="busy" autocomplete="off"
|
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
|
||||||
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
<input id="create-user-link" type="text" readonly :value="link"
|
||||||
</div>
|
@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" />
|
||||||
<div class="flex flex-col gap-1.5">
|
<button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
|
||||||
<label for="create-user-email"
|
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
|
||||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||||
Email
|
</button>
|
||||||
</label>
|
</div>
|
||||||
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
|
<span class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||||
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
|
||||||
</div>
|
user's menu. Nobody can sign in to the account until it is used.
|
||||||
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<template v-else>
|
||||||
<button type="button" :disabled="busy"
|
<div class="flex flex-col gap-1.5">
|
||||||
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"
|
<label for="create-user-name"
|
||||||
@click="emit('cancel')">
|
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
Cancel
|
Name
|
||||||
</button>
|
</label>
|
||||||
<button type="submit" :disabled="busy || !ready"
|
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
|
||||||
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">
|
maxlength="128" :disabled="busy" autocomplete="off"
|
||||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||||
Create user
|
</div>
|
||||||
</button>
|
|
||||||
</footer>
|
<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>
|
</form>
|
||||||
</template>
|
</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 Confirm from './Confirm.vue';
|
||||||
import CreateUser from './CreateUser.vue';
|
import CreateUser from './CreateUser.vue';
|
||||||
import CreateInvite from './CreateInvite.vue';
|
import CreateInvite from './CreateInvite.vue';
|
||||||
|
import RecoverAccount from './RecoverAccount.vue';
|
||||||
|
|
||||||
import { DialogType } from '~/composables/useDialog';
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
|
||||||
@@ -74,6 +75,8 @@ onUnmounted(() => {
|
|||||||
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
|
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
|
||||||
<CreateUser v-else-if="page === DialogType.CreateUser" @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" />
|
<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" />
|
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum DialogType {
|
|||||||
DeleteAccount = 'delete-account',
|
DeleteAccount = 'delete-account',
|
||||||
CreateUser = 'create-user',
|
CreateUser = 'create-user',
|
||||||
CreateInvite = 'create-invite',
|
CreateInvite = 'create-invite',
|
||||||
|
RecoverAccount = 'recover-account',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogOptions {
|
interface DialogOptions {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ definePageMeta({
|
|||||||
|
|
||||||
const to = useRoute().query.to as string | undefined;
|
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) {
|
if (session.value !== null) {
|
||||||
await navigateTo(to ?? '/');
|
await navigateTo(to ?? '/');
|
||||||
}
|
}
|
||||||
@@ -236,6 +240,20 @@ const submitTwoFactor = async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="rounded-2xl">
|
||||||
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -308,7 +326,14 @@ const submitTwoFactor = async () => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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?
|
New to Veridian?
|
||||||
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
||||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
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
|
Someone else just created the first account, so this instance already has its
|
||||||
administrator. Nothing was created for you.
|
administrator. Nothing was created for you.
|
||||||
</p>
|
</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
|
Go to log in
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type UserRow = {
|
|||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
|
// created by an admin and has not set a password yet
|
||||||
|
pending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastActiveAt: string | null;
|
lastActiveAt: string | null;
|
||||||
};
|
};
|
||||||
@@ -139,6 +141,12 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
|
|||||||
return !error;
|
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({
|
const resetTwoFactor = (user: UserRow) => confirmThen({
|
||||||
title: `Reset two-factor for ${user.name}?`,
|
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.',
|
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 = () => {
|
const openCreateUser = () => {
|
||||||
openDialog(DialogType.CreateUser, async (result) => {
|
// the dialog can be closed after the account was made, so always reload
|
||||||
if (result.ok === false) return;
|
openDialog(DialogType.CreateUser, async () => {
|
||||||
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -278,9 +285,9 @@ const openCreateUser = () => {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span role="cell" class="flex items-center gap-1.5 text-[13px]"
|
<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>
|
<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>
|
||||||
|
|
||||||
<span role="cell" class="text-[13px]"
|
<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>
|
<span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span>
|
||||||
Make admin
|
Make admin
|
||||||
</button>
|
</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"
|
<button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||||
@click="close(); resetTwoFactor(u)">
|
@click="close(); resetTwoFactor(u)">
|
||||||
<span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span>
|
<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)]">
|
<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
|
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.
|
managed from Profile and Security, and you can't remove your own admin role.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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(),
|
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
|
// 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.
|
// 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
|
// 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 = {
|
const roles = {
|
||||||
admin: accessControl.newRole({
|
admin: accessControl.newRole({
|
||||||
user: ['create', 'list', 'set-role', 'ban', 'get', 'update'],
|
user: ['list', 'set-role', 'ban', 'get', 'update'],
|
||||||
session: ['list', 'revoke', 'delete'],
|
session: ['list', 'revoke', 'delete'],
|
||||||
}),
|
}),
|
||||||
user: accessControl.newRole({
|
user: accessControl.newRole({
|
||||||
@@ -74,9 +76,11 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// creating a user as an admin skips the sign up password rules
|
// Creating a user as an admin skips the sign up password rules. No
|
||||||
if (ctx.path === '/admin/create-user') {
|
// password at all is fine: that account can't be signed in to until
|
||||||
const password = ctx.body?.password;
|
// 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) {
|
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||||
throw new APIError('BAD_REQUEST', {
|
throw new APIError('BAD_REQUEST', {
|
||||||
message: 'Password is too short',
|
message: 'Password is too short',
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
|
"recover": "bun scripts/recover.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.71",
|
"@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 { asc, eq, max, sql } from 'drizzle-orm';
|
||||||
import { sessions, users } from '~~/drizzle/schema';
|
import { accounts, sessions, users } from '~~/drizzle/schema';
|
||||||
import { db } from '~~/server/lib/db';
|
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
|
// sessions are touched as they are used, the newest one is the
|
||||||
// closest thing there is to a last seen time
|
// closest thing there is to a last seen time
|
||||||
lastActiveAt: max(sessions.updatedAt),
|
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)
|
.from(users)
|
||||||
.leftJoin(sessions, eq(sessions.userId, users.id))
|
.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