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 -->
This commit is contained in:
Nico 2026-03-03 21:46:47 +01:00 committed by GitHub
parent 4bb6a52431
commit fb3c5b33c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 356 additions and 7 deletions

View file

@ -164,12 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</div>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={appContext.user?.username} disabled className="max-w-md" />
<Label htmlFor="username">Username</Label>
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={appContext.user?.email} disabled className="max-w-md" />
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
</div>
</CardContent>

View file

@ -0,0 +1,151 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { account, sessionsTable, usersTable } from "~/server/db/schema";
import { changeEmailForUser, getEmailChangeImpact } from "./change-email";
const randomId = () => Bun.randomUUIDv7();
const randomSlug = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
const insertUser = async (username: string, email: string) => {
const id = randomId();
await db.insert(usersTable).values({
id,
username,
name: username,
email,
});
return { id, username, email };
};
const insertCredentialAccount = async (userId: string) => {
await db.insert(account).values({
id: randomId(),
accountId: userId,
providerId: "credential",
userId,
password: randomSlug("hash"),
});
};
const insertSsoAccount = async (userId: string, providerId: string, accountId = randomSlug("oidc-account")) => {
await db.insert(account).values({
id: randomId(),
accountId,
providerId,
userId,
});
return accountId;
};
const insertSession = async (userId: string) => {
await db.insert(sessionsTable).values({
id: randomId(),
userId,
token: randomSlug("token"),
expiresAt: new Date(Date.now() + 60_000),
});
};
describe("changeEmailForUser", () => {
beforeEach(async () => {
await db.delete(sessionsTable);
await db.delete(account);
await db.delete(usersTable);
});
test("changes email, deletes linked SSO accounts, and invalidates sessions", async () => {
const user = await insertUser("alice", "alice@example.com");
await insertCredentialAccount(user.id);
await insertSsoAccount(user.id, "oidc-google");
await insertSession(user.id);
const result = await changeEmailForUser("alice", "new-alice@example.com");
expect(result).toEqual({
previousEmail: "alice@example.com",
updatedEmail: "new-alice@example.com",
deletedSsoAccounts: 1,
});
const [updatedUser] = await db
.select({ email: usersTable.email })
.from(usersTable)
.where(eq(usersTable.id, user.id));
expect(updatedUser?.email).toBe("new-alice@example.com");
const remainingAccounts = await db
.select({ providerId: account.providerId })
.from(account)
.where(eq(account.userId, user.id));
expect(remainingAccounts).toEqual([{ providerId: "credential" }]);
const sessions = await db
.select({ id: sessionsTable.id })
.from(sessionsTable)
.where(eq(sessionsTable.userId, user.id));
expect(sessions).toHaveLength(0);
});
test("fails when the user has no credential account", async () => {
const user = await insertUser("bob", "bob@example.com");
await insertSsoAccount(user.id, "oidc-github");
await expect(changeEmailForUser("bob", "new-bob@example.com")).rejects.toThrow("no credential account");
});
test("fails when the target email is already in use", async () => {
const firstUser = await insertUser("carol", "carol@example.com");
await insertCredentialAccount(firstUser.id);
const secondUser = await insertUser("dave", "dave@example.com");
await insertCredentialAccount(secondUser.id);
await expect(changeEmailForUser("carol", "dave@example.com")).rejects.toThrow("already in use");
});
test("returns linked SSO accounts when previewing impact", async () => {
const user = await insertUser("eve", "eve@example.com");
await insertCredentialAccount(user.id);
await insertSsoAccount(user.id, "oidc-google", "google-eve");
await insertSsoAccount(user.id, "oidc-github", "github-eve");
const impact = await getEmailChangeImpact("eve", "eve-new@example.com");
expect(impact.ssoAccounts).toEqual([
{ providerId: "oidc-github", accountId: "github-eve" },
{ providerId: "oidc-google", accountId: "google-eve" },
]);
});
test("returns no SSO warning candidates when user has no SSO accounts", async () => {
const user = await insertUser("frank", "frank@example.com");
await insertCredentialAccount(user.id);
const impact = await getEmailChangeImpact("frank", "frank-new@example.com");
expect(impact.ssoAccounts).toEqual([]);
});
test("rejects changing to the same email and leaves SSO accounts and sessions unchanged", async () => {
const user = await insertUser("grace", "grace@example.com");
await insertCredentialAccount(user.id);
await insertSsoAccount(user.id, "oidc-google", "google-grace");
await insertSession(user.id);
await expect(changeEmailForUser("grace", "grace@example.com")).rejects.toThrow("already has email");
const impact = await getEmailChangeImpact("grace", "grace-different@example.com");
expect(impact.ssoAccounts).toEqual([{ providerId: "oidc-google", accountId: "google-grace" }]);
const sessions = await db
.select({ id: sessionsTable.id })
.from(sessionsTable)
.where(eq(sessionsTable.userId, user.id));
expect(sessions).toHaveLength(1);
});
});

View file

@ -0,0 +1,179 @@
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);
});

View file

@ -21,10 +21,27 @@ const resetPassword = async (username: string, newPassword: string) => {
const legacyHash = user.passwordHash ? await Bun.password.hash(newPassword) : null;
db.transaction((tx) => {
tx.update(account)
.set({ password: newPasswordHash })
const existingAccount = tx
.select()
.from(account)
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
.run();
.get();
if (existingAccount) {
tx.update(account).set({ password: newPasswordHash }).where(eq(account.id, existingAccount.id)).run();
} else {
tx.insert(account)
.values({
id: crypto.randomUUID(),
providerId: "credential",
accountId: user.username,
userId: user.id,
password: newPasswordHash,
createdAt: new Date(),
updatedAt: new Date(),
})
.run();
}
if (legacyHash) {
tx.update(usersTable).set({ passwordHash: legacyHash }).where(eq(usersTable.id, user.id)).run();

View file

@ -1,5 +1,6 @@
import { Command } from "commander";
import { assignOrganizationCommand } from "./commands/assign-organization";
import { changeEmailCommand } from "./commands/change-email";
import { changeUsernameCommand } from "./commands/change-username";
import { disable2FACommand } from "./commands/disable-2fa";
import { rekey2FACommand } from "./commands/rekey-2fa";
@ -12,6 +13,7 @@ program.name("zerobyte").description("Zerobyte CLI - Backup automation tool buil
program.addCommand(resetPasswordCommand);
program.addCommand(disable2FACommand);
program.addCommand(changeUsernameCommand);
program.addCommand(changeEmailCommand);
program.addCommand(rekey2FACommand);
program.addCommand(assignOrganizationCommand);