feat: setup and roles
This commit is contained in:
@@ -66,6 +66,9 @@ const submit = async () => {
|
||||
needsCode.value = true;
|
||||
error.value = 'This account has two-factor sign-in on. Enter a code to continue.';
|
||||
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:
|
||||
error.value = 'Your account could not be deleted. Nothing was deleted, try again.';
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum AuthError {
|
||||
|
||||
export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
type sessionData = {
|
||||
@@ -35,6 +36,7 @@ export const useAuth = () => {
|
||||
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
||||
const user = useState<AuthUser | null>('auth:user', () => null);
|
||||
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;
|
||||
|
||||
@@ -244,6 +246,7 @@ export const useAuth = () => {
|
||||
client: authClient,
|
||||
session,
|
||||
user,
|
||||
isAdmin,
|
||||
loggedIn: computed(() => !!session.value),
|
||||
signIn,
|
||||
verifyTwoFactor,
|
||||
|
||||
@@ -22,6 +22,31 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
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 (!loggedIn.value && !isAuthPage) {
|
||||
return await navigateTo(`/auth/login?to=${to.path}`);
|
||||
|
||||
@@ -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>
|
||||
@@ -6,9 +6,15 @@ const AVATAR_SIZE = 256;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
const { user, updateProfile } = useAuth();
|
||||
const { user, isAdmin, updateProfile } = useAuth();
|
||||
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 avatarBusy = ref(false);
|
||||
const avatarError = ref('');
|
||||
@@ -239,12 +245,24 @@ const memberSince = computed(() => {
|
||||
</span>
|
||||
</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
|
||||
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">
|
||||
<h2 class="text-[15px] font-semibold">Delete account</h2>
|
||||
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
|
||||
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>
|
||||
</div>
|
||||
<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
@@ -254,6 +254,11 @@ export const users = pgTable("users", {
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
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(),
|
||||
updatedAt: timestamptz("updated_at")
|
||||
.defaultNow()
|
||||
@@ -295,6 +300,7 @@ export const sessions = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("sessions_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
+47
@@ -1,9 +1,29 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||
import { db } from "../server/lib/db";
|
||||
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api';
|
||||
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 * 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({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
@@ -23,10 +43,37 @@ export const auth = betterAuth({
|
||||
baseURL: 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: [
|
||||
twoFactor({
|
||||
issuer: 'Veridian',
|
||||
}),
|
||||
admin({
|
||||
ac: accessControl,
|
||||
roles,
|
||||
}),
|
||||
],
|
||||
|
||||
emailAndPassword: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import {
|
||||
agents,
|
||||
@@ -57,6 +57,25 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
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) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
|
||||
@@ -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() };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user