diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index 8027b09f..4ec40678 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -1,11 +1,25 @@ +import { ChangeEmailAddress } from "@/components/settings/ChangeEmailAddress"; import { ChangePassword } from "@/components/settings/ChangePassword"; import UserDetails from "@/components/settings/UserDetails"; +import { api } from "@/server/api/client"; export default async function InfoPage() { + const whoami = await api.users.whoami(); + return (
- + {whoami.localUser && ( + <> + + + + )} + {!whoami.localUser && ( +
+ Changing Email address and password is not possible for OAuth Users +
+ )}
); } diff --git a/apps/web/components/settings/ChangeEmailAddress.tsx b/apps/web/components/settings/ChangeEmailAddress.tsx new file mode 100644 index 00000000..7dba2b76 --- /dev/null +++ b/apps/web/components/settings/ChangeEmailAddress.tsx @@ -0,0 +1,122 @@ +"use client"; + +import type { z } from "zod"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { signOut } from "next-auth/react"; +import { useForm } from "react-hook-form"; + +import { zChangeEmailAddressSchema } from "@hoarder/shared/types/users"; + +export function ChangeEmailAddress() { + const form = useForm>({ + resolver: zodResolver(zChangeEmailAddressSchema), + defaultValues: { + newEmailAddress: "", + currentPassword: "", + }, + }); + + const mutator = api.users.changeEmailAddress.useMutation({ + onSuccess: () => { + toast({ + description: "Email Address changed successfully. Logging out.", + }); + form.reset(); + signOut(); + }, + onError: (e) => { + if (e.data?.code == "UNAUTHORIZED") { + toast({ + description: "Your current password is incorrect", + variant: "destructive", + }); + } else { + toast({ + description: e.message || "Something went wrong", + variant: "destructive", + }); + } + }, + }); + + async function onSubmit(value: z.infer) { + mutator.mutate({ + newEmailAddress: value.newEmailAddress, + currentPassword: value.currentPassword, + }); + } + + return ( +
+
+ Change Email Address +
+
(reauthentication required after change)
+
+
+ + { + return ( + + New Email Address + + + + + + ); + }} + /> + { + return ( + + Current Password + + + + + + ); + }} + /> + + Update Email address + + + +
+ ); +} diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx index aa27f223..4cbe64f4 100644 --- a/apps/web/components/settings/ChangePassword.tsx +++ b/apps/web/components/settings/ChangePassword.tsx @@ -124,7 +124,7 @@ export function ChangePassword() { type="submit" loading={mutator.isPending} > - Save + Change Password diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 7d97a6d9..86e6af22 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -24,3 +24,8 @@ export const zChangePasswordSchema = z message: "Passwords don't match", path: ["newPasswordConfirm"], }); + +export const zChangeEmailAddressSchema = z.object({ + newEmailAddress: z.string().email(), + currentPassword: z.string().min(8).max(PASSWORD_MAX_LENGTH), +}); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index ca46d9f7..2200ec8b 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, isNotNull, not } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -7,7 +7,10 @@ import { SqliteError } from "@hoarder/db"; import { users } from "@hoarder/db/schema"; import { deleteUserAssets } from "@hoarder/shared/assetdb"; import serverConfig from "@hoarder/shared/config"; -import { zSignUpSchema } from "@hoarder/shared/types/users"; +import { + zChangeEmailAddressSchema, + zSignUpSchema, +} from "@hoarder/shared/types/users"; import { hashPassword, validatePassword } from "../auth"; import { @@ -146,6 +149,53 @@ export const usersAppRouter = router({ }) .where(eq(users.id, ctx.user.id)); }), + changeEmailAddress: authedProcedure + .input(zChangeEmailAddressSchema) + .mutation(async ({ input, ctx }) => { + invariant(ctx.user.email, "A user always has an email specified"); + + // verify that the user is actually a local user by checking if it has a password + const [{ count: usersWithPassword }] = await ctx.db + .select({ count: count() }) + .from(users) + .where(and(eq(users.id, ctx.user.id), isNotNull(users.password))); + if (usersWithPassword < 1) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Updating the email address is not allowed for OAuth users.", + }); + } + // verify that no other user has this email address yet + const [{ count: usersWithEmailAddress }] = await ctx.db + .select({ count: count() }) + .from(users) + .where( + and( + not(eq(users.id, ctx.user.id)), + eq(users.email, input.newEmailAddress), + ), + ); + if (usersWithEmailAddress != 0) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Updating the email address is not allowed because it is already in use.", + }); + } + let user; + try { + user = await validatePassword(ctx.user.email, input.currentPassword); + } catch (e) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + invariant(user.id, ctx.user.id); + await ctx.db + .update(users) + .set({ + email: input.newEmailAddress, + }) + .where(eq(users.id, ctx.user.id)); + }), delete: adminProcedure .input( z.object({ @@ -165,6 +215,7 @@ export const usersAppRouter = router({ id: z.string(), name: z.string().nullish(), email: z.string().nullish(), + localUser: z.boolean(), }), ) .query(async ({ ctx }) => { @@ -177,6 +228,11 @@ export const usersAppRouter = router({ if (!userDb) { throw new TRPCError({ code: "UNAUTHORIZED" }); } - return { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email }; + return { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + localUser: !!userDb.password, + }; }), });