diff --git a/app/icons/Check.tsx b/app/icons/Check.tsx new file mode 100644 index 0000000..8011119 --- /dev/null +++ b/app/icons/Check.tsx @@ -0,0 +1,20 @@ +const Check = ({ className }: { className?: string }) => ( + + + + + + + + +); + +export default Check; diff --git a/app/root.tsx b/app/root.tsx index f1b7f37..516f5d8 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,12 @@ -import type { LinksFunction, MetaFunction } from "remix"; +import { + LinksFunction, + LoaderFunction, + MetaFunction, + useLoaderData, +} from "remix"; +import type { User, Team } from "@prisma/client"; import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix"; +import { getUser } from "./utils/session.server"; import styles from "./tailwind.css"; import headerStyles from "./styles/header.css"; @@ -9,22 +16,6 @@ export const links: LinksFunction = () => { { rel: "stylesheet", href: styles }, { rel: "stylesheet", href: headerStyles }, ]; - // return [ - // { - // rel: "stylesheet", - // href: globalStylesUrl, - // }, - // { - // rel: "stylesheet", - // href: globalMediumStylesUrl, - // media: "print, (min-width: 640px)", - // }, - // { - // rel: "stylesheet", - // href: globalLargeStylesUrl, - // media: "screen and (min-width: 1024px)", - // }, - // ]; }; export const meta: MetaFunction = () => { @@ -40,6 +31,19 @@ export const meta: MetaFunction = () => { }; }; +type LoaderData = { + user: (User & { team: Team & { members: User[] } }) | null; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const user = await getUser(request); + + const data: LoaderData = { + user, + }; + return data; +}; + function Document({ children, title = `Explit`, @@ -47,8 +51,10 @@ function Document({ children: React.ReactNode; title?: string; }) { + const data = useLoaderData(); + return ( - + { return data; }; -const themes = [ - "light", - "dark", - "cupcake", - "bumblebee", - "emerald", - "corporate", - "synthwave", - "retro", - "cyberpunk", - "valentine", -]; - -export default function ExpensesRoute() { +export default function AccountRoute() { const data = useLoaderData(); - const [activeTab, setActiveTab] = useState<"preferences" | "manage">( - "preferences" - ); - const [activeTheme, setActiveTheme] = useState(data.user?.theme || "dark"); - useEffect(() => { - document?.querySelector("html")?.setAttribute("data-theme", activeTheme); - }, [activeTheme]); return ( <> @@ -54,122 +33,7 @@ export default function ExpensesRoute() { Account - - setActiveTab("preferences")} - > - Preferences - - setActiveTab("manage")} - > - Manage account - - - - - - - - Theme - - {themes.map((theme) => ( - - - {theme} - { - if (e.target.checked) setActiveTheme(theme); - }} - className="radio" - aria-labelledby="#theme" - value={theme} - /> - - - ))} - - - - - - - - Username: - - - - - - Icon: - - - - - - Team: - - - - - - Change password: - - - - - - Confirm password: - - - - - - - - - Save - - - + > diff --git a/app/routes/account/index.tsx b/app/routes/account/index.tsx new file mode 100644 index 0000000..a909160 --- /dev/null +++ b/app/routes/account/index.tsx @@ -0,0 +1,6 @@ +import type { LoaderFunction } from "remix"; +import { redirect } from "remix"; + +export const loader: LoaderFunction = async () => { + return redirect("/account/preferences"); +}; diff --git a/app/routes/account/manage.tsx b/app/routes/account/manage.tsx new file mode 100644 index 0000000..91f3f0d --- /dev/null +++ b/app/routes/account/manage.tsx @@ -0,0 +1,379 @@ +import type { User, Team } from "@prisma/client"; +import type { LinksFunction, LoaderFunction, ActionFunction } from "remix"; +import { + Link, + useLoaderData, + useActionData, + Form, + redirect, + useCatch, + json, +} from "remix"; +import { getUser, updateUser } from "~/utils/session.server"; +import { db } from "~/utils/db.server"; +import Check from "~/icons/Check"; + +export const links: LinksFunction = () => { + return []; +}; + +type LoaderData = { + user: (User & { team: Team & { members: User[] } }) | null; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const user = await getUser(request); + if (!user?.id) { + return redirect("/login"); + } + + const data: LoaderData = { + user, + }; + return data; +}; + +const badRequest = (data: ActionData) => json(data, { status: 400 }); + +const success = (data: ActionData) => json(data, { status: 200 }); + +function validatePassword(password: unknown) { + if (typeof password === "string" && password.length < 6) { + return `Passwords must be at least 6 characters long`; + } +} + +function validateConfirmPassword(confirmPassword: unknown, password: string) { + if (typeof confirmPassword === "string" && confirmPassword !== password) { + return `Passwords must match`; + } +} + +function validateIcon(icon: unknown) { + if (typeof icon === "string" && icon.length > 2) { + return `Icons must be a single character, e.g. "A" or "😎"`; + } +} + +function validateTeamId(teamId: unknown) { + if (typeof teamId === "string" && teamId.length < 1) { + return "You must indicate an arbitrary team ID"; + } +} + +type ActionData = { + formError?: string; + success?: string; + fieldErrors?: { + icon: string | undefined; + teamId: string | undefined; + password: string | undefined; + confirmPassword: string | undefined; + }; + fields?: { + password?: string; + confirmPassword?: string; + teamId?: string; + icon?: string; + }; +}; + +export const action: ActionFunction = async ({ request }) => { + // @ts-ignore + const user = await getUser(request); + if (!user) + return badRequest({ + formError: "You must be logged in to change your settings", + }); + + const form = await request.formData(); + const password = form.get("password"); + const confirmPassword = form.get("confirmPassword"); + const icon = form.get("icon") ?? (user ? user.username[0] : undefined); + const teamId = form.get("teamId"); + + const fields = { + icon: typeof icon === "string" ? icon : undefined, + password: typeof password === "string" ? password : undefined, + confirmPassword: + typeof confirmPassword === "string" ? confirmPassword : undefined, + teamId: typeof teamId === "string" ? teamId : undefined, + }; + const fieldErrors = { + password: validatePassword(password), + confirmPassword: validateConfirmPassword( + confirmPassword, + typeof password === "string" ? password : "" + ), + icon: validateIcon(icon), + teamId: validateTeamId(teamId), + }; + if (Object.values(fieldErrors).some(Boolean)) + return badRequest({ fieldErrors, fields }); + + const nonEmptyFields = Object.entries(fields).reduce((acc, [key, value]) => { + if (typeof value === "string" && key !== "confirmPassword") + return { ...acc, [key]: value ?? undefined }; + else return acc; + }, {}); + const userUpdated = await updateUser({ + id: user.id, + ...nonEmptyFields, + }); + if (!userUpdated) { + return badRequest({ + fields, + formError: `Something went wrong trying to update user.`, + }); + } + return success({ + success: "Your settings have been updated.", + }); +}; + +export default function AccountPreferencesRoute() { + const data = useLoaderData(); + const actionData = useActionData(); + + return ( + <> + + + Preferences + + + Manage + + + + + + + Username: + + + + + + Icon + + + + + Icon defaults to inital letter of username, can be a single + character or emoji + + + {actionData?.fieldErrors?.icon && ( + + + + + + {actionData?.fieldErrors.icon} + + + )} + + + + Password + + + {actionData?.fieldErrors?.password && ( + + + + + + + {actionData?.fieldErrors.password} + + + + )} + + + + Confirm password + + + {actionData?.fieldErrors?.confirmPassword && ( + + + + + + + {actionData?.fieldErrors.confirmPassword} + + + + )} + + + + Team + + + {actionData?.fieldErrors?.teamId && ( + + + + + + + {actionData?.fieldErrors.teamId} + + + + )} + {actionData?.success && ( + + + + {actionData?.success} + + + )} + + + + Save + + + + > + ); +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 401) { + return ( + + You must be logged in to set your preferences. + Login + + ); + } +} + +export function ErrorBoundary() { + return ( + + Something unexpected went wrong. Sorry about that. + + ); +} diff --git a/app/routes/account/preferences.tsx b/app/routes/account/preferences.tsx new file mode 100644 index 0000000..2b56c08 --- /dev/null +++ b/app/routes/account/preferences.tsx @@ -0,0 +1,206 @@ +import type { User, Team } from "@prisma/client"; +import type { LinksFunction, LoaderFunction, ActionFunction } from "remix"; +import { + Link, + useLoaderData, + useActionData, + Form, + redirect, + useCatch, + json, +} from "remix"; +import { getUser, requireUserId } from "~/utils/session.server"; +import { db } from "~/utils/db.server"; +import Check from "~/icons/Check"; + +export const links: LinksFunction = () => { + return []; +}; + +type LoaderData = { + user: (User & { team: Team & { members: User[] } }) | null; +}; + +type ActionData = { + formError?: string; + formSuccess?: string; + fieldErrors?: { + theme: string | undefined; + }; + fields?: { + theme: string; + }; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const user = await getUser(request); + if (!user?.id) { + return redirect("/login"); + } + + const data: LoaderData = { + user, + }; + return data; +}; + +const themes = [ + "light", + "dark", + "cupcake", + "bumblebee", + "emerald", + "corporate", + "synthwave", + "retro", + "cyberpunk", + "valentine", +]; + +const badRequest = (data: ActionData) => json(data, { status: 400 }); +const success = (data: ActionData) => json(data, { status: 200 }); + +const validateTheme = (theme: unknown) => { + if (typeof theme !== "string" || !themes.includes(theme)) { + return `That theme is not valid`; + } +}; + +export const action: ActionFunction = async ({ request }) => { + const userId = await requireUserId(request); + const user = await getUser(request); + const form = await request.formData(); + const theme = form.get("theme"); + if (typeof theme !== "string" || user === null) { + return badRequest({ + formError: `Form not submitted correctly.`, + }); + } + + const fieldErrors = { + theme: validateTheme(theme), + }; + const fields = { theme }; + if (Object.values(fieldErrors).some(Boolean)) { + return badRequest({ fieldErrors, fields }); + } + + const updatedUser = await db.user.update({ + where: { + id: userId, + }, + data: { + theme, + }, + }); + if (!updatedUser) { + return badRequest({ + formError: `Something went wrong trying to update user.`, + }); + } + return success({ formSuccess: "Preferences updated successfully." }); +}; + +export default function AccountPreferencesRoute() { + const data = useLoaderData(); + const actionData = useActionData(); + const activeTheme = data.user?.theme || "dark"; + + return ( + <> + + + Preferences + + + Manage + + + + + + + Theme + + {themes.map((theme) => ( + + + {theme} + { + if (e.target.checked) + document + ?.querySelector("html") + ?.setAttribute("data-theme", theme); + }} + className="radio" + aria-labelledby="#theme" + value={theme} + /> + + + ))} + {actionData?.fieldErrors?.theme && ( + + + + + + + {actionData?.fieldErrors.theme} + + + + )} + {actionData?.formSuccess && ( + + + + {actionData?.formSuccess} + + + )} + + + + Save + + + + > + ); +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 401) { + return ( + + You must be logged in to set your preferences. + Login + + ); + } +} + +export function ErrorBoundary() { + return ( + + Something unexpected went wrong. Sorry about that. + + ); +} diff --git a/app/routes/expenses/new.tsx b/app/routes/expenses/new.tsx index d365bd9..19e847a 100644 --- a/app/routes/expenses/new.tsx +++ b/app/routes/expenses/new.tsx @@ -1,4 +1,3 @@ -import { User } from "@prisma/client"; import type { ActionFunction, LoaderFunction } from "remix"; import { useActionData, @@ -74,6 +73,11 @@ export const action: ActionFunction = async ({ request }) => { const expense = await db.expense.create({ data: { ...fields, userId: userId, teamId: user.teamId }, }); + if (!expense) { + return badRequest({ + formError: `Could not create expense.`, + }); + } return redirect(`/expenses/${expense.id}`); }; diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index 37b4fea..5ad5ae4 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -14,6 +14,13 @@ type RegisterForm = { teamId: string; }; +type UpdateUserForm = { + id: string; + password?: string; + icon?: string; + teamId?: string; +}; + export async function register({ username, password, @@ -36,6 +43,31 @@ export async function register({ return user; } +export async function updateUser({ id, ...data }: UpdateUserForm) { + if (data.teamId) { + const team = await db.team.findUnique({ where: { id: data.teamId } }); + if (!team) { + await db.team.create({ + data: { + id: data.teamId, + icon: data.teamId[0], + }, + }); + } + } + + const passwordHash = await bcrypt.hash(data.password ?? "", 10); + const user = await db.user.update({ + data: { + ...(data?.icon ? { icon: data.icon } : {}), + ...(data?.password ? { passwordHash } : {}), + ...(data?.teamId ? { teamId: data.teamId } : {}), + }, + where: { id }, + }); + return user; +} + export async function login({ username, password }: LoginForm) { const user = await db.user.findUnique({ where: { username },
You must be logged in to set your preferences.