Skip to content

Commit

Permalink
Feat: reset password (#68)
Browse files Browse the repository at this point in the history
* update dependencies

* add reset password
  • Loading branch information
rphlmr authored Feb 22, 2023
1 parent 693135c commit f7efdc5
Show file tree
Hide file tree
Showing 10 changed files with 395 additions and 28 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,5 +243,8 @@ You need to add the site url as well as the redirect urls of your local, test an
To do that navigate to Authentication > URL configiration and add the folowing values:

- https://localhost:3000/oauth/callback
- https://localhost:3000/reset-password
- https://staging-domain.com/oauth/callback
- https://live-domain.com/oauth/callback
- https://staging-domain.com/reset-password
- https://live-domain.com/oauth/callback
- https://live-domain.com/reset-password
2 changes: 2 additions & 0 deletions app/modules/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export {
signInWithEmail,
sendMagicLink,
refreshAccessToken,
updateAccountPassword,
sendResetPasswordLink,
} from "./service.server";
export {
commitAuthSession,
Expand Down
17 changes: 17 additions & 0 deletions app/modules/auth/service.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export async function sendMagicLink(email: string) {
});
}

export async function sendResetPasswordLink(email: string) {
return getSupabaseAdmin().auth.resetPasswordForEmail(email, {
redirectTo: `${SERVER_URL}/reset-password`,
});
}

export async function updateAccountPassword(id: string, password: string) {
const { data, error } = await getSupabaseAdmin().auth.admin.updateUserById(
id,
{ password }
);

if (!data.user || error) return null;

return data.user;
}

export async function deleteAuthAccount(userId: string) {
const { error } = await getSupabaseAdmin().auth.admin.deleteUser(userId);

Expand Down
120 changes: 120 additions & 0 deletions app/routes/forgot-password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useTransition } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { parseFormAny, useZorm } from "react-zorm";
import { z } from "zod";

import { i18nextServer } from "~/integrations/i18n";
import { getAuthSession, sendResetPasswordLink } from "~/modules/auth";
import { assertIsPost, isFormProcessing, tw } from "~/utils";

export async function loader({ request }: LoaderArgs) {
const authSession = await getAuthSession(request);
const t = await i18nextServer.getFixedT(request, "auth");
const title = t("login.forgotPassword");

if (authSession) return redirect("/notes");

return json({ title });
}

const ForgotPasswordSchema = z.object({
email: z
.string()
.email("invalid-email")
.transform((email) => email.toLowerCase()),
});

export async function action({ request }: ActionArgs) {
assertIsPost(request);

const formData = await request.formData();
const result = await ForgotPasswordSchema.safeParseAsync(
parseFormAny(formData)
);

if (!result.success) {
return json(
{
message: "invalid-request",
},
{ status: 400 }
);
}

const { email } = result.data;

const { error } = await sendResetPasswordLink(email);

if (error) {
return json(
{
message: "unable-to-send-reset-password-link",
},
{ status: 500 }
);
}

return json({ message: null });
}

export default function ForgotPassword() {
const zo = useZorm("ForgotPasswordForm", ForgotPasswordSchema);
const { t } = useTranslation("auth");
const actionData = useActionData<typeof action>();
const transition = useTransition();
const disabled = isFormProcessing(transition.state);

return (
<div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8">
{!actionData ? (
<Form ref={zo.ref} method="post" className="space-y-6" replace>
<div>
<label
htmlFor={zo.fields.email()}
className="block text-sm font-medium text-gray-700"
>
{t("register.email")}
</label>
<div className="mt-1">
<input
data-test-id="email"
name={zo.fields.email()}
type="email"
autoComplete="email"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
disabled={disabled}
/>
{zo.errors.email()?.message && (
<div className="pt-1 text-red-700" id="password-error">
{zo.errors.email()?.message}
</div>
)}
</div>
</div>

<button
data-test-id="send-password-reset-link"
type="submit"
className="w-full rounded bg-blue-500 py-2 px-4 text-white focus:bg-blue-400 hover:bg-blue-600"
disabled={disabled}
>
{t("register.sendLink")}
</button>
</Form>
) : (
<div
className={tw(
`mb-2 h-6 text-center`,
actionData.message ? "text-red-600" : "text-green-600"
)}
>
{actionData.message || t("register.checkEmail")}
</div>
)}
</div>
</div>
);
}
7 changes: 7 additions & 0 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export default function LoginPage() {
>
{t("login.action")}
</button>
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
<Link className="text-blue-500 underline" to="/forgot-password">
{t("login.forgotPassword")}?
</Link>
</div>
</div>
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
{t("login.dontHaveAccount")}{" "}
Expand Down
206 changes: 206 additions & 0 deletions app/routes/reset-password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useEffect, useMemo, useState } from "react";

import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useTransition } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { parseFormAny, useZorm } from "react-zorm";
import { z } from "zod";

import { i18nextServer } from "~/integrations/i18n";
import { getSupabase } from "~/integrations/supabase";
import {
commitAuthSession,
getAuthSession,
refreshAccessToken,
updateAccountPassword,
} from "~/modules/auth";
import { assertIsPost, isFormProcessing, tw } from "~/utils";

export async function loader({ request }: LoaderArgs) {
const authSession = await getAuthSession(request);
const t = await i18nextServer.getFixedT(request, "auth");
const title = t("register.changePassword");

if (authSession) return redirect("/notes");

return json({ title });
}

const ResetPasswordSchema = z
.object({
password: z.string().min(8, "password-too-short"),
confirmPassword: z.string().min(8, "password-too-short"),
refreshToken: z.string(),
})
.superRefine(({ password, confirmPassword, refreshToken }, ctx) => {
if (password !== confirmPassword) {
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password and confirm password must match",
path: ["confirmPassword"],
});
}

return { password, confirmPassword, refreshToken };
});

export async function action({ request }: ActionArgs) {
assertIsPost(request);

const formData = await request.formData();
const result = await ResetPasswordSchema.safeParseAsync(
parseFormAny(formData)
);

if (!result.success) {
return json(
{
message: "invalid-request",
},
{ status: 400 }
);
}

const { password, refreshToken } = result.data;

// We should not trust what is sent from the client
// https://github.com/rphlmr/supa-fly-stack/issues/45
const authSession = await refreshAccessToken(refreshToken);

if (!authSession) {
return json(
{
message: "invalid-refresh-token",
},
{ status: 401 }
);
}

const user = await updateAccountPassword(authSession.userId, password);

if (!user) {
return json(
{
message: "update-password-error",
},
{ status: 500 }
);
}

return redirect("/notes", {
headers: {
"Set-Cookie": await commitAuthSession(request, {
authSession,
}),
},
});
}

export default function ResetPassword() {
const zo = useZorm("ResetPasswordForm", ResetPasswordSchema);
const { t } = useTranslation("auth");
const [userRefreshToken, setUserRefreshToken] = useState("");
const actionData = useActionData<typeof action>();
const transition = useTransition();
const disabled = isFormProcessing(transition.state);
const supabase = useMemo(() => getSupabase(), []);

useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, supabaseSession) => {
if (event === "SIGNED_IN") {
const refreshToken = supabaseSession?.refresh_token;

if (!refreshToken) return;

setUserRefreshToken(refreshToken);
}
});

return () => {
// prevent memory leak. Listener stays alive 👨‍🎤
subscription.unsubscribe();
};
}, [supabase.auth]);

return (
<div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8">
<Form ref={zo.ref} method="post" className="space-y-6" replace>
<div>
<label
htmlFor={zo.fields.password()}
className="block text-sm font-medium text-gray-700"
>
{t("register.password")}
</label>
<div className="mt-1">
<input
data-test-id="password"
name={zo.fields.password()}
type="password"
autoComplete="new-password"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
disabled={disabled}
/>
{zo.errors.password()?.message && (
<div className="pt-1 text-red-700" id="password-error">
{zo.errors.password()?.message}
</div>
)}
</div>
</div>
<div>
<label
htmlFor={zo.fields.confirmPassword()}
className="block text-sm font-medium text-gray-700"
>
{t("register.confirmPassword")}
</label>
<div className="mt-1">
<input
data-test-id="confirmPassword"
name={zo.fields.confirmPassword()}
type="password"
autoComplete="new-password"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
disabled={disabled}
/>
{zo.errors.confirmPassword()?.message && (
<div className="pt-1 text-red-700" id="password-error">
{zo.errors.confirmPassword()?.message}
</div>
)}
</div>
</div>

<input
type="hidden"
name={zo.fields.refreshToken()}
value={userRefreshToken}
/>
<button
data-test-id="change-password"
type="submit"
className="w-full rounded bg-blue-500 py-2 px-4 text-white focus:bg-blue-400 hover:bg-blue-600"
disabled={disabled}
>
{t("register.changePassword")}
</button>
</Form>
{actionData?.message ? (
<div className="flex flex-col items-center">
<div className={tw(`mb-2 h-6 text-center text-red-600`)}>
{actionData.message}
</div>
<Link className="text-blue-500 underline" to="/forgot-password">
Resend link
</Link>
</div>
) : null}
</div>
</div>
);
}
Loading

0 comments on commit f7efdc5

Please sign in to comment.