Skip to content

Commit

Permalink
feat: Allow modifying user email from settings
Browse files Browse the repository at this point in the history
  • Loading branch information
ansen01-svg committed Dec 20, 2024
1 parent 454b6ed commit fcbae31
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 26 deletions.
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,4 @@ node_modules
.npmrc

# Local files
*.local

.env.local
*.local
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions convex/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const INVALID_PASSWORD = "INVALID_PASSWORD";
export const INVALID_EMAIL = "INVALID_EMAIL";
export const DUPLICATE_EMAIL = "DUPLICATE_EMAIL";
63 changes: 63 additions & 0 deletions convex/users.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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: "[email protected]",
role: "user",
});
});

const asUser = t.withIdentity({ subject: userId });
await asUser.mutation(api.users.setEmail, {
email: "[email protected]",
});

const user = await t.run(async (ctx) => {
return await ctx.db.get(userId);
});
expect(user?.email).toBe("[email protected]");
});

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: "[email protected]",
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: "[email protected]",
role: "user",
});
});

const userId = await t.run(async (ctx) => {
return await ctx.db.insert("users", {
email: "[email protected]",
role: "user",
});
});

const asUser = t.withIdentity({ subject: userId });
await expect(
asUser.mutation(api.users.setEmail, { email: "[email protected]" }),
).rejects.toThrow(DUPLICATE_EMAIL);
});
});

describe("setBirthplace", () => {
it("should update user birthplace", async () => {
const t = convexTest(schema, modules);
Expand Down
19 changes: 19 additions & 0 deletions convex/users.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
},
});
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 13 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 18 additions & 11 deletions src/components/settings/EditEmailSetting/EditEmailSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,20 +32,28 @@ const EditEmailModal = ({
}: EditEmailModalProps) => {
const updateEmail = useMutation(api.users.setEmail);
const [email, setEmail] = useState(defaultEmail);
const [error, setError] = useState<string>();
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(undefined);
setError("");

try {
setIsSubmitting(true);
await updateEmail({ email: email.trim() });
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);
}
Expand All @@ -52,8 +62,8 @@ const EditEmailModal = ({
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalHeader
title="Email address"
description="This is the email we’ll use to contact you."
title="Edit email address"
description="What email would you like to use for Namesake?"
/>
<Form onSubmit={handleSubmit} className="w-full">
{error && <Banner variant="danger">{error}</Banner>}
Expand All @@ -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
/>
Expand Down Expand Up @@ -95,8 +102,8 @@ export const EditEmailSetting = ({ user }: EditEmailSettingProps) => {

return (
<SettingsItem
label="Email address"
description="This is the email we’ll use to contact you."
label="Edit email address"
description="What email would you like to use for Namesake?"
>
<Button icon={Pencil} onPress={() => setIsEmailModalOpen(true)}>
{user?.email ?? "Set email"}
Expand Down
Loading

0 comments on commit fcbae31

Please sign in to comment.