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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user