feat: setup and roles

This commit is contained in:
Zoe
2026-10-08 13:08:20 -05:00
parent 4bba529ba8
commit 7b0800882a
13 changed files with 3502 additions and 2 deletions
+3
View File
@@ -66,6 +66,9 @@ const submit = async () => {
needsCode.value = true; needsCode.value = true;
error.value = 'This account has two-factor sign-in on. Enter a code to continue.'; error.value = 'This account has two-factor sign-in on. Enter a code to continue.';
break; break;
case 'LAST_ADMIN':
error.value = 'You are the only admin of this instance. Make someone else an admin first. Nothing was deleted.';
break;
default: default:
error.value = 'Your account could not be deleted. Nothing was deleted, try again.'; error.value = 'Your account could not be deleted. Nothing was deleted, try again.';
break; break;
+3
View File
@@ -15,6 +15,7 @@ export enum AuthError {
export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & { export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
twoFactorEnabled?: boolean | null; twoFactorEnabled?: boolean | null;
role?: string | null;
}; };
type sessionData = { type sessionData = {
@@ -35,6 +36,7 @@ export const useAuth = () => {
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null); const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
const user = useState<AuthUser | null>('auth:user', () => null); const user = useState<AuthUser | null>('auth:user', () => null);
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false); const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
const isAdmin = computed(() => user.value?.role === 'admin');
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null; let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
@@ -244,6 +246,7 @@ export const useAuth = () => {
client: authClient, client: authClient,
session, session,
user, user,
isAdmin,
loggedIn: computed(() => !!session.value), loggedIn: computed(() => !!session.value),
signIn, signIn,
verifyTwoFactor, verifyTwoFactor,
+25
View File
@@ -22,6 +22,31 @@ export default defineNuxtRouteMiddleware(async (to) => {
return await navigateTo((to.query.to as string) ?? "/"); return await navigateTo((to.query.to as string) ?? "/");
} }
// A fresh instance has no accounts, so there is nothing to log in to yet
// and the first visitor creates the admin account instead.
if (!loggedIn.value) {
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
const isSetupPage = to.path.toLowerCase() === '/auth/setup';
// once an account exists the answer never changes back
if (setupNeeded.value !== false) {
try {
setupNeeded.value = (await $fetch<{ needed: boolean }>('/api/setup')).needed;
} catch (error) {
console.error('Failed to check instance setup:', error);
setupNeeded.value = null;
}
}
if (setupNeeded.value === true && !isSetupPage) {
return await navigateTo('/auth/setup');
}
if (setupNeeded.value !== true && isSetupPage) {
return await navigateTo('/auth/login');
}
}
// If not authenticated, and not on a signin/signup page, redirect to login page // If not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !isAuthPage) { if (!loggedIn.value && !isAuthPage) {
return await navigateTo(`/auth/login?to=${to.path}`); return await navigateTo(`/auth/login?to=${to.path}`);
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
const { signIn } = useAuth();
definePageMeta({
layout: 'auth',
});
// the auth middleware only lets this page render while no account exists
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
const form = reactive({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const loading = ref(false);
const error = ref('');
const alreadySetUp = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null);
const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
const tempForm = {
name: '',
email: '',
password: '',
confirmPassword: '',
};
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
});
const hydrated = ref(false);
onMounted(() => {
form.name = tempForm.name;
form.email = tempForm.email;
form.password = tempForm.password;
form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true;
emailInputEl.value!.addEventListener('input', () => {
emailInputEl.value!.setCustomValidity('');
});
const checkPasswordsMatch = () => {
const matches = passwordInputEl.value!.value === confirmPasswordInputEl.value!.value;
confirmPasswordInputEl.value!.setCustomValidity(matches ? '' : 'Passwords do not match');
};
passwordInputEl.value!.addEventListener('input', checkPasswordsMatch);
confirmPasswordInputEl.value!.addEventListener('input', () => {
checkPasswordsMatch();
confirmPasswordInputEl.value!.reportValidity();
});
});
const submit = async () => {
if (loading.value) return;
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
for (const input of inputs) {
if (input.value?.validity.valid === false) {
input.value!.reportValidity();
return;
}
}
loading.value = true;
error.value = '';
try {
await $fetch('/api/setup', {
method: 'POST',
body: {
name: form.name,
email: form.email,
password: form.password,
},
});
} catch (err: any) {
loading.value = false;
// TODO: i18n
switch (err?.data?.data?.code as string | undefined) {
case 'SETUP_ALREADY_DONE':
// someone else created the first account in the meantime
setupNeeded.value = false;
alreadySetUp.value = true;
break;
case 'INVALID_EMAIL':
emailInputEl.value!.setCustomValidity('That email address is not valid');
emailInputEl.value!.reportValidity();
break;
case 'INVALID_BODY':
case 'PASSWORD_TOO_LONG':
error.value = err.data.message ?? 'Check the form and try again.';
break;
default:
console.error('Failed to set up the instance:', err);
error.value = 'The admin account could not be created. Try again.';
break;
}
return;
}
setupNeeded.value = false;
const res = await signIn(form.email, form.password);
if (!res.ok || res.data.twoFactorRequired) {
// the account exists, so logging in by hand still works
return navigateTo('/auth/login');
}
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo('/');
};
</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>
<div v-if="alreadySetUp" class="flex flex-col gap-4">
<p role="alert"
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)]">
Someone else just created the first account, so this instance already has its
administrator. Nothing was created for you.
</p>
<NuxtLink to="/auth/login" class="accent w-full h-11 flex items-center justify-center font-semibold">
Go to log in
</NuxtLink>
</div>
<template v-else>
<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)]">
No accounts exist yet. The account you create here becomes the
<strong class="font-medium text-[var(--text-primary)]">administrator</strong> of this instance.
</p>
<form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5">
<label for="name"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Name</label>
<input required maxlength="128" ref="nameInputEl" type="text" autocomplete="name" id="name"
v-model="form.name" placeholder="John Doe"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="password"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="new-password" id="password" v-model="form.password" placeholder="••••••••"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="confirmPassword"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
password</label>
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword"
placeholder="••••••••"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<p class="flex gap-2 mt-1 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>
Your password also encrypts your API keys in the browser. If you forget it, saved
keys can't be recovered and have to be re-entered.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<button :disabled="!hydrated || loading"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>Create admin account</span>
</button>
</form>
</template>
</div>
<div class="absolute bottom-4 right-4">
<ThemeSwitcher size="medium" />
</div>
</div>
</template>
+19 -1
View File
@@ -6,9 +6,15 @@ const AVATAR_SIZE = 256;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp']; const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const { user, updateProfile } = useAuth(); const { user, isAdmin, updateProfile } = useAuth();
const { openDialog } = useDialog(); const { openDialog } = useDialog();
// same tinted pill as the states that are on in Security
const ACCENT_PILL_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
};
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false); const avatarBusy = ref(false);
const avatarError = ref(''); const avatarError = ref('');
@@ -239,12 +245,24 @@ const memberSince = computed(() => {
</span> </span>
</div> </div>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Role</span>
<div class="flex-1 min-w-60 max-w-120 flex items-center gap-2.5">
<span v-if="isAdmin"
class="text-[13px] font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">Admin</span>
<span v-else
class="text-[13px] font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
</div>
</div>
<section <section
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35"> class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
<div class="flex flex-col gap-0.5 flex-1 min-w-60"> <div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="text-[15px] font-semibold">Delete account</h2> <h2 class="text-[15px] font-semibold">Delete account</h2>
<p class="text-[13px] leading-snug text-[var(--text-secondary)]"> <p class="text-[13px] leading-snug text-[var(--text-secondary)]">
Permanently deletes your agents, topics, and api keys from this instance. Permanently deletes your agents, topics, and api keys from this instance.
<template v-if="isAdmin">The last remaining admin can't delete their account.</template>
</p> </p>
</div> </div>
<button type="button" @click="openDialog(DialogType.DeleteAccount)" <button type="button" @click="openDialog(DialogType.DeleteAccount)"
@@ -0,0 +1,7 @@
ALTER TABLE "sessions" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'user';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "ban_expires" timestamp with time zone;--> statement-breakpoint
-- Accounts were all equal until now, so the oldest one becomes the admin.
UPDATE "users" SET "role" = 'admin' WHERE "id" = (SELECT "id" FROM "users" ORDER BY "created_at", "id" LIMIT 1);
File diff suppressed because it is too large Load Diff
+6
View File
@@ -254,6 +254,11 @@ export const users = pgTable("users", {
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
twoFactorEnabled: boolean("two_factor_enabled").default(false), twoFactorEnabled: boolean("two_factor_enabled").default(false),
// 'admin' or 'user', the first account of an instance is its admin
role: text("role").default("user"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamptz("ban_expires"),
createdAt: timestamptz("created_at").defaultNow().notNull(), createdAt: timestamptz("created_at").defaultNow().notNull(),
updatedAt: timestamptz("updated_at") updatedAt: timestamptz("updated_at")
.defaultNow() .defaultNow()
@@ -295,6 +300,7 @@ export const sessions = pgTable(
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
}, },
(table) => [index("sessions_userId_idx").on(table.userId)], (table) => [index("sessions_userId_idx").on(table.userId)],
); );
+47
View File
@@ -1,9 +1,29 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import { db } from "../server/lib/db"; import { db } from "../server/lib/db";
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api';
import { betterAuth } from 'better-auth/minimal'; import { betterAuth } from 'better-auth/minimal';
import { createAccessControl } from 'better-auth/plugins/access';
import { admin } from 'better-auth/plugins/admin';
import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor'; import { twoFactor } from 'better-auth/plugins/two-factor';
import * as schema from '../drizzle/schema'; import * as schema from '../drizzle/schema';
const accessControl = createAccessControl(defaultStatements);
// Admins manage accounts, not content. Signing in as someone else and setting
// another user's password or email are left out on purpose: the first would
// show their chats, the others would lock them out of their encrypted API keys.
const roles = {
admin: accessControl.newRole({
user: ['create', 'list', 'set-role', 'ban', 'delete', 'get', 'update'],
session: ['list', 'revoke', 'delete'],
}),
user: accessControl.newRole({
user: [],
session: [],
}),
};
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
@@ -23,10 +43,37 @@ export const auth = betterAuth({
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000', baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'], trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
hooks: {
// The admin endpoints already stop admins from removing or disabling
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
if (role === undefined) return;
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
if (roleList.includes('admin')) return;
const session = await getSessionFromCtx(ctx);
if (session && String(ctx.body?.userId) === session.user.id) {
throw new APIError('BAD_REQUEST', {
message: 'You cannot remove your own admin role',
code: 'YOU_CANNOT_DEMOTE_YOURSELF',
});
}
}),
},
plugins: [ plugins: [
twoFactor({ twoFactor({
issuer: 'Veridian', issuer: 'Veridian',
}), }),
admin({
ac: accessControl,
roles,
}),
], ],
emailAndPassword: { emailAndPassword: {
+20 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'; import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod'; import * as z from 'zod';
import { import {
agents, agents,
@@ -57,6 +57,25 @@ export default defineEventHandler(async (event) => {
const twoFactorEnabled = !!account?.twoFactorEnabled; const twoFactorEnabled = !!account?.twoFactorEnabled;
// an instance always keeps one admin, someone has to be able to manage it
if (account?.role === 'admin') {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
.limit(1);
if (otherAdmins.length === 0) {
throw createError({
statusCode: 409,
statusMessage: 'The last admin cannot be deleted',
data: {
code: 'LAST_ADMIN',
},
});
}
}
if (twoFactorEnabled && code === undefined) { if (twoFactorEnabled && code === undefined) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
+4
View File
@@ -0,0 +1,4 @@
// Public on purpose: the sign in pages ask this before anyone has an account.
export default defineEventHandler(async () => {
return { needed: await isSetupNeeded() };
});
+91
View File
@@ -0,0 +1,91 @@
import { sql } from 'drizzle-orm';
import * as z from 'zod';
import { users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
// any fixed number works, it only has to be the same for every request
const SETUP_LOCK_ID = 7_261_008;
/**
* Creates the first account of the instance as its admin.
*
* Public, because nobody can be signed in yet, and only works while there are
* no accounts at all. It does not go through sign up, so it also works when
* registration is turned off.
*/
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().trim().min(1).max(128),
email: z.email(),
password: z.string().min(8).max(128),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
},
});
}
const { name, email, password } = result.data;
let alreadyDone = false;
try {
await db.transaction(async (tx) => {
// two people submitting the form at the same moment wait here, and
// the second one then finds the first one's account
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
const existing = await tx.select({ id: users.id }).from(users).limit(1);
if (existing.length > 0) {
alreadyDone = true;
return;
}
await auth.api.createUser({
body: {
name,
email,
password,
role: 'admin',
},
});
});
} catch (error: any) {
const code = error?.body?.code as string | undefined;
if (code === undefined) {
console.error('Failed to create the admin account:', error);
}
throw createError({
statusCode: code === undefined ? 500 : 400,
statusMessage: 'Failed to set up the instance',
message: error?.body?.message ?? 'Failed to set up the instance',
data: {
code: code ?? 'INTERNAL_ERROR',
},
});
}
if (alreadyDone) {
throw createError({
statusCode: 409,
statusMessage: 'This instance is already set up',
data: {
code: 'SETUP_ALREADY_DONE',
},
});
}
return { ok: true };
});
+12
View File
@@ -0,0 +1,12 @@
import { users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* An instance needs setting up for as long as it has no accounts. The first
* account is created by the setup page and becomes the admin.
*/
export const isSetupNeeded = async (): Promise<boolean> => {
const existing = await db.select({ id: users.id }).from(users).limit(1);
return existing.length === 0;
};