From 454b6ed98ad27f7ddbd5f5ef025deeae201b03f7 Mon Sep 17 00:00:00 2001 From: ansen01-svg Date: Sun, 15 Dec 2024 10:43:18 +0530 Subject: [PATCH 1/4] feat: added email display in account settings page --- .changeset/eight-yaks-itch.md | 5 + .gitignore | 2 + convex/_generated/api.d.ts | 4 +- convex/users.ts | 7 ++ .../EditEmailSetting/EditEmailSetting.tsx | 112 ++++++++++++++++++ .../settings/EditEmailSetting/index.tsx | 1 + .../EditNameSetting/EditNameSetting.tsx | 3 +- src/components/settings/index.ts | 1 + .../_authenticated/settings/account.tsx | 2 + 9 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 .changeset/eight-yaks-itch.md create mode 100644 src/components/settings/EditEmailSetting/EditEmailSetting.tsx create mode 100644 src/components/settings/EditEmailSetting/index.tsx 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/.gitignore b/.gitignore index 08a4462..8a12f00 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ node_modules # Local files *.local + +.env.local diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9eb0e7a..0a33f91 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,8 +25,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"; /** @@ -50,8 +50,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/users.ts b/convex/users.ts index 6a1a56b..02bff7a 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -50,6 +50,13 @@ export const setName = userMutation({ }, }); +export const setEmail = userMutation({ + args: { email: v.optional(v.string()) }, + handler: async (ctx, args) => { + await ctx.db.patch(ctx.userId, { email: args.email }); + }, +}); + export const setResidence = userMutation({ args: { residence: jurisdiction }, handler: async (ctx, args) => { diff --git a/src/components/settings/EditEmailSetting/EditEmailSetting.tsx b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx new file mode 100644 index 0000000..9fc4f4f --- /dev/null +++ b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx @@ -0,0 +1,112 @@ +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 { useMutation } from "convex/react"; +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(undefined); + + try { + setIsSubmitting(true); + await updateEmail({ email: email.trim() }); + onSubmit(); + toast.success("Email updated."); + } catch (err) { + setError("Failed to update email. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+ {error && {error}} + { + setEmail(value); + setError(undefined); + }} + 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/index.tsx b/src/components/settings/EditEmailSetting/index.tsx new file mode 100644 index 0000000..80982e8 --- /dev/null +++ b/src/components/settings/EditEmailSetting/index.tsx @@ -0,0 +1 @@ +export * from "./EditEmailSetting"; diff --git a/src/components/settings/EditNameSetting/EditNameSetting.tsx b/src/components/settings/EditNameSetting/EditNameSetting.tsx index 8b1df2d..2a5aeab 100644 --- a/src/components/settings/EditNameSetting/EditNameSetting.tsx +++ b/src/components/settings/EditNameSetting/EditNameSetting.tsx @@ -63,8 +63,9 @@ 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() { <> + From fcbae31837a2446c415925ae8082fe1154bf4071 Mon Sep 17 00:00:00 2001 From: ansen01-svg Date: Fri, 20 Dec 2024 17:10:36 +0530 Subject: [PATCH 2/4] feat: Allow modifying user email from settings --- .gitignore | 4 +- convex/_generated/api.d.ts | 2 + convex/errors.ts | 3 + convex/users.test.ts | 63 +++++++ convex/users.ts | 19 +++ package.json | 3 +- pnpm-lock.yaml | 23 +-- .../EditEmailSetting/EditEmailSetting.tsx | 29 ++-- .../EditEmailSettings.test.tsx | 156 ++++++++++++++++++ .../EditEmailSetting/{index.tsx => index.ts} | 0 .../EditNameSetting/EditNameSetting.tsx | 1 - 11 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 convex/errors.ts create mode 100644 src/components/settings/EditEmailSetting/EditEmailSettings.test.tsx rename src/components/settings/EditEmailSetting/{index.tsx => index.ts} (100%) diff --git a/.gitignore b/.gitignore index 8a12f00..3c0a889 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,4 @@ node_modules .npmrc # Local files -*.local - -.env.local +*.local \ No newline at end of file diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0a33f91..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"; @@ -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; 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 02bff7a..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,9 +53,25 @@ 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 }); }, }); diff --git a/package.json b/package.json index e45be10..d82dc89 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,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 acf7092..00f8492 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,7 +83,7 @@ importers: version: 1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) convex-helpers: specifier: ^0.1.65 - version: 0.1.65(convex@1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(zod@3.23.8) + version: 0.1.65(convex@1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(zod@3.24.1) framer-motion: specifier: 12.0.0-alpha.2 version: 12.0.0-alpha.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -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 @@ -219,7 +222,7 @@ importers: version: 3.7.2(@swc/helpers@0.5.15)(vite@6.0.3(@types/node@22.10.1)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.8(vitest@2.1.8(@edge-runtime/vm@5.0.0)(@types/node@22.10.1)(@vitest/ui@2.1.8)(jsdom@25.0.0(canvas@2.11.2))) + version: 2.1.8(vitest@2.1.8) '@vitest/ui': specifier: ^2.1.8 version: 2.1.8(vitest@2.1.8) @@ -5405,8 +5408,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} snapshots: @@ -7609,7 +7612,7 @@ snapshots: '@tanstack/virtual-file-routes': 1.81.9 prettier: 3.4.2 tsx: 4.19.2 - zod: 3.23.8 + zod: 3.24.1 '@tanstack/router-plugin@1.86.0(vite@6.0.3(@types/node@22.10.1)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.6.1))': dependencies: @@ -7630,7 +7633,7 @@ snapshots: babel-dead-code-elimination: 1.0.6 chokidar: 3.6.0 unplugin: 1.16.0 - zod: 3.23.8 + zod: 3.24.1 optionalDependencies: vite: 6.0.3(@types/node@22.10.1)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.6.1) transitivePeerDependencies: @@ -7879,7 +7882,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@edge-runtime/vm@5.0.0)(@types/node@22.10.1)(@vitest/ui@2.1.8)(jsdom@25.0.0(canvas@2.11.2)))': + '@vitest/coverage-v8@2.1.8(vitest@2.1.8)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -8289,12 +8292,12 @@ snapshots: convert-source-map@2.0.0: {} - convex-helpers@0.1.65(convex@1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(zod@3.23.8): + convex-helpers@0.1.65(convex@1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(zod@3.24.1): dependencies: convex: 1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) optionalDependencies: react: 19.0.0 - zod: 3.23.8 + zod: 3.24.1 convex-test@0.0.34(convex@1.17.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)): dependencies: @@ -11102,4 +11105,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.23.8: {} + zod@3.24.1: {} diff --git a/src/components/settings/EditEmailSetting/EditEmailSetting.tsx b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx index 9fc4f4f..26169d5 100644 --- a/src/components/settings/EditEmailSetting/EditEmailSetting.tsx +++ b/src/components/settings/EditEmailSetting/EditEmailSetting.tsx @@ -9,7 +9,9 @@ import { } from "@/components/common"; import { api } from "@convex/_generated/api"; import type { Doc } from "@convex/_generated/dataModel"; +import { 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"; @@ -30,12 +32,12 @@ const EditEmailModal = ({ }: EditEmailModalProps) => { const updateEmail = useMutation(api.users.setEmail); const [email, setEmail] = useState(defaultEmail); - const [error, setError] = useState(); + const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setError(undefined); + setError(""); try { setIsSubmitting(true); @@ -43,7 +45,15 @@ const EditEmailModal = ({ onSubmit(); toast.success("Email updated."); } catch (err) { - setError("Failed to update email. Please try again."); + if (err instanceof ConvexError) { + if (err.data === INVALID_EMAIL) { + setError("Please enter a valid email address."); + } else { + setError("This email is currently in use. Try another one."); + } + } else { + setError("Failed to update email. Please try again."); + } } finally { setIsSubmitting(false); } @@ -52,8 +62,8 @@ const EditEmailModal = ({ return ( {error && {error}} @@ -62,10 +72,7 @@ const EditEmailModal = ({ name="email" type="email" value={email} - onChange={(value) => { - setEmail(value); - setError(undefined); - }} + onChange={(value) => setEmail(value)} className="w-full" isRequired /> @@ -95,8 +102,8 @@ export const EditEmailSetting = ({ user }: EditEmailSettingProps) => { return (