import { confirm, input, select } from "@inquirer/prompts"; import { Command } from "commander"; import { and, eq, ne } from "drizzle-orm"; import { toMessage } from "~/server/utils/errors"; import { db } from "../../db/db"; import { account, sessionsTable, usersTable } from "../../db/schema"; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; type EmailChangeImpact = { userId: string; previousEmail: string; updatedEmail: string; ssoAccounts: Array<{ providerId: string; accountId: string; }>; }; const listUsers = () => { return db.select({ id: usersTable.id, username: usersTable.username, email: usersTable.email }).from(usersTable); }; export const changeEmailForUser = async (username: string, newEmail: string, precomputedImpact?: EmailChangeImpact) => { const impact = precomputedImpact ?? (await getEmailChangeImpact(username, newEmail)); db.transaction((tx) => { tx.update(usersTable).set({ email: impact.updatedEmail }).where(eq(usersTable.id, impact.userId)).run(); tx.delete(account) .where(and(eq(account.userId, impact.userId), ne(account.providerId, "credential"))) .run(); tx.delete(sessionsTable).where(eq(sessionsTable.userId, impact.userId)).run(); }); return { previousEmail: impact.previousEmail, updatedEmail: impact.updatedEmail, deletedSsoAccounts: impact.ssoAccounts.length, }; }; export const getEmailChangeImpact = async (username: string, newEmail: string): Promise => { const normalizedEmail = newEmail.trim().toLowerCase(); if (!emailRegex.test(normalizedEmail)) { throw new Error(`Invalid email address "${newEmail}"`); } const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username)); if (!user) { throw new Error(`User "${username}" not found`); } if (user.email.trim().toLowerCase() === normalizedEmail) { throw new Error(`User "${username}" already has email "${normalizedEmail}"`); } const [existingUser] = await db .select({ id: usersTable.id }) .from(usersTable) .where(and(eq(usersTable.email, normalizedEmail), ne(usersTable.id, user.id))); if (existingUser) { throw new Error(`Email "${normalizedEmail}" is already in use`); } const [credentialAccount] = await db .select({ id: account.id }) .from(account) .where(and(eq(account.userId, user.id), eq(account.providerId, "credential"))); if (!credentialAccount) { throw new Error(`User "${username}" has no credential account. Reset their password before changing email.`); } const ssoAccounts = ( await db .select({ providerId: account.providerId, accountId: account.accountId, }) .from(account) .where(and(eq(account.userId, user.id), ne(account.providerId, "credential"))) ).sort((left, right) => { const providerCompare = left.providerId.localeCompare(right.providerId); if (providerCompare !== 0) { return providerCompare; } return left.accountId.localeCompare(right.accountId); }); return { userId: user.id, previousEmail: user.email, updatedEmail: normalizedEmail, ssoAccounts, }; }; export const changeEmailCommand = new Command("change-email") .description("Change email for a user and remove linked SSO accounts") .option("-u, --username ", "Username of the account") .option("-e, --email ", "New email for the account") .action(async (options) => { console.info("\n📧 Zerobyte Change Email\n"); let username = options.username; let newEmail = options.email; try { if (!username) { const users = await listUsers(); if (users.length === 0) { console.error("❌ No users found in the database."); console.info(" Please create a user first by starting the application."); process.exit(1); } username = await select({ message: "Select user to change email for:", choices: users.map((user) => ({ name: `${user.username} (${user.email})`, value: user.username, })), }); } if (!newEmail) { newEmail = await input({ message: "Enter the new email:", validate: (value) => { if (!emailRegex.test(value.trim())) { return "Please enter a valid email address"; } return true; }, }); } const impact = await getEmailChangeImpact(username, newEmail); if (impact.ssoAccounts.length > 0) { console.warn("\n⚠️ Disclaimer: changing this email will delete the following linked SSO account(s):"); for (const ssoAccount of impact.ssoAccounts) { console.warn(` - ${ssoAccount.providerId} (${ssoAccount.accountId})`); } console.warn( " The user will need to be invited again using the new email to regain access with those SSO providers.", ); const shouldContinue = await confirm({ message: `Continue and delete ${impact.ssoAccounts.length} SSO account(s) for "${username}"?`, default: false, }); if (!shouldContinue) { console.info("\nℹ️ Email change cancelled. No data was modified."); process.exit(0); } } const result = await changeEmailForUser(username, newEmail, impact); console.info(`\n✅ Email for "${username}" changed from "${result.previousEmail}" to "${result.updatedEmail}".`); if (result.deletedSsoAccounts > 0) { console.info(` Deleted ${result.deletedSsoAccounts} linked SSO account(s).`); } console.info(" All existing sessions have been invalidated."); } catch (error) { console.error(`\n❌ Failed to change email: ${toMessage(error)}`); process.exit(1); } process.exit(0); });