From 5be93c65ea03730605f97390347bdf3bf3b559f2 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Sat, 6 Jun 2026 00:02:43 +0100 Subject: [PATCH] refactor: replace admin bootstrap script with first-run setup --- README.md | 21 +--- package.json | 5 +- scripts/create-admin.mjs | 90 -------------- src/lib/components/first-admin-form.svelte | 131 +++++++++++++++++++++ src/lib/schemas/auth.ts | 7 ++ src/routes/login/+page.server.ts | 63 +++++++++- src/routes/login/+page.svelte | 7 +- 7 files changed, 210 insertions(+), 114 deletions(-) delete mode 100644 scripts/create-admin.mjs create mode 100644 src/lib/components/first-admin-form.svelte diff --git a/README.md b/README.md index b9b5900..1b4aaa8 100644 --- a/README.md +++ b/README.md @@ -32,22 +32,14 @@ Apply database migrations: DATABASE_URL=file:local.db pnpm db:migrate ``` -Create an initial admin user: - -```sh -ADMIN_EMAIL=admin@example.com \ -ADMIN_PASSWORD='change-me' \ -ADMIN_NAME='Administrator' \ -DATABASE_URL=file:local.db \ -pnpm admin:create -``` - Start the development server: ```sh pnpm dev ``` +Open `http://localhost:5173/login`. If no Better Auth users exist yet, Clearity shows a first-admin setup form instead of the normal sign-in form. Submitting it creates the initial user with the default Better Auth `admin` role and signs you in. + Useful development commands: ```sh @@ -68,13 +60,6 @@ Required: - `ORIGIN`: Public app origin, for example `http://localhost:5173` locally or `https://clearity.example.com` in production. - `BETTER_AUTH_SECRET`: Secret used by Better Auth. -Admin bootstrap script: - -- `ADMIN_EMAIL`: Email address for the admin account. -- `ADMIN_PASSWORD`: Password for the admin account. -- `ADMIN_NAME`: Optional display name. Defaults to `Administrator`. -- `ADMIN_OVERWRITE`: Set to `1` to update an existing admin user. - ## Database The application schema lives in `src/lib/server/db`. Drizzle migration files live in `drizzle`. @@ -163,4 +148,4 @@ Preview the built Worker locally: pnpm preview ``` -Create the first production admin user by running the admin script against the production database connection before exposing the app to users. +Create the first production admin by opening `/login` after migrations have run. The setup form is only shown while the Better Auth `user` table is empty. diff --git a/package.json b/package.json index 3698f3c..3ac8b96 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite dev", "build": "wrangler types --check && vite build", - "preview": "wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173", "prepare": "svelte-kit sync || echo ''", "check": "wrangler types --check && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -16,9 +15,7 @@ "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio", - "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes", - "admin:create": "sh -c 'db=\"${DATABASE_URL:-$([ -d .wrangler/state/v3/d1 ] && find .wrangler/state/v3/d1 -name \"*.sqlite\" -print -quit)}\"; if [ -z \"$db\" ]; then echo \"No local Wrangler D1 SQLite database found. Start wrangler dev or run a local D1 command first.\" >&2; exit 1; fi; if [ -z \"$DATABASE_URL\" ]; then db=\"file:$db\"; fi; DATABASE_URL=\"$db\" node scripts/create-admin.mjs'" + "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" }, "devDependencies": { "@better-auth/cli": "~1.4.21", diff --git a/scripts/create-admin.mjs b/scripts/create-admin.mjs deleted file mode 100644 index 9752a4e..0000000 --- a/scripts/create-admin.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import { createClient } from '@libsql/client'; -import { hashPassword } from 'better-auth/crypto'; -import { readFileSync } from 'node:fs'; -import { randomUUID } from 'node:crypto'; - -function readDotEnv() { - try { - const entries = readFileSync('.env', 'utf8') - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map((line) => { - const index = line.indexOf('='); - const key = line.slice(0, index); - const value = line.slice(index + 1).replace(/^"|"$/g, ''); - return [key, value]; - }); - - for (const [key, value] of entries) { - process.env[key] ??= value; - } - } catch { - // .env is optional in deployed or scripted environments. - } -} - -readDotEnv(); - -const databaseUrl = process.env.DATABASE_URL; -const email = process.env.ADMIN_EMAIL?.trim().toLowerCase(); -const password = process.env.ADMIN_PASSWORD; -const name = process.env.ADMIN_NAME?.trim() || 'Administrator'; -const overwrite = process.env.ADMIN_OVERWRITE === '1'; - -if (!databaseUrl) { - console.error('DATABASE_URL is required.'); - process.exit(1); -} - -if (!email || !password) { - console.error('ADMIN_EMAIL and ADMIN_PASSWORD are required.'); - console.error('Example: ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=change-me pnpm admin:create'); - process.exit(1); -} - -const client = createClient({ url: databaseUrl }); -const now = Date.now(); -const existing = await client.execute({ - sql: 'select id from user where email = ? limit 1', - args: [email] -}); - -if (existing.rows.length > 0 && !overwrite) { - console.error(`A user with email ${email} already exists. Set ADMIN_OVERWRITE=1 to update the password.`); - process.exit(1); -} - -const userId = existing.rows[0]?.id?.toString() ?? randomUUID(); -const passwordHash = await hashPassword(password); - -if (existing.rows.length === 0) { - await client.execute({ - sql: 'insert into user (id, name, email, email_verified, image, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)', - args: [userId, name, email, 1, null, now, now] - }); -} else { - await client.execute({ - sql: 'update user set name = ?, email_verified = ?, updated_at = ? where id = ?', - args: [name, 1, now, userId] - }); -} - -const account = await client.execute({ - sql: 'select id from account where user_id = ? and provider_id = ? limit 1', - args: [userId, 'credential'] -}); - -if (account.rows.length === 0) { - await client.execute({ - sql: 'insert into account (id, account_id, provider_id, user_id, password, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)', - args: [randomUUID(), userId, 'credential', userId, passwordHash, now, now] - }); -} else { - await client.execute({ - sql: 'update account set password = ?, updated_at = ? where id = ?', - args: [passwordHash, now, account.rows[0].id] - }); -} - -console.log(`Admin user ready: ${email}`); diff --git a/src/lib/components/first-admin-form.svelte b/src/lib/components/first-admin-form.svelte new file mode 100644 index 0000000..29f85f6 --- /dev/null +++ b/src/lib/components/first-admin-form.svelte @@ -0,0 +1,131 @@ + + +
+
+ +
+
+
+ +
+ Clearity +
+

Create the first admin

+ + Set up the initial Better Auth admin account for this workspace. + +
+ + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + + {#snippet children({ props })} + Email + + {/snippet} + + + + + + + + {#snippet children({ props })} + Password + + {/snippet} + + + + + + + +
+
+ + This setup screen disappears once the first user exists. + +
diff --git a/src/lib/schemas/auth.ts b/src/lib/schemas/auth.ts index 5305c30..6af25d6 100644 --- a/src/lib/schemas/auth.ts +++ b/src/lib/schemas/auth.ts @@ -5,4 +5,11 @@ export const loginSchema = z.object({ password: z.string().min(1, 'Enter your password.') }); +export const firstAdminSchema = z.object({ + name: z.string().trim().min(1, 'Enter your name.'), + email: z.email('Enter a valid email address.').transform((value) => value.toLowerCase()), + password: z.string().min(8, 'Password must be at least 8 characters.') +}); + export type LoginSchema = typeof loginSchema; +export type FirstAdminSchema = typeof firstAdminSchema; diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 095eb9b..a180357 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,9 +1,12 @@ import { redirect } from '@sveltejs/kit'; import { APIError } from 'better-auth/api'; +import { count } from 'drizzle-orm'; import { message, superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { auth } from '$lib/server/auth'; -import { loginSchema } from '$lib/schemas/auth'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { firstAdminSchema, loginSchema } from '$lib/schemas/auth'; import type { Actions, PageServerLoad } from './$types'; function getSafeRedirect(url: URL) { @@ -21,7 +24,11 @@ export const load: PageServerLoad = async ({ locals, url }) => { redirect(303, getSafeRedirect(url)); } + const [{ total }] = await db.select({ total: count() }).from(user); + const needsBootstrap = total === 0; + return { + needsBootstrap, form: await superValidate( { email: url.searchParams.get('email') ?? '', @@ -29,11 +36,65 @@ export const load: PageServerLoad = async ({ locals, url }) => { }, zod4(loginSchema), { id: 'login' } + ), + firstAdminForm: await superValidate( + { + name: '', + email: url.searchParams.get('email') ?? '', + password: '' + }, + zod4(firstAdminSchema), + { id: 'first-admin' } ) }; }; export const actions: Actions = { + bootstrap: async (event) => { + const form = await superValidate(event, zod4(firstAdminSchema), { id: 'first-admin' }); + + if (!form.valid) { + return message(form, 'Check the highlighted fields.', { status: 400 }); + } + + const [{ total }] = await db.select({ total: count() }).from(user); + + if (total > 0) { + return message(form, 'The first admin account has already been created.', { status: 409 }); + } + + try { + await auth.api.createUser({ + body: { + name: form.data.name, + email: form.data.email, + password: form.data.password, + role: 'admin' + } + }); + + await auth.api.signInEmail({ + body: { + email: form.data.email, + password: form.data.password, + callbackURL: getSafeRedirect(event.url) + } + }); + } catch (error) { + if (error instanceof APIError) { + return message(form, error.message || 'Unable to create the first admin account.', { + status: 400 + }); + } + + return message(form, 'Something went wrong while creating the first admin account.', { + status: 500 + }); + } + + redirect(303, getSafeRedirect(event.url)); + }, + default: async (event) => { const form = await superValidate(event, zod4(loginSchema), { id: 'login' }); diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 7477df8..6cc4ae1 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,4 +1,5 @@