Skip to content

Commit 690b751

Browse files
committed
feat: add openmapx users verify <email> to bootstrap first admin without SMTP
Better Auth blocks sign-in until `email_verified = true`. On a fresh deployment with no SMTP configured, that creates a chicken-and-egg problem: the first user can't verify by email, so they can't sign in, so they can't reach the admin panel to configure anything. Add a CLI command that flips the column directly via psql in the postgis container, mirroring the existing `promote`/`demote` plumbing. Typical bootstrap is now: sign up → `users verify <email>` → `users promote <email>` → sign in → configure SMTP from /admin → self-service signup works for everyone else.
1 parent b1ce7d0 commit 690b751

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

packages/cli/src/commands/users.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,64 @@ export async function promoteUserToAdmin(email: string, rootDir?: string): Promi
129129
}
130130
}
131131

132+
/**
133+
* Mark a user's email as verified. Useful for bootstrapping the first admin
134+
* on a fresh deployment where SMTP isn't configured yet — Better Auth blocks
135+
* sign-in until `emailVerified = true`, and without SMTP no verification mail
136+
* is ever sent. After verifying + promoting one user, that user signs in,
137+
* configures SMTP, and self-service signup works for everyone else.
138+
*/
139+
export async function markEmailVerified(email: string, rootDir?: string): Promise<void> {
140+
if (!email?.includes("@")) {
141+
throw new Error(`"${email}" does not look like an email address`);
142+
}
143+
const target = await resolvePostgresTarget(rootDir);
144+
const paths = repoPaths(rootDir);
145+
const result = await execa(
146+
"docker",
147+
[
148+
"compose",
149+
"-f",
150+
paths.composeOutPath,
151+
"exec",
152+
"-T",
153+
target.serviceId,
154+
"psql",
155+
"-U",
156+
target.user,
157+
"-d",
158+
target.db,
159+
"-v",
160+
"ON_ERROR_STOP=1",
161+
"--no-psqlrc",
162+
"-A",
163+
"-t",
164+
"-v",
165+
`email=${email}`,
166+
"-c",
167+
`UPDATE "user" SET email_verified = true WHERE email = :'email' RETURNING id;`,
168+
],
169+
{ cwd: paths.infraDir, reject: false },
170+
);
171+
if (result.exitCode !== 0) {
172+
throw new Error(
173+
`psql failed (exit ${result.exitCode}): ${result.stderr?.trim() || result.stdout?.trim() || "no output"}`,
174+
);
175+
}
176+
const updatedIds = (result.stdout ?? "")
177+
.split("\n")
178+
.map((s) => s.trim())
179+
.filter(Boolean);
180+
if (updatedIds.length === 0) {
181+
throw new Error(
182+
`No user found with email "${email}". Sign up through the web UI first, then re-run this command.`,
183+
);
184+
}
185+
if (updatedIds.length > 1) {
186+
log.warn(`${updatedIds.length} users matched "${email}" — verified all of them`);
187+
}
188+
}
189+
132190
export async function listUsers(
133191
rootDir?: string,
134192
): Promise<Array<{ id: string; email: string; name: string; role: string }>> {
@@ -183,6 +241,21 @@ export function registerUsersCommands(program: Command): void {
183241
}
184242
});
185243

244+
users
245+
.command("verify <email>")
246+
.description(
247+
"Mark a user's email as verified (bootstraps the first admin when SMTP isn't configured yet)",
248+
)
249+
.action(async (email: string) => {
250+
try {
251+
await markEmailVerified(email);
252+
log.ok(`Marked ${email} as email-verified`);
253+
} catch (err) {
254+
log.err((err as Error).message);
255+
process.exit(1);
256+
}
257+
});
258+
186259
users
187260
.command("demote <email>")
188261
.description("Remove admin role from a user (sets role back to user)")

0 commit comments

Comments
 (0)