Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow modifying user email from settings #282

Merged
merged 5 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eight-yaks-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Added email display in account settings page.
6 changes: 4 additions & 2 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 @@ -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";

/**
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 All @@ -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;
evadecker marked this conversation as resolved.
Show resolved Hide resolved
validators: typeof validators;
}>;
export declare const api: FilterApi<
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
26 changes: 26 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,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()) },
evadecker marked this conversation as resolved.
Show resolved Hide resolved
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) => {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

121 changes: 121 additions & 0 deletions src/components/settings/EditEmailSetting/EditEmailSetting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
evadecker marked this conversation as resolved.
Show resolved Hide resolved
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<HTMLFormElement>) => {
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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalHeader
title="Email address"
description="Your account email is used for sign-in and communication."
/>
<Form onSubmit={handleSubmit} className="w-full">
{error && <Banner variant="danger">{error}</Banner>}
<TextField
label="Email"
name="email"
type="email"
value={email}
onChange={(value) => setEmail(value)}
className="w-full"
isRequired
/>
<ModalFooter>
<Button
variant="secondary"
isDisabled={isSubmitting}
onPress={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" variant="primary" isDisabled={isSubmitting}>
Save
</Button>
</ModalFooter>
</Form>
</Modal>
);
};

type EditEmailSettingProps = {
user: Doc<"users">;
};

export const EditEmailSetting = ({ user }: EditEmailSettingProps) => {
const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);

return (
<SettingsItem
label="Edit email address"
description="What email would you like to use for Namesake?"
>
<Button icon={Pencil} onPress={() => setIsEmailModalOpen(true)}>
{user?.email ?? "Set email"}
</Button>
<EditEmailModal
isOpen={isEmailModalOpen}
onOpenChange={setIsEmailModalOpen}
defaultEmail={user.email ?? ""}
onSubmit={() => setIsEmailModalOpen(false)}
/>
</SettingsItem>
);
};
Loading
Loading