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)
+
+
+
+
+ );
+}
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,
+ };
}),
});