mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-18 13:57:52 -04:00
<!-- 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 -->
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
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);
|
||
});
|