From 7b0800882a84608545fd0d0876cea53d61f56b24 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 8 Oct 2026 13:08:20 -0500 Subject: [PATCH] feat: setup and roles --- app/components/Dialog/DeleteAccount.vue | 3 + app/composables/useAuth.ts | 3 + app/middleware/auth.global.ts | 25 + app/pages/auth/setup.vue | 226 ++ app/pages/settings/profile.vue | 20 +- .../20261008180028_admin-roles/migration.sql | 7 + .../20261008180028_admin-roles/snapshot.json | 3039 +++++++++++++++++ drizzle/schema.ts | 6 + lib/auth.ts | 47 + server/api/account/index.delete.ts | 21 +- server/api/setup/index.get.ts | 4 + server/api/setup/index.post.ts | 91 + server/utils/setup.ts | 12 + 13 files changed, 3502 insertions(+), 2 deletions(-) create mode 100644 app/pages/auth/setup.vue create mode 100644 drizzle/migrations/20261008180028_admin-roles/migration.sql create mode 100644 drizzle/migrations/20261008180028_admin-roles/snapshot.json create mode 100644 server/api/setup/index.get.ts create mode 100644 server/api/setup/index.post.ts create mode 100644 server/utils/setup.ts diff --git a/app/components/Dialog/DeleteAccount.vue b/app/components/Dialog/DeleteAccount.vue index 72a3b48..dbcb2c8 100644 --- a/app/components/Dialog/DeleteAccount.vue +++ b/app/components/Dialog/DeleteAccount.vue @@ -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; diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index b0e1849..798c412 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -15,6 +15,7 @@ export enum AuthError { export type AuthUser = InferUserFromClient & { twoFactorEnabled?: boolean | null; + role?: string | null; }; type sessionData = { @@ -35,6 +36,7 @@ export const useAuth = () => { const session = useState | null>('auth:session', () => null); const user = useState('auth:user', () => null); const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false); + const isAdmin = computed(() => user.value?.role === 'admin'); let sessionPromise: Promise> | null = null; @@ -244,6 +246,7 @@ export const useAuth = () => { client: authClient, session, user, + isAdmin, loggedIn: computed(() => !!session.value), signIn, verifyTwoFactor, diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index ee53414..98f4129 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -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('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}`); diff --git a/app/pages/auth/setup.vue b/app/pages/auth/setup.vue new file mode 100644 index 0000000..88528a4 --- /dev/null +++ b/app/pages/auth/setup.vue @@ -0,0 +1,226 @@ + + + diff --git a/app/pages/settings/profile.vue b/app/pages/settings/profile.vue index ed24ebd..d66b51a 100644 --- a/app/pages/settings/profile.vue +++ b/app/pages/settings/profile.vue @@ -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(null); const avatarBusy = ref(false); const avatarError = ref(''); @@ -239,12 +245,24 @@ const memberSince = computed(() => { +
+ Role +
+ Admin + User +
+
+

Delete account

Permanently deletes your agents, topics, and api keys from this instance. +