feat: setup and roles
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 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
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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