feat: complete admin invite and user creation and recovery flows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user