diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index f348e826..d3621073 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -164,12 +164,12 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
- - + +
- - + +
diff --git a/app/server/cli/commands/change-email.test.ts b/app/server/cli/commands/change-email.test.ts new file mode 100644 index 00000000..93fd172c --- /dev/null +++ b/app/server/cli/commands/change-email.test.ts @@ -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); + }); +}); diff --git a/app/server/cli/commands/change-email.ts b/app/server/cli/commands/change-email.ts new file mode 100644 index 00000000..a1b2e8ac --- /dev/null +++ b/app/server/cli/commands/change-email.ts @@ -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 => { + 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 of the account") + .option("-e, --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); + }); diff --git a/app/server/cli/commands/reset-password.ts b/app/server/cli/commands/reset-password.ts index fb9225fa..34b6b94c 100644 --- a/app/server/cli/commands/reset-password.ts +++ b/app/server/cli/commands/reset-password.ts @@ -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(); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index 04638c4c..b7c3f82e 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -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);