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:
parent
4bb6a52431
commit
fb3c5b33c0
5 changed files with 356 additions and 7 deletions
|
|
@ -164,12 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Username</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input value={appContext.user?.username} disabled className="max-w-md" />
|
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input value={appContext.user?.email} disabled className="max-w-md" />
|
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
|
||||||
151
app/server/cli/commands/change-email.test.ts
Normal file
151
app/server/cli/commands/change-email.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
179
app/server/cli/commands/change-email.ts
Normal file
179
app/server/cli/commands/change-email.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -21,10 +21,27 @@ const resetPassword = async (username: string, newPassword: string) => {
|
||||||
const legacyHash = user.passwordHash ? await Bun.password.hash(newPassword) : null;
|
const legacyHash = user.passwordHash ? await Bun.password.hash(newPassword) : null;
|
||||||
|
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
tx.update(account)
|
const existingAccount = tx
|
||||||
.set({ password: newPasswordHash })
|
.select()
|
||||||
|
.from(account)
|
||||||
.where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
|
.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) {
|
if (legacyHash) {
|
||||||
tx.update(usersTable).set({ passwordHash: legacyHash }).where(eq(usersTable.id, user.id)).run();
|
tx.update(usersTable).set({ passwordHash: legacyHash }).where(eq(usersTable.id, user.id)).run();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { assignOrganizationCommand } from "./commands/assign-organization";
|
import { assignOrganizationCommand } from "./commands/assign-organization";
|
||||||
|
import { changeEmailCommand } from "./commands/change-email";
|
||||||
import { changeUsernameCommand } from "./commands/change-username";
|
import { changeUsernameCommand } from "./commands/change-username";
|
||||||
import { disable2FACommand } from "./commands/disable-2fa";
|
import { disable2FACommand } from "./commands/disable-2fa";
|
||||||
import { rekey2FACommand } from "./commands/rekey-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(resetPasswordCommand);
|
||||||
program.addCommand(disable2FACommand);
|
program.addCommand(disable2FACommand);
|
||||||
program.addCommand(changeUsernameCommand);
|
program.addCommand(changeUsernameCommand);
|
||||||
|
program.addCommand(changeEmailCommand);
|
||||||
program.addCommand(rekey2FACommand);
|
program.addCommand(rekey2FACommand);
|
||||||
program.addCommand(assignOrganizationCommand);
|
program.addCommand(assignOrganizationCommand);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue