feat: complete onboarding flow

This commit is contained in:
Zoe
2026-10-08 14:04:29 -05:00
parent fae05ebc4c
commit 61f9bb74b4
16 changed files with 684 additions and 297 deletions
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
const MAX_USES = 1000;
const DURATIONS = [
{ hours: 1, label: '1 hour' },
{ hours: 24, label: '1 day' },
{ hours: 24 * 7, label: '7 days' },
{ hours: 24 * 30, label: '30 days' },
];
const emit = defineEmits(['confirm', 'cancel']);
const hours = ref(24 * 7);
const maxUses = ref(1);
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 usesValid = computed(() => Number.isInteger(maxUses.value) && maxUses.value >= 1 && maxUses.value <= MAX_USES);
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 invite link:', err);
}
};
const submit = async () => {
if (busy.value || !usesValid.value) return;
busy.value = true;
error.value = '';
try {
const invite = await $fetch<{ token: string }>('/api/admin/invites', {
method: 'POST',
body: {
maxUses: maxUses.value,
expiresInHours: hours.value,
},
});
link.value = `${window.location.origin}/auth/register?invite=${invite.token}`;
} catch (err) {
console.error('Failed to create invite:', err);
error.value = 'The invite link could not be created. 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="create-invite-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="create-invite-title" class="text-lg font-semibold">
{{ link ? 'Invite link created' : 'New invite link' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
{{ link
? 'Send it to whoever should join. You can copy it again or revoke it under Instance.'
: 'Anyone with the link can create an account until it runs out.' }}
</p>
</header>
<template v-if="link">
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input type="text" readonly :value="link" aria-label="Invite 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 invite 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>
<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>
<template v-else>
<fieldset class="flex flex-col">
<legend class="mb-1.5 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Valid for
</legend>
<div class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)] self-start">
<button v-for="d in DURATIONS" :key="d.hours" type="button" :aria-pressed="hours === d.hours"
:disabled="busy" @click="hours = d.hours"
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
:class="hours === d.hours
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
{{ d.label }}
</button>
</div>
</fieldset>
<div class="flex flex-col gap-1.5">
<label for="create-invite-uses"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Number of uses
</label>
<input id="create-invite-uses" v-model.number="maxUses" type="number" required min="1"
:max="MAX_USES" step="1" :disabled="busy"
class="w-28 h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
<p class="text-xs leading-normal text-[var(--text-secondary)]">
How many people can register with this link, up to {{ MAX_USES }}.
</p>
</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 || !usesValid"
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 link
</button>
</footer>
</template>
</form>
</template>
+2
View File
@@ -6,6 +6,7 @@ import TwoFactor from './TwoFactor.vue';
import DeleteAccount from './DeleteAccount.vue';
import Confirm from './Confirm.vue';
import CreateUser from './CreateUser.vue';
import CreateInvite from './CreateInvite.vue';
import { DialogType } from '~/composables/useDialog';
@@ -72,6 +73,7 @@ onUnmounted(() => {
@cancel="close" />
<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" />
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
</div>
</div>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import type { Model } from '~/composables/useModels';
import { encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
import { providerIcons } from '~/utils/model-mapping';
import { sortByReleaseDate } from '~/utils/sort';
// the providers offered up front, the rest sit behind "More…"
const FEATURED_PROVIDERS = ['openrouter', 'anthropic', 'openai', 'google', 'ollama'];
// these run on your own machine and are reached by address, not by key
const LOCAL_PROVIDERS = ['ollama', 'vllm'];
// A provider can list hundreds of models, so only the newest few are turned on
// to start with. The rest are a click away in the provider's settings.
const MODELS_ENABLED_AT_START = 8;
const PROVIDER_LABELS: Record<string, string> = {
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
openai: 'OpenAI',
google: 'Google',
ollama: 'Ollama',
};
const KEY_PLACEHOLDERS: Record<string, string> = {
openrouter: 'sk-or-…',
anthropic: 'sk-ant-…',
openai: 'sk-…',
};
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
const SELECTED_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
};
defineProps<{
submitLabel: string;
// shown next to skip, for flows that have a step before this one
showBack?: boolean;
}>();
// done: a provider was connected or the step was skipped
const emit = defineEmits(['done', 'back']);
const { providers } = await useModels();
const showAll = ref(false);
const selectedType = ref(FEATURED_PROVIDERS.find(type => providers.value.some(p => p.type === type))
?? providers.value[0]?.type ?? '');
const featured = computed(() => FEATURED_PROVIDERS
.map(type => providers.value.find(p => p.type === type))
.filter(p => p !== undefined));
const others = computed(() => providers.value.filter(p => !FEATURED_PROVIDERS.includes(p.type)));
const shownProviders = computed(() => showAll.value ? [...featured.value, ...others.value] : featured.value);
const selected = computed(() => providers.value.find(p => p.type === selectedType.value));
const isLocal = computed(() => LOCAL_PROVIDERS.includes(selectedType.value));
const apiKey = ref('');
const apiKeyVisible = ref(false);
const baseUrl = ref('');
const busy = ref(false);
const error = ref('');
const ready = computed(() => isLocal.value ? baseUrl.value.trim() !== '' : apiKey.value.trim() !== '');
const selectProvider = (type: string) => {
selectedType.value = type;
error.value = '';
};
const submit = async () => {
const provider = selected.value;
if (busy.value || !ready.value || !provider) return;
busy.value = true;
error.value = '';
const storedKey = localStorage.getItem('encryptionKey');
if (storedKey === null) {
error.value = 'This browser has no encryption key for your account. Log out and back in, then add the provider under Settings.';
busy.value = false;
return;
}
const key = apiKey.value.trim();
const config = { ...provider.config };
try {
if (isLocal.value) {
config.apiProxyUrl = baseUrl.value.trim();
} else {
const cryptoKey = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), 'AES-GCM', false, ['encrypt', 'decrypt']);
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
}
await $fetch(`/api/provider/${provider.id}`, {
method: 'PATCH',
body: { config, enabled: true },
});
} catch (err) {
console.error('Failed to save provider:', err);
error.value = 'The provider could not be saved. Try again.';
busy.value = false;
return;
}
let models: Model[];
try {
const response = await $fetch<{ models: Model[] }>(`/api/provider/${provider.id}/models`, {
method: 'POST',
body: { providerApiKey: isLocal.value ? undefined : key },
});
models = response.models;
} catch (err) {
console.error('Failed to fetch models:', err);
error.value = isLocal.value
? 'No models could be fetched from that address. Check that it is right and reachable from the server.'
: 'No models could be fetched with that key. Check that it is right and try again.';
busy.value = false;
return;
}
const toEnable = models
.filter(m => m.outputModalities.includes('text'))
.sort(sortByReleaseDate)
.slice(0, MODELS_ENABLED_AT_START);
if (toEnable.length === 0) {
error.value = 'The provider answered, but has no chat models to offer. Try another provider.';
busy.value = false;
return;
}
// a model that fails to turn on here can still be turned on in settings
const results = await Promise.allSettled(toEnable.map(m => $fetch(`/api/model/${m.id}`, {
method: 'PATCH',
body: { enabled: true },
})));
const enabledIds = new Set(toEnable.filter((_, i) => results[i]!.status === 'fulfilled').map(m => m.id));
providers.value = providers.value.map(p => p.id === provider.id
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
: p);
emit('done');
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="flex flex-col">
<legend
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Provider
</legend>
<div class="grid grid-cols-3 gap-2">
<button v-for="p in shownProviders" :key="p.id" type="button"
:aria-pressed="selectedType === p.type" :disabled="busy" @click="selectProvider(p.type)"
class="flex items-center justify-center gap-2 h-11 px-2 min-w-0 text-sm rounded-lg border disabled:cursor-not-allowed transition-colors duration-200"
:style="selectedType === p.type ? SELECTED_STYLE : undefined" :class="selectedType === p.type
? 'border-[var(--color-accent)] font-medium'
: 'border-[var(--color-border)] enabled:@hover:bg-[var(--color-hover)]'">
<component v-if="providerIcons[p.type]" :is="providerIcons[p.type]" :color="true"
class="w-5.5 h-5.5 flex-shrink-0 text-[var(--text-primary)]" />
<span class="case-capital overflow-hidden text-ellipsis whitespace-nowrap">
{{ PROVIDER_LABELS[p.type] ?? p.name }}
</span>
</button>
<button v-if="!showAll && others.length > 0" type="button" :disabled="busy"
@click="showAll = true"
class="flex items-center justify-center h-11 text-sm rounded-lg border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:cursor-not-allowed transition-colors duration-200">
More…
</button>
</div>
</fieldset>
<div v-if="isLocal" class="flex flex-col gap-1.5">
<label for="baseurl"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
Server address
</label>
<input id="baseurl" v-model="baseUrl" type="url" :disabled="busy" autocomplete="off"
spellcheck="false" :placeholder="selected?.defaultBaseUrl || 'http://localhost:11434'"
class="text-sm border border-[var(--color-border)]" />
</div>
<div v-else class="flex flex-col gap-1.5">
<label for="apikey"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
API key
</label>
<div
class="flex items-center rounded-md bg-[var(--bg-container)] border border-[var(--color-border)]">
<input id="apikey" v-model="apiKey" :type="apiKeyVisible ? 'text' : 'password'"
:disabled="busy" autocomplete="off" spellcheck="false"
:placeholder="KEY_PLACEHOLDERS[selectedType] ?? 'API key'"
class="flex-1 min-w-0 text-sm !bg-transparent" />
<button type="button" :aria-label="apiKeyVisible ? 'Hide API key' : 'Show API key'"
@click="apiKeyVisible = !apiKeyVisible"
class="flex items-center justify-center w-10 h-10 flex-shrink-0 text-[var(--text-secondary)]">
<span class="text-4.5" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
</button>
</div>
</div>
<p v-if="!isLocal" class="flex gap-2 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>
Encrypted in this browser with AES-GCM before it is stored. Nobody else on this instance,
including admins, can read it.
</span>
</p>
<slot name="note"></slot>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<div class="flex gap-2">
<button v-if="showBack" type="button" :disabled="busy" @click="emit('back')"
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Back
</button>
<button type="button" :disabled="busy" @click="emit('done')"
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Skip for now
</button>
<button type="submit" :disabled="busy || !ready"
class="accent flex-1 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>{{ submitLabel }}</span>
</button>
</div>
</form>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
const STEPS = ['Admin account', 'Instance', 'Provider'];
defineProps<{
// 1 based, the steps before it show as done
current: number;
}>();
</script>
<template>
<ol class="flex items-center gap-2 text-sm">
<template v-for="(step, index) in STEPS" :key="step">
<li v-if="index > 0" aria-hidden="true" class="flex-1 h-px bg-[var(--color-border)] [list-style-type:none]">
</li>
<li class="flex items-center gap-2 whitespace-nowrap [list-style-type:none]"
:aria-current="index + 1 === current ? 'step' : undefined"
:class="index + 1 === current ? 'font-medium text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<span class="flex items-center justify-center w-6 h-6 rounded-md text-xs" :class="index + 1 <= current
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-semibold'
: 'border border-[var(--color-border-active)]'">
<span v-if="index + 1 < current" class="i-mynaui-check text-3.5" aria-label="Done"></span>
<template v-else>{{ index + 1 }}</template>
</span>
{{ step }}
</li>
</template>
</ol>
</template>