feat: complete onboarding flow
This commit is contained in:
@@ -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>
|
||||||
@@ -6,6 +6,7 @@ import TwoFactor from './TwoFactor.vue';
|
|||||||
import DeleteAccount from './DeleteAccount.vue';
|
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 { DialogType } from '~/composables/useDialog';
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ onUnmounted(() => {
|
|||||||
@cancel="close" />
|
@cancel="close" />
|
||||||
<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" />
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -8,6 +8,7 @@ export enum DialogType {
|
|||||||
TwoFactor = 'two-factor',
|
TwoFactor = 'two-factor',
|
||||||
DeleteAccount = 'delete-account',
|
DeleteAccount = 'delete-account',
|
||||||
CreateUser = 'create-user',
|
CreateUser = 'create-user',
|
||||||
|
CreateInvite = 'create-invite',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogOptions {
|
interface DialogOptions {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { deriveKey } from '~/utils/crypto';
|
import { deriveKey } from '~/utils/crypto';
|
||||||
|
import { timeLeft } from '~/utils/time';
|
||||||
|
|
||||||
const { client: authClient, signUp, session } = useAuth();
|
const { client: authClient, signUp, session } = useAuth();
|
||||||
|
|
||||||
@@ -12,8 +13,6 @@ if (session.value !== null) {
|
|||||||
await navigateTo(to ?? '/');
|
await navigateTo(to ?? '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// who may register is an instance setting, admins change it while it runs
|
// who may register is an instance setting, admins change it while it runs
|
||||||
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
||||||
|
|
||||||
@@ -43,9 +42,7 @@ const blocked = computed(() => inviteOnly.value && validInvite.value === null);
|
|||||||
const inviteValidFor = computed(() => {
|
const inviteValidFor = computed(() => {
|
||||||
if (!validInvite.value) return '';
|
if (!validInvite.value) return '';
|
||||||
|
|
||||||
const days = Math.max(1, Math.ceil((new Date(validInvite.value.expiresAt).getTime() - Date.now()) / DAY_MS));
|
return `Invite valid for ${timeLeft(validInvite.value.expiresAt)} more${validInvite.value.singleUse ? ' · single use' : ''}`;
|
||||||
|
|
||||||
return `Invite valid for ${days} more ${days === 1 ? 'day' : 'days'}${validInvite.value.singleUse ? ' · single use' : ''}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { deriveKey } from '~/utils/crypto';
|
import { deriveKey } from '~/utils/crypto';
|
||||||
|
|
||||||
const { signIn } = useAuth();
|
const { signIn, fetchSession } = useAuth();
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
@@ -127,8 +127,11 @@ const submit = async () => {
|
|||||||
const key = await deriveKey(form.password, res.data.user.id);
|
const key = await deriveKey(form.password, res.data.user.id);
|
||||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||||
|
|
||||||
// straight on to connecting a provider, the instance is useless without one
|
// the next steps are for admins only, so make sure the role is loaded
|
||||||
return navigateTo('/welcome');
|
await fetchSession();
|
||||||
|
|
||||||
|
// on to the rest of the setup: who can register, then a first provider
|
||||||
|
return navigateTo('/setup/instance');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -160,6 +163,8 @@ const submit = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<OnboardingSetupSteps :current="1"></OnboardingSetupSteps>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
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)]">
|
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)]">
|
||||||
No accounts exist yet. The account you create here becomes the
|
No accounts exist yet. The account you create here becomes the
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RegistrationMode } from '~~/drizzle/schema';
|
import type { RegistrationMode } from '~~/drizzle/schema';
|
||||||
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
import { timeLeft } from '~/utils/time';
|
||||||
|
|
||||||
type InstanceSettings = {
|
type InstanceSettings = {
|
||||||
registration: RegistrationMode;
|
registration: RegistrationMode;
|
||||||
@@ -25,6 +27,7 @@ const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; descriptio
|
|||||||
];
|
];
|
||||||
|
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
if (!isAdmin.value) {
|
if (!isAdmin.value) {
|
||||||
await navigateTo('/settings/profile');
|
await navigateTo('/settings/profile');
|
||||||
@@ -43,8 +46,6 @@ type Invite = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
|
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
|
||||||
key: 'admin_invites',
|
key: 'admin_invites',
|
||||||
});
|
});
|
||||||
@@ -89,9 +90,7 @@ const copiedId = ref<string | null>(null);
|
|||||||
const invitePath = (invite: Invite) => `/auth/register?invite=${invite.token}`;
|
const invitePath = (invite: Invite) => `/auth/register?invite=${invite.token}`;
|
||||||
|
|
||||||
const describeInvite = (invite: Invite) => {
|
const describeInvite = (invite: Invite) => {
|
||||||
const days = Math.max(1, Math.ceil((new Date(invite.expiresAt).getTime() - Date.now()) / DAY_MS));
|
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${timeLeft(invite.expiresAt)}`;
|
||||||
|
|
||||||
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${days} ${days === 1 ? 'day' : 'days'}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyInvite = async (invite: Invite) => {
|
const copyInvite = async (invite: Invite) => {
|
||||||
@@ -107,21 +106,12 @@ const copyInvite = async (invite: Invite) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createInvite = async () => {
|
// The dialog asks how long the link lasts and for how many people, and shows
|
||||||
if (inviteBusy.value) return;
|
// the link. It may be closed after the link was made, so always reload.
|
||||||
|
const createInvite = () => {
|
||||||
inviteBusy.value = true;
|
openDialog(DialogType.CreateInvite, async () => {
|
||||||
inviteError.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch('/api/admin/invites', { method: 'POST' });
|
|
||||||
await loadInvites();
|
await loadInvites();
|
||||||
} catch (err) {
|
});
|
||||||
console.error('Failed to create invite:', err);
|
|
||||||
inviteError.value = 'The invite link could not be created. Try again.';
|
|
||||||
}
|
|
||||||
|
|
||||||
inviteBusy.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const revokeInvite = async (invite: Invite) => {
|
const revokeInvite = async (invite: Invite) => {
|
||||||
@@ -202,16 +192,11 @@ const revokeInvite = async (invite: Invite) => {
|
|||||||
|
|
||||||
<p v-if="inviteError" role="alert" class="text-sm text-red-400">{{ inviteError }}</p>
|
<p v-if="inviteError" role="alert" class="text-sm text-red-400">{{ inviteError }}</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
<button type="button" @click="createInvite"
|
||||||
<button type="button" :disabled="inviteBusy" @click="createInvite"
|
class="self-start flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||||
class="flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
|
||||||
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
|
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
|
||||||
New invite link
|
New invite link
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-[var(--text-secondary)]">
|
|
||||||
Each link lets one person register and expires after 7 days.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,38 +187,12 @@ const { data: instance } = await useFetch<{ registration: string }>('/api/admin/
|
|||||||
key: 'admin_instance',
|
key: 'admin_instance',
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteBusy = ref(false);
|
// the dialog asks for the details and shows the link to copy
|
||||||
const inviteLink = ref('');
|
const createInvite = () => {
|
||||||
const inviteCopied = ref(false);
|
openDialog(DialogType.CreateInvite, () => {
|
||||||
|
|
||||||
const createInvite = async () => {
|
|
||||||
if (inviteBusy.value) return;
|
|
||||||
|
|
||||||
inviteBusy.value = true;
|
|
||||||
actionError.value = '';
|
|
||||||
inviteCopied.value = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invite = await $fetch<{ token: string }>('/api/admin/invites', { method: 'POST' });
|
|
||||||
inviteLink.value = `${window.location.origin}/auth/register?invite=${invite.token}`;
|
|
||||||
// the Instance page lists the links, so it has to load the new one
|
// the Instance page lists the links, so it has to load the new one
|
||||||
clearNuxtData('admin_invites');
|
clearNuxtData('admin_invites');
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Failed to create invite:', error);
|
|
||||||
actionError.value = 'The invite link could not be created. Try again.';
|
|
||||||
inviteBusy.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// copying can be refused by the browser, the link is shown either way
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(inviteLink.value);
|
|
||||||
inviteCopied.value = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy invite link:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
inviteBusy.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateUser = () => {
|
const openCreateUser = () => {
|
||||||
@@ -256,8 +230,7 @@ const openCreateUser = () => {
|
|||||||
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||||
Create user
|
Create user
|
||||||
</button>
|
</button>
|
||||||
<button v-if="instance?.registration === 'invite'" type="button" :disabled="inviteBusy"
|
<button v-if="instance?.registration === 'invite'" type="button" @click="createInvite"
|
||||||
@click="createInvite"
|
|
||||||
class="flex items-center gap-1.5 h-8 px-3 text-[13px] font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-50 disabled:cursor-not-allowed">
|
class="flex items-center gap-1.5 h-8 px-3 text-[13px] font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
<span class="i-mynaui-link text-4" aria-hidden="true"></span>
|
<span class="i-mynaui-link text-4" aria-hidden="true"></span>
|
||||||
Invite link
|
Invite link
|
||||||
@@ -265,17 +238,6 @@ const openCreateUser = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="inviteLink" role="status"
|
|
||||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-3 px-3 py-2 rounded-lg bg-[var(--bg-container)] text-sm">
|
|
||||||
<span class="text-[var(--text-secondary)]">
|
|
||||||
{{ inviteCopied ? 'Invite link copied:' : 'New invite link:' }}
|
|
||||||
</span>
|
|
||||||
<span class="flex-1 min-w-55 font-mono text-[13px] overflow-hidden text-ellipsis whitespace-nowrap select-all">
|
|
||||||
{{ inviteLink }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-[var(--text-secondary)]">one use · expires in 7 days</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
|
<p v-if="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
|
||||||
<p v-if="actionError" role="alert" class="pb-3 text-sm text-red-400">{{ actionError }}</p>
|
<p v-if="actionError" role="alert" class="pb-3 text-sm text-red-400">{{ actionError }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RegistrationMode } from '~~/drizzle/schema';
|
||||||
|
|
||||||
|
type InstanceSettings = {
|
||||||
|
registration: RegistrationMode;
|
||||||
|
registrationLocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'open',
|
||||||
|
label: 'Open',
|
||||||
|
description: 'Anyone who can reach this URL can create an account.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'invite',
|
||||||
|
label: 'Invite only',
|
||||||
|
description: 'Admins create invite links. Good default for a shared instance.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'closed',
|
||||||
|
label: 'Closed',
|
||||||
|
description: 'No sign-ups. Only you use this instance.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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)',
|
||||||
|
};
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
// the rest of first-run setup belongs to the admin who just made the account
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
await navigateTo('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
|
||||||
|
key: 'admin_instance',
|
||||||
|
});
|
||||||
|
|
||||||
|
const registration = ref<RegistrationMode>(instance.value?.registration ?? 'invite');
|
||||||
|
const locked = computed(() => !!instance.value?.registrationLocked);
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (saving.value) return;
|
||||||
|
|
||||||
|
// nothing to save when the environment decides, or nothing changed
|
||||||
|
if (locked.value || instance.value?.registration === registration.value) {
|
||||||
|
return navigateTo('/setup/provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { registration: registration.value },
|
||||||
|
});
|
||||||
|
// the sign in pages read this to decide whether to offer registering
|
||||||
|
clearNuxtData('instance');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save registration:', err);
|
||||||
|
error.value = 'That could not be saved. Try again.';
|
||||||
|
saving.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
return navigateTo('/setup/provider');
|
||||||
|
};
|
||||||
|
</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)]">Set up this instance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OnboardingSetupSteps :current="2"></OnboardingSetupSteps>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||||
|
<fieldset class="flex flex-col gap-1.5" :disabled="saving || locked">
|
||||||
|
<legend
|
||||||
|
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
|
Who can register
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label v-for="option in REGISTRATION_OPTIONS" :key="option.value"
|
||||||
|
class="flex items-start gap-3 px-3 py-2.5 rounded-lg border transition-colors duration-200"
|
||||||
|
:style="registration === option.value ? SELECTED_STYLE : undefined" :class="[
|
||||||
|
registration === option.value
|
||||||
|
? 'border-[var(--color-accent)]'
|
||||||
|
: 'border-[var(--color-border)]',
|
||||||
|
locked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||||
|
]">
|
||||||
|
<!-- a plain radio: the auth layout sizes every input as a text field -->
|
||||||
|
<input v-model="registration" type="radio" name="registration" :value="option.value"
|
||||||
|
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="font-medium">{{ option.label }}</span>
|
||||||
|
<span class="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{{ option.description }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p v-if="locked" 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>
|
||||||
|
Registration is turned off by the <code class="font-mono">DISABLE_SIGNUP</code>
|
||||||
|
environment variable. Remove it and restart the server to choose here.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="saving"
|
||||||
|
class="accent w-full h-11 flex items-center justify-center font-semibold">
|
||||||
|
<span v-if="saving" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||||
|
<span v-else>Continue</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-center text-[var(--text-secondary)]">
|
||||||
|
This can be changed later in Settings → Instance.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
// the rest of first-run setup belongs to the admin who just made the account
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
await navigateTo('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = () => navigateTo('/');
|
||||||
|
const back = () => navigateTo('/setup/instance');
|
||||||
|
</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)]">Connect your first AI provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OnboardingSetupSteps :current="3"></OnboardingSetupSteps>
|
||||||
|
|
||||||
|
<OnboardingProviderForm submit-label="Fetch models and finish" :show-back="true" @done="finish"
|
||||||
|
@back="back">
|
||||||
|
<template #note>
|
||||||
|
<p class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||||
|
Providers are per account. Other people on this instance add their own keys, and nobody,
|
||||||
|
including admins, can read yours.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</OnboardingProviderForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+1
-212
@@ -1,146 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<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-…',
|
|
||||||
};
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
});
|
});
|
||||||
|
|
||||||
const to = useRoute().query.to as string | undefined;
|
const to = useRoute().query.to as string | undefined;
|
||||||
|
|
||||||
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 finish = () => navigateTo(to ?? '/');
|
const finish = () => navigateTo(to ?? '/');
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
await finish();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -164,83 +29,7 @@ const submit = async () => {
|
|||||||
land on Home.
|
land on Home.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
<OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm>
|
||||||
<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"
|
|
||||||
:class="selectedType === p.type
|
|
||||||
? 'border-[var(--color-accent)] bg-[var(--color-accent)]/10 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>
|
|
||||||
|
|
||||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button type="button" :disabled="busy" @click="finish"
|
|
||||||
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>Fetch models and continue</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
|
/** How long until a date, in whole days or hours: "6 days", "3 hours". */
|
||||||
|
export const timeLeft = (until: string | Date): string => {
|
||||||
|
const ms = new Date(until).getTime() - Date.now();
|
||||||
|
|
||||||
|
if (ms >= DAY_MS) {
|
||||||
|
const days = Math.ceil(ms / DAY_MS);
|
||||||
|
return `${days} ${days === 1 ? 'day' : 'days'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.max(1, Math.ceil(ms / HOUR_MS));
|
||||||
|
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
};
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import * as z from 'zod';
|
||||||
import { invites } from '~~/drizzle/schema';
|
import { invites } from '~~/drizzle/schema';
|
||||||
import { db } from '~~/server/lib/db';
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
const TOKEN_LENGTH = 32;
|
const TOKEN_LENGTH = 32;
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_USES = 1000;
|
||||||
|
const MAX_HOURS = 24 * 365;
|
||||||
|
|
||||||
/** Creates an invite link that lets one person register within a week. */
|
/** Creates an invite link that works for the given time and number of people. */
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectAdminRoute(event);
|
await protectAdminRoute(event);
|
||||||
|
|
||||||
|
const result = await readValidatedBody(event, (body) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
maxUses: z.int().min(1).max(MAX_USES),
|
||||||
|
expiresInHours: z.int().min(1).max(MAX_HOURS),
|
||||||
|
})
|
||||||
|
.safeParse(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: result.error.issues[0]!.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxUses, expiresInHours } = result.data;
|
||||||
|
|
||||||
const [invite] = await db.insert(invites)
|
const [invite] = await db.insert(invites)
|
||||||
.values({
|
.values({
|
||||||
token: nanoid(TOKEN_LENGTH),
|
token: nanoid(TOKEN_LENGTH),
|
||||||
createdBy: event.context.user!.id as string,
|
createdBy: event.context.user!.id as string,
|
||||||
maxUses: 1,
|
maxUses,
|
||||||
expiresAt: new Date(Date.now() + INVITE_LIFETIME_MS),
|
expiresAt: new Date(Date.now() + expiresInHours * HOUR_MS),
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: invites.id,
|
id: invites.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { users } from '~~/drizzle/schema';
|
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
|
||||||
import { auth } from '~~/lib/auth';
|
import { auth } from '~~/lib/auth';
|
||||||
import { db } from '~~/server/lib/db';
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
@@ -59,6 +59,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A new instance starts invite only. The next setup step lets the
|
||||||
|
// admin pick, and if they never get there it is not left open.
|
||||||
|
await tx.insert(instanceSettings)
|
||||||
|
.values({ id: INSTANCE_SETTINGS_ID, registration: 'invite' })
|
||||||
|
.onConflictDoNothing();
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const code = error?.body?.code as string | undefined;
|
const code = error?.body?.code as string | undefined;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { invites, users } from '../../drizzle/schema';
|
|||||||
import { db } from '../lib/db';
|
import { db } from '../lib/db';
|
||||||
|
|
||||||
export const INVITE_TOKEN_HEADER = 'x-invite-token';
|
export const INVITE_TOKEN_HEADER = 'x-invite-token';
|
||||||
export const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// still has uses left and has not run out
|
// still has uses left and has not run out
|
||||||
const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt, new Date()));
|
const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt, new Date()));
|
||||||
|
|||||||
Reference in New Issue
Block a user