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
371ca270fa
commit
fc11432b87
5 changed files with 356 additions and 7 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
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;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue