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
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
image?: string | null;
twoFactorEnabled?: boolean;
// has no password yet, so this makes a setup link and nothing is lost
pending?: boolean;
};
}>();
const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off and sign every device out when the link is used
const resetSecurity = ref(true);
const busy = ref(false);
const error = ref('');
// set once the link exists, the dialog then shows it instead of the options
const link = ref('');
const copied = ref(false);
const revoked = ref(false);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy recovery link:', err);
}
};
const create = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
const { token } = await $fetch<{ token: string }>(`/api/admin/users/${props.user.id}/recovery`, {
method: 'POST',
body: { resetSecurity: resetSecurity.value },
});
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err) {
console.error('Failed to create recovery link:', err);
error.value = 'The recovery link could not be created. Try again.';
}
busy.value = false;
};
const revoke = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await $fetch(`/api/admin/users/${props.user.id}/recovery`, { method: 'DELETE' });
link.value = '';
revoked.value = true;
} catch (err) {
console.error('Failed to revoke recovery link:', err);
error.value = 'The link could not be revoked. Try again.';
}
busy.value = false;
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="recover-account-title" @submit.prevent="create">
<header class="flex items-center gap-3">
<UserAvatar :user="user" class="w-10 h-10 rounded-lg"></UserAvatar>
<div class="flex flex-col min-w-0">
<h2 id="recover-account-title" class="text-lg font-semibold">
{{ user.pending ? 'Account setup link' : 'Recover account' }}
</h2>
<p class="text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ user.name }} · {{ user.email }}
</p>
</div>
</header>
<p v-if="user.pending && !link" class="text-sm leading-normal text-[var(--text-secondary)]">
This account has no password yet. A new link lets them choose one, and replaces the link they
were given before.
</p>
<div v-if="!user.pending" role="note" class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
kept.
</span>
</div>
</div>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="recover-account-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
{{ user.pending ? 'Setup link' : 'Recovery link' }}
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="recover-account-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy recovery link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in {{ user.pending ? '7 days' : '24 hours' }}. Shown only now; closing
this dialog hides it for good.
Send it to them yourself, over a channel you trust.
</span>
</div>
</template>
<template v-else>
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer">
<input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" />
<span>Also reset two-factor and sign out all their sessions</span>
</label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
The link was revoked and no longer works.
</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<template v-if="link">
<button type="button" :disabled="busy" @click="revoke"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Revoke link
</button>
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</template>
<template v-else>
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
{{ revoked ? 'Close' : 'Cancel' }}
</button>
<button type="submit" :disabled="busy"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ user.pending ? 'Create setup link' : 'Create recovery link' }}
</button>
</template>
</footer>
</form>
</template>