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

Need a way to change email address of account #136 #611

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
16 changes: 15 additions & 1 deletion apps/web/app/settings/info/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-md border bg-background p-4">
<UserDetails />
<ChangePassword />
{whoami.localUser && (
<>
<ChangeEmailAddress />
<ChangePassword />
</>
)}
{!whoami.localUser && (
<div className="flex flex-col sm:flex-row">
Changing Email address and password is not possible for OAuth Users
</div>
)}
</div>
);
}
122 changes: 122 additions & 0 deletions apps/web/components/settings/ChangeEmailAddress.tsx
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof zChangeEmailAddressSchema>>({
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<typeof zChangeEmailAddressSchema>) {
mutator.mutate({
newEmailAddress: value.newEmailAddress,
currentPassword: value.currentPassword,
});
}

return (
<div className="flex flex-col sm:flex-row">
<div className="mb-4 w-full text-lg font-medium sm:w-1/3">
Change Email Address
<br />
<div className="text-xs">(reauthentication required after change)</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-2"
>
<FormField
control={form.control}
name="newEmailAddress"
render={({ field }) => {
return (
<FormItem className="flex-1">
<FormLabel>New Email Address</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter new Email Address"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => {
return (
<FormItem className="flex-1">
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Current Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<ActionButton
className="mt-4 h-10 w-max px-8"
type="submit"
loading={mutator.isPending}
>
Update Email address
</ActionButton>
</form>
</Form>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/components/settings/ChangePassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function ChangePassword() {
type="submit"
loading={mutator.isPending}
>
Save
Change Password
</ActionButton>
</form>
</Form>
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/types/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
62 changes: 59 additions & 3 deletions packages/trpc/routers/users.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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";

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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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 }) => {
Expand All @@ -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,
};
}),
});
Loading