Skip to content

Commit

Permalink
feat: profile view
Browse files Browse the repository at this point in the history
feat: profile view
  • Loading branch information
arrocke authored Sep 25, 2024
2 parents 26247c6 + 53a74e7 commit 892aa93
Show file tree
Hide file tree
Showing 5 changed files with 709 additions and 497 deletions.
87 changes: 87 additions & 0 deletions app/[locale]/(authenticated)/profile/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use server";

import * as z from "zod";
import { Scrypt } from "oslo/password";
import { getLocale, getTranslations } from "next-intl/server";
import { FormState } from "@/app/components/Form";
import { revalidatePath } from "next/cache";
import { parseForm } from "@/app/form-parser";
import mailer from "@/app/mailer";
import { query } from "@/shared/db";

const scrypt = new Scrypt();

const profileValidationSchema = z
.object({
email: z.string().email().min(1),
name: z.string().min(1),
password: z.union([z.string().min(8), z.literal("")]).optional(),
confirm_password: z.string().optional(),
})
.refine((data) => data.password === data.confirm_password, {
path: ["confirm_password"],
});

export default async function updateProfile(
_prevState: FormState,
data: FormData
): Promise<FormState> {
const t = await getTranslations("ProfileView");
const parsedData = parseForm(data) as Record<string, string>;
const request = profileValidationSchema.safeParse(parsedData, {
errorMap: (error) => {
switch (error.path[0]) {
case "email":
if (error.code === "invalid_string") {
return { message: t("errors.email_format") };
} else {
return { message: t("errors.email_required") };
}
case "name":
return { message: t("errors.name_required") };
case "password":
return { message: t("errors.password_format") };
case "confirm_password":
return { message: t("errors.password_confirmation") };
default:
return { message: "Invalid" };
}
},
});
if (!request.success) {
return { state: "error", validation: request.error.flatten().fieldErrors };
}

if (parsedData.password) {
await mailer.sendEmail({
userId: parsedData.user_id,
subject: "Password Changed",
text: `Your password for Global Bible Tools has changed.`,
html: `Your password for Global Bible Tools has changed.`,
});
await query(
`UPDATE "User"
SET "email" = $1,
"name" = $2,
"hashedPassword" = $3
WHERE "id" = $4`,
[
parsedData.email,
parsedData.name,
await scrypt.hash(parsedData.password),
parsedData.user_id,
]
);
} else {
await query(
`UPDATE "User"
SET "email" = $1,
"name" = $2
WHERE "id" = $3`,
[parsedData.email, parsedData.name, parsedData.user_id]
);
}

revalidatePath(`/${await getLocale()}/profile`);
return { state: "success", message: t("profile_updated") };
}
110 changes: 103 additions & 7 deletions app/[locale]/(authenticated)/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,108 @@
import { query } from "@/shared/db"
import { verifySession } from "@/app/session"
import FormLabel from "@/app/components/FormLabel";
import TextInput from "@/app/components/TextInput";
import ViewTitle from "@/app/components/ViewTitle";
import { verifySession } from "@/app/session";
import { ResolvingMetadata, Metadata } from "next";
import { getTranslations } from "next-intl/server";
import FieldError from "@/app/components/FieldError";
import Button from "@/app/components/Button";
import { notFound } from "next/navigation";
import Form from "@/app/components/Form";
import updateProfile from "./actions";
import { query } from "@/shared/db";

export async function generateMetadata(
_: any,
parent: ResolvingMetadata
): Promise<Metadata> {
const t = await getTranslations("ProfileView");
const { title } = await parent;

return {
title: `${t("title")} | ${title?.absolute}`,
};
}

export default async function ProfileView() {
const session = await verifySession()
const result = session ? await query<{ name?: string, email: string }>(`SELECT name, email FROM "User" WHERE id = $1`, [session.user.id]) : undefined
const user = result?.rows[0]
const session = await verifySession();
if (!session) notFound();

const result = await query<{ name?: string; email: string }>(
`SELECT name, email FROM "User" WHERE id = $1`,
[session.user.id]
);
const user = result?.rows[0];

const t = await getTranslations("ProfileView");

return <div>
{user && `${user.name} - ${user.email}`}
return (
<div className="flex items-start justify-center absolute w-full h-full">
<div
className="flex-shrink p-6 mx-4 mt-4 w-96
border border-gray-300 rounded shadow-md
dark:bg-gray-700 dark:border-gray-600 dark:shadow-none"
>
<ViewTitle>{t("title")}</ViewTitle>
<Form action={updateProfile}>
<input hidden name="user_id" value={session.user.id} />
<div className="mb-2">
<FormLabel htmlFor="email">{t("form.email")}</FormLabel>
<TextInput
id="email"
name="email"
type="email"
className="w-full"
autoComplete="email"
aria-describedby="email-error"
defaultValue={user?.email}
/>
<FieldError id="email-error" name="email" />
</div>
<div className="mb-2">
<FormLabel htmlFor="name">{t("form.name")}</FormLabel>
<TextInput
id="name"
name="name"
className="w-full"
autoComplete="name"
aria-describedby="name-error"
defaultValue={user?.name}
/>
<FieldError id="name-error" name="name" />
</div>
<div className="mb-2">
<FormLabel htmlFor="password">{t("form.password")}</FormLabel>
<TextInput
type="password"
id="password"
name="password"
className="w-full"
autoComplete="new-password"
aria-describedby="password-error"
/>
<FieldError id="password-error" name="password" />
</div>
<div className="mb-4">
<FormLabel htmlFor="confirm-password">
{t("form.confirm_password")}
</FormLabel>
<TextInput
type="password"
id="confirm-password"
name="confirm_password"
className="w-full"
autoComplete="new-password"
aria-describedby="confirm-password-error"
/>
<FieldError id="confirm-password-error" name="confirm_password" />
</div>
<div>
<Button type="submit" className="w-full mb-2">
{t("form.submit")}
</Button>
</div>
</Form>
</div>
</div>
);
}
134 changes: 72 additions & 62 deletions app/[locale]/(public)/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,86 +2,96 @@ import ModalView, { ModalViewTitle } from "@/app/components/ModalView";
import Button from "@/app/components/Button";
import FormLabel from "@/app/components/FormLabel";
import TextInput from "@/app/components/TextInput";
import FieldError from '@/app/components/FieldError';
import { verifySession } from '@/app/session';
import { notFound, redirect, RedirectType } from 'next/navigation';
import { getLocale, getTranslations } from 'next-intl/server';
import FieldError from "@/app/components/FieldError";
import { verifySession } from "@/app/session";
import { notFound, redirect, RedirectType } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { Metadata, ResolvingMetadata } from "next";
import { query } from "@/shared/db";
import { resetPassword } from "./actions";
import Form from "@/app/components/Form";

export async function generateMetadata(_: any, parent: ResolvingMetadata): Promise<Metadata> {
const t = await getTranslations("ResetPasswordPage")
const { title } = await parent
export async function generateMetadata(
_: any,
parent: ResolvingMetadata
): Promise<Metadata> {
const t = await getTranslations("ResetPasswordPage");
const { title } = await parent;

return {
title: `${t("title")} | ${title?.absolute}`
}
title: `${t("title")} | ${title?.absolute}`,
};
}

export default async function ResetPasswordPage({ searchParams }: { searchParams: { token?: string } }) {
const t = await getTranslations('ResetPasswordPage');
const locale = await getLocale()
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: { token?: string };
}) {
const t = await getTranslations("ResetPasswordPage");
const locale = await getLocale();

const session = await verifySession();
if (session) {
redirect(`/${locale}/interlinear`, RedirectType.replace)
}
const session = await verifySession();
if (session) {
redirect(`/${locale}/interlinear`, RedirectType.replace);
}

if (!searchParams.token) {
notFound()
}
if (!searchParams.token) {
notFound();
}

const tokenQuery = await query(
`SELECT FROM "ResetPasswordToken" WHERE token = $1
const tokenQuery = await query(
`SELECT FROM "ResetPasswordToken" WHERE token = $1
AND expires > (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint * 1000::bigint)
`,
[searchParams.token]
)
if (tokenQuery.rows.length === 0) {
notFound()
}
[searchParams.token]
);
if (tokenQuery.rows.length === 0) {
notFound();
}

return <ModalView className="max-w-[480px] w-full"
header={
return (
<ModalView
className="max-w-[480px] w-full"
header={
<Button href={`/${locale}/login`} variant="tertiary">
{t("actions.log_in")}
{t("actions.log_in")}
</Button>
}
>
<ModalViewTitle>{t('title')}</ModalViewTitle>
<Form className="max-w-[300px] w-full mx-auto" action={resetPassword}>
<input type="hidden" name="token" value={searchParams.token ?? ''} />
<div className="mb-4">
<FormLabel htmlFor="password">
{t('form.password')}
</FormLabel>
<TextInput
type="password"
id="password"
name="password"
className="w-full"
autoComplete="new-password"
aria-describedby="password-error"
/>
<FieldError id="password-error" name="password" />
</div>
<div className="mb-6">
<FormLabel htmlFor="confirm-password">
{t('form.confirm_password').toUpperCase()}
</FormLabel>
<TextInput
type="password"
id="confirm-password"
name="confirm_password"
className="w-full"
autoComplete="new-password"
aria-describedby="confirm-password-error"
/>
<FieldError id="confirm-password-error" name="confirm_password" />
</div>
<Button type="submit" className="w-full mb-2">{t('form.submit')}</Button>
</Form>
<ModalViewTitle>{t("title")}</ModalViewTitle>
<Form className="max-w-[300px] w-full mx-auto" action={resetPassword}>
<input type="hidden" name="token" value={searchParams.token ?? ""} />
<div className="mb-4">
<FormLabel htmlFor="password">{t("form.password")}</FormLabel>
<TextInput
type="password"
id="password"
name="password"
className="w-full"
autoComplete="new-password"
aria-describedby="password-error"
/>
<FieldError id="password-error" name="password" />
</div>
<div className="mb-6">
<FormLabel htmlFor="confirm-password">
{t("form.confirm_password").toUpperCase()}
</FormLabel>
<TextInput
type="password"
id="confirm-password"
name="confirm_password"
className="w-full"
autoComplete="new-password"
aria-describedby="confirm-password-error"
/>
<FieldError id="confirm-password-error" name="confirm_password" />
</div>
<Button type="submit" className="w-full mb-2">
{t("form.submit")}
</Button>
</Form>
</ModalView>
);
}
Loading

0 comments on commit 892aa93

Please sign in to comment.