Polish onboarding, invoices, and time clock while promoting the first registrant to admin.

Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:25 -04:00
co-authored by Cursor
parent c53f2e6c4d
commit 3fb61ff4dd
37 changed files with 1135 additions and 481 deletions
+13
View File
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -48,6 +49,18 @@ export const auth = betterAuth({
verification: schema.verificationTokens,
},
}),
databaseHooks: {
user: {
create: {
after: async (user) => {
if (isDemoUser(user)) {
return;
}
await promoteFirstRealUserIfNeeded(user.id);
},
},
},
},
trustedOrigins: async (request) => {
const origins = [...staticTrustedOrigins];
+15 -1
View File
@@ -1,5 +1,5 @@
import { env } from "~/env";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export {
@@ -39,3 +39,17 @@ export const brand = {
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
export function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
+82
View File
@@ -0,0 +1,82 @@
import { and, asc, eq, ne, sql } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
export const DEMO_USER_EMAIL = "demo@example.com";
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
export function isDemoUser(user: {
email?: string | null;
id?: string | null;
}): boolean {
const email = user.email?.toLowerCase();
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
}
function nonDemoUserConditions() {
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
}
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
);
}
/**
* Role for a user about to be inserted. Call inside a transaction before insert.
*/
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
await acquireFirstUserAdminLock(tx);
const [result] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(nonDemoUserConditions());
return (result?.count ?? 0) === 0 ? "admin" : "user";
}
/**
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
*/
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
await db.transaction(async (tx) => {
await acquireFirstUserAdminLock(tx);
const user = await tx.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, role: true },
});
if (!user || isDemoUser(user)) {
return;
}
const [adminResult] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
if ((adminResult?.count ?? 0) > 0) {
return;
}
const [firstRealUser] = await tx
.select({ id: users.id })
.from(users)
.where(nonDemoUserConditions())
.orderBy(asc(users.createdAt))
.limit(1);
if (firstRealUser?.id === userId) {
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
}
});
}
+17 -2
View File
@@ -1,4 +1,5 @@
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveEffectiveHourlyRate(
enteredRate: number,
@@ -21,7 +22,21 @@ export function resolveClockDescription(
const trimmed = title.trim();
if (trimmed) return trimmed;
if (existingDescription?.trim()) return existingDescription.trim();
return DEFAULT_CLOCK_DESCRIPTION;
return "";
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
return trimmed;
}
export function resolveBillingDescription(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
}
return trimmed;
}
export type ClockOutOutcome =