feat: complete admin invite and user creation and recovery flows

This commit is contained in:
Zoe
2026-10-08 14:24:47 -05:00
parent 61f9bb74b4
commit b6f4d85ed7
23 changed files with 4430 additions and 110 deletions
+98 -94
View File
@@ -1,46 +1,36 @@
<script setup lang="ts">
const MIN_PASSWORD_LENGTH = 8;
const GENERATED_PASSWORD_LENGTH = 16;
// no look-alikes (0/O, 1/l/I), this gets read out or typed by hand
const PASSWORD_ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient } = useAuth();
const form = reactive({
name: '',
email: '',
password: '',
});
const busy = ref(false);
const error = ref('');
// set once the account exists, the dialog then shows its setup link
const link = ref('');
const copied = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
nameInputEl.value?.focus();
});
const ready = computed(() =>
form.name.trim() !== '' && form.email.trim() !== '' && form.password.length >= MIN_PASSWORD_LENGTH);
const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
const generatePassword = () => {
// values past the last full run of the alphabet are skipped, so every
// character stays equally likely
const limit = 256 - (256 % PASSWORD_ALPHABET.length);
let password = '';
while (password.length < GENERATED_PASSWORD_LENGTH) {
for (const value of crypto.getRandomValues(new Uint8Array(GENERATED_PASSWORD_LENGTH))) {
if (value >= limit || password.length === GENERATED_PASSWORD_LENGTH) continue;
password += PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length];
}
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy setup link:', err);
}
form.password = password;
};
const submit = async () => {
@@ -49,17 +39,20 @@ const submit = async () => {
busy.value = true;
error.value = '';
const { error: createError } = await authClient.admin.createUser({
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
role: 'user',
});
try {
const { token } = await $fetch<{ token: string }>('/api/admin/users', {
method: 'POST',
body: {
name: form.name.trim(),
email: form.email.trim(),
},
});
busy.value = false;
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err: any) {
busy.value = false;
if (createError) {
switch (createError.code) {
switch (err?.data?.data?.code as string | undefined) {
case 'USER_ALREADY_EXISTS':
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
error.value = 'An account with this email already exists.';
@@ -67,14 +60,11 @@ const submit = async () => {
case 'INVALID_EMAIL':
error.value = 'That email address is not valid.';
break;
case 'PASSWORD_TOO_SHORT':
error.value = `The password needs at least ${MIN_PASSWORD_LENGTH} characters.`;
break;
case 'PASSWORD_TOO_LONG':
error.value = 'That password is too long.';
case 'INVALID_BODY':
error.value = 'Check the name and the email address.';
break;
default:
console.error('Failed to create user:', createError);
console.error('Failed to create user:', err);
error.value = 'The account could not be created. Try again.';
break;
}
@@ -82,7 +72,7 @@ const submit = async () => {
return;
}
emit('confirm', true);
busy.value = false;
};
</script>
@@ -90,66 +80,80 @@ const submit = async () => {
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="create-user-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="create-user-title" class="text-lg font-semibold">Create user</h2>
<h2 id="create-user-title" class="text-lg font-semibold">
{{ link ? 'Account created' : 'Create user' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
Creates the account right away. Nothing is sent to them, so pass the password on yourself.
{{ link
? `Send ${form.name.trim()} this link. They open it, choose a password and are in.`
: 'Creates the account without a password. You get a link for them to choose their own, so you never know it.' }}
</p>
</header>
<div class="flex flex-col gap-1.5">
<label for="create-user-name"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Name
</label>
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required maxlength="128"
:disabled="busy" autocomplete="off"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Temporary password
</label>
<div class="flex gap-2">
<!-- shown in the clear, the admin has to read it to pass it on -->
<input id="create-user-password" v-model="form.password" type="text" required
:minlength="MIN_PASSWORD_LENGTH" maxlength="128" :disabled="busy" autocomplete="off"
autocapitalize="off" spellcheck="false"
class="flex-1 min-w-0 h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)]" />
<button type="button" :disabled="busy" @click="generatePassword"
class="flex items-center h-9 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Generate
</button>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="create-user-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Setup link
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="create-user-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
user's menu. Nobody can sign in to the account until it is used.
</span>
</div>
<p class="text-xs leading-normal text-[var(--text-secondary)]">
At least {{ MIN_PASSWORD_LENGTH }} characters. They can change it under Settings → Security
after signing in.
</p>
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-end mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</footer>
</template>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
<template v-else>
<div class="flex flex-col gap-1.5">
<label for="create-user-name"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Name
</label>
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
maxlength="128" :disabled="busy" autocomplete="off"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
</template>
</form>
</template>