Files
zerobyte/app/server/cli/commands/change-email.ts
Nico fb3c5b33c0 feat: change email by cli (#611)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * New CLI command to change user email addresses with impact preview and validation.

* **Accessibility**
  * Improved settings form with proper label-input associations and email field type validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-03 21:46:47 +01:00

180 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<EmailChangeImpact> => {
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>", "Username of the account")
.option("-e, --email <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);
});