diff --git a/.changeset/eight-yaks-itch.md b/.changeset/eight-yaks-itch.md new file mode 100644 index 0000000..087eeb7 --- /dev/null +++ b/.changeset/eight-yaks-itch.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Added email display in account settings page. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9eb0e7a..b88b375 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,6 +16,7 @@ import type { import type * as auth from "../auth.js"; import type * as constants from "../constants.js"; import type * as documents from "../documents.js"; +import type * as errors from "../errors.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as passwordReset from "../passwordReset.js"; @@ -25,8 +26,8 @@ import type * as seed from "../seed.js"; import type * as topics from "../topics.js"; import type * as userFormData from "../userFormData.js"; import type * as userQuests from "../userQuests.js"; -import type * as userSettings from "../userSettings.js"; import type * as users from "../users.js"; +import type * as userSettings from "../userSettings.js"; import type * as validators from "../validators.js"; /** @@ -41,6 +42,7 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; constants: typeof constants; documents: typeof documents; + errors: typeof errors; helpers: typeof helpers; http: typeof http; passwordReset: typeof passwordReset; @@ -50,8 +52,8 @@ declare const fullApi: ApiFromModules<{ topics: typeof topics; userFormData: typeof userFormData; userQuests: typeof userQuests; - userSettings: typeof userSettings; users: typeof users; + userSettings: typeof userSettings; validators: typeof validators; }>; export declare const api: FilterApi< diff --git a/convex/errors.ts b/convex/errors.ts new file mode 100644 index 0000000..c844652 --- /dev/null +++ b/convex/errors.ts @@ -0,0 +1,3 @@ +export const INVALID_PASSWORD = "INVALID_PASSWORD"; +export const INVALID_EMAIL = "INVALID_EMAIL"; +export const DUPLICATE_EMAIL = "DUPLICATE_EMAIL"; diff --git a/convex/users.test.ts b/convex/users.test.ts index 19abe6a..226515f 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -1,6 +1,7 @@ import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; import { api } from "./_generated/api"; +import { DUPLICATE_EMAIL, INVALID_EMAIL } from "./errors"; import schema from "./schema"; import { modules } from "./test.setup"; @@ -149,6 +150,68 @@ describe("users", () => { }); }); + describe("setEmail", () => { + it("should update the user's email", async () => { + const t = convexTest(schema, modules); + + const userId = await t.run(async (ctx) => { + return await ctx.db.insert("users", { + email: "old@example.com", + role: "user", + }); + }); + + const asUser = t.withIdentity({ subject: userId }); + await asUser.mutation(api.users.setEmail, { + email: "new@example.com", + }); + + const user = await t.run(async (ctx) => { + return await ctx.db.get(userId); + }); + expect(user?.email).toBe("new@example.com"); + }); + + it("should throw an error for an invalid email", async () => { + const t = convexTest(schema, modules); + + const userId = await t.run(async (ctx) => { + return await ctx.db.insert("users", { + email: "valid@example.com", + role: "user", + }); + }); + + const asUser = t.withIdentity({ subject: userId }); + await expect( + asUser.mutation(api.users.setEmail, { email: "invalid-email" }), + ).rejects.toThrow(INVALID_EMAIL); + }); + + it("should throw an error for a duplicate email", async () => { + const t = convexTest(schema, modules); + + await t.run(async (ctx) => { + await ctx.db.insert("users", { + email: "existing@example.com", + role: "user", + }); + }); + + const userId = await t.run(async (ctx) => { + return await ctx.db.insert("users", { + email: "new@example.com", + role: "user", + }); + }); + + const asUser = t.withIdentity({ subject: userId }); + await expect( + asUser.mutation(api.users.setEmail, { email: "existing@example.com" }), + ).rejects.toThrow(DUPLICATE_EMAIL); + }); + }); + describe("setBirthplace", () => { it("should update user birthplace", async () => { const t = convexTest(schema, modules); diff --git a/convex/users.ts b/convex/users.ts index 6a1a56b..ba1a97b 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,7 +1,10 @@ import { getAuthUserId } from "@convex-dev/auth/server"; import { v } from "convex/values"; +import { ConvexError } from "convex/values"; +import { z } from "zod"; import { query } from "./_generated/server"; import type { Role } from "./constants"; +import { DUPLICATE_EMAIL, INVALID_EMAIL } from "./errors"; import { userMutation, userQuery } from "./helpers"; import { jurisdiction } from "./validators"; @@ -50,6 +53,29 @@ export const setName = userMutation({ }, }); +const ParamsSchema = z.object({ + email: z.string().email(), +}); + +export const setEmail = userMutation({ + args: { email: v.optional(v.string()) }, + handler: async (ctx, args) => { + const { error } = ParamsSchema.safeParse(args); + if (error) { + throw new ConvexError(INVALID_EMAIL); + } + + const existingUser = await getByEmail(ctx, { + email: args.email as string, + }); + if (existingUser && existingUser._id !== ctx.userId) { + throw new ConvexError(DUPLICATE_EMAIL); + } + + await ctx.db.patch(ctx.userId, { email: args.email }); + }, +}); + export const setResidence = userMutation({ args: { residence: jurisdiction }, handler: async (ctx, args) => { diff --git a/package.json b/package.json index b992ba6..f0d6f59 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "tailwindcss": "^3.4.16", "tailwindcss-animate": "^1.0.7", "text-readability": "^1.1.0", - "use-debounce": "^10.0.4" + "use-debounce": "^10.0.4", + "zod": "^3.24.1" }, "devDependencies": { "@axe-core/playwright": "^4.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e1278f..cf68ce5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: use-debounce: specifier: ^10.0.4 version: 10.0.4(react@19.0.0) + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@axe-core/playwright': specifier: ^4.10.1 diff --git a/src/components/settings/EditEmailSetting/EditEmailSetting.tsx b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx new file mode 100644 index 0000000..5922074 --- /dev/null +++ b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx @@ -0,0 +1,121 @@ +import { + Banner, + Button, + Form, + Modal, + ModalFooter, + ModalHeader, + TextField, +} from "@/components/common"; +import { api } from "@convex/_generated/api"; +import type { Doc } from "@convex/_generated/dataModel"; +import { DUPLICATE_EMAIL, INVALID_EMAIL } from "@convex/errors"; +import { useMutation } from "convex/react"; +import { ConvexError } from "convex/values"; +import { Pencil } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { SettingsItem } from "../SettingsItem"; + +type EditEmailModalProps = { + defaultEmail: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSubmit: () => void; +}; + +const EditEmailModal = ({ + defaultEmail, + isOpen, + onOpenChange, + onSubmit, +}: EditEmailModalProps) => { + const updateEmail = useMutation(api.users.setEmail); + const [email, setEmail] = useState(defaultEmail); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + try { + setIsSubmitting(true); + await updateEmail({ email: email.trim() }); + onSubmit(); + toast.success("Email updated."); + } catch (err) { + if (err instanceof ConvexError) { + if (err.data === INVALID_EMAIL) { + setError("Please enter a valid email address."); + } else if (err.data === DUPLICATE_EMAIL) { + setError("This email is currently in use. Try another one."); + } else { + setError("Something went wrong. Please try again later."); + } + } else { + setError("Failed to update email. Please try again."); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+ {error && {error}} + setEmail(value)} + className="w-full" + isRequired + /> + + + + + +
+ ); +}; + +type EditEmailSettingProps = { + user: Doc<"users">; +}; + +export const EditEmailSetting = ({ user }: EditEmailSettingProps) => { + const [isEmailModalOpen, setIsEmailModalOpen] = useState(false); + + return ( + + + setIsEmailModalOpen(false)} + /> + + ); +}; diff --git a/src/components/settings/EditEmailSetting/EditEmailSettings.test.tsx b/src/components/settings/EditEmailSetting/EditEmailSettings.test.tsx new file mode 100644 index 0000000..c0e786e --- /dev/null +++ b/src/components/settings/EditEmailSetting/EditEmailSettings.test.tsx @@ -0,0 +1,178 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { DUPLICATE_EMAIL, INVALID_EMAIL } from "@convex/errors"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { ConvexError } from "convex/values"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditEmailSetting } from "./EditEmailSetting"; + +describe("EditEmailSetting", () => { + const mockUser: Doc<"users"> = { + _id: "012" as Id<"users">, + email: "testuser@example.com", + name: "Test user", + role: "user", + _creationTime: 12542, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the correct email if it exists", () => { + render(); + expect(screen.getByText("testuser@example.com")).toBeInTheDocument(); + }); + + it("renders 'Set email' if email is not set", () => { + render(); + expect( + screen.getByRole("button", { name: "Set email" }), + ).toBeInTheDocument(); + }); + + it("populates the correct email when the modal is opened", async () => { + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue( + "testuser@example.com", + ); + }); + + it("displays an error for an invalid email address", async () => { + const user = userEvent.setup(); + const mockError = new ConvexError(INVALID_EMAIL); + + const updateEmail = vi.fn().mockRejectedValue(mockError); + (useMutation as ReturnType).mockReturnValue(updateEmail); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@zmail"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Please enter a valid email address."), + ).toBeInTheDocument(); + }); + + it("displays an error when the email is already in use", async () => { + const user = userEvent.setup(); + const mockError = new ConvexError(DUPLICATE_EMAIL); + + const updateEmail = vi.fn().mockRejectedValue(mockError); + (useMutation as ReturnType).mockReturnValue(updateEmail); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@example.com"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText( + "This email is currently in use. Try another one.", + ), + ).toBeInTheDocument(); + }); + + it("displays a fallback error when an unknown ConvexError is received", async () => { + const user = userEvent.setup(); + const mockError = new ConvexError("UNKNOWN_ERROR"); + + const updateEmail = vi.fn().mockRejectedValue(mockError); + (useMutation as ReturnType).mockReturnValue(updateEmail); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@example.com"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Something went wrong. Please try again later."), + ).toBeInTheDocument(); + }); + + it("displays a generic error when a non-ConvexError is received", async () => { + const user = userEvent.setup(); + const updateEmail = vi + .fn() + .mockRejectedValue( + new Error("Failed to update email. Please try again."), + ); + (useMutation as ReturnType).mockReturnValue(updateEmail); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@example.com"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect( + await screen.findByText("Failed to update email. Please try again."), + ).toBeInTheDocument(); + }); + + it("submits the form successfully", async () => { + const user = userEvent.setup(); + const updateEmail = vi.fn().mockResolvedValue({}); + (useMutation as ReturnType).mockReturnValue(updateEmail); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@example.com"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(updateEmail).toHaveBeenCalledWith({ + email: "newuser@example.com", + }), + ); + expect(toast.success).toHaveBeenCalledWith("Email updated."); + }); + + it("closes the modal without saving when the cancel button is clicked", async () => { + const user = userEvent.setup(); + + render(); + await user.click( + screen.getByRole("button", { name: "testuser@example.com" }), + ); + + const input = screen.getByRole("textbox", { name: /email/i }); + await user.clear(input); + await user.type(input, "newuser@example.com"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByText("Email address")).not.toBeInTheDocument(); + expect(screen.getByText("testuser@example.com")).toBeInTheDocument(); + }); +}); diff --git a/src/components/settings/EditEmailSetting/index.ts b/src/components/settings/EditEmailSetting/index.ts new file mode 100644 index 0000000..80982e8 --- /dev/null +++ b/src/components/settings/EditEmailSetting/index.ts @@ -0,0 +1 @@ +export * from "./EditEmailSetting"; diff --git a/src/components/settings/EditNameSetting/EditNameSetting.tsx b/src/components/settings/EditNameSetting/EditNameSetting.tsx index 38a3ce4..a756023 100644 --- a/src/components/settings/EditNameSetting/EditNameSetting.tsx +++ b/src/components/settings/EditNameSetting/EditNameSetting.tsx @@ -63,8 +63,8 @@ const EditNameModal = ({
{error && {error}} { setName(value); diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 5b7239d..b9f2610 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -1,6 +1,7 @@ export * from "./DeleteAccountSetting"; export * from "./EditBirthplaceSetting"; export * from "./EditNameSetting"; +export * from "./EditEmailSetting"; export * from "./EditMinorSetting"; export * from "./EditResidenceSetting"; export * from "./EditThemeSetting"; diff --git a/src/routes/_authenticated/settings/account.tsx b/src/routes/_authenticated/settings/account.tsx index 23c8316..a0d4b44 100644 --- a/src/routes/_authenticated/settings/account.tsx +++ b/src/routes/_authenticated/settings/account.tsx @@ -2,6 +2,7 @@ import { PageHeader } from "@/components/app"; import { DeleteAccountSetting, EditBirthplaceSetting, + EditEmailSetting, EditMinorSetting, EditNameSetting, EditResidenceSetting, @@ -30,6 +31,7 @@ function SettingsAccountRoute() { <> +