feat: add account management

This commit is contained in:
Nicola Zambello 2022-02-14 17:05:17 +01:00
parent 0a98a0b365
commit f09572d8ef
8 changed files with 675 additions and 158 deletions

20
app/icons/Check.tsx Normal file
View file

@ -0,0 +1,20 @@
const Check = ({ className }: { className?: string }) => (
<svg
height="24"
version="1.1"
viewBox="0 0 512 512"
width="24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g>
<path d="M340.1,177.3L215.3,303l-47.2-47.2l-17.8,17.8l56,56c2.5,2.5,5.9,4.5,8.9,4.5s6.3-2,8.8-4.4l133.7-134.4L340.1,177.3z" />
<g>
<path d="M256,48C141.1,48,48,141.1,48,256s93.1,208,208,208c114.9,0,208-93.1,208-208S370.9,48,256,48z M256,446.7 c-105.1,0-190.7-85.5-190.7-190.7c0-105.1,85.5-190.7,190.7-190.7c105.1,0,190.7,85.5,190.7,190.7 C446.7,361.1,361.1,446.7,256,446.7z" />
</g>
</g>
</svg>
);
export default Check;

View file

@ -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<LoaderData>();
return (
<html lang="en" data-theme="dark">
<html lang="en" data-theme={data?.user?.theme ?? "dark"}>
<head>
<meta charSet="utf-8" />
<meta

View file

@ -1,7 +1,6 @@
import type { User, Team } from "@prisma/client";
import { useEffect, useState } from "react";
import type { LinksFunction, LoaderFunction } from "remix";
import { useLoaderData, Form, redirect, useCatch } from "remix";
import { useLoaderData, redirect, useCatch, Outlet } from "remix";
import { getUser } from "~/utils/session.server";
import Header from "../components/Header";
@ -25,28 +24,8 @@ export const loader: LoaderFunction = async ({ request }) => {
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<LoaderData>();
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() {
<main className="p-2 lg:py-4 lg:px-6">
<div className="card shadow-lg p-4 lg:p-6">
<h1 className="mb-2 lg:mb-6 text-2xl">Account</h1>
<div className="tabs tabs-boxed my-6 mr-auto">
<button
className={`tab lg:tab-lg${
activeTab === "preferences" ? " tab-active" : ""
}`}
onClick={() => setActiveTab("preferences")}
>
Preferences
</button>
<button
className={`tab lg:tab-lg${
activeTab === "manage" ? " tab-active" : ""
}`}
onClick={() => setActiveTab("manage")}
>
Manage account
</button>
</div>
<Form autoComplete="off">
<fieldset
id="preferences"
className={activeTab === "preferences" ? "" : "hidden"}
>
<div className="p-6 card bordered">
<h3 id="theme" className="mb-4">
Theme
</h3>
{themes.map((theme) => (
<div className="form-control" key={theme}>
<label className="cursor-pointer label">
<span className="label-text">{theme}</span>
<input
type="radio"
name="theme"
checked={activeTheme === theme}
onChange={(e) => {
if (e.target.checked) setActiveTheme(theme);
}}
className="radio"
aria-labelledby="#theme"
value={theme}
/>
</label>
</div>
))}
</div>
</fieldset>
<fieldset
id="manage"
className={activeTab === "manage" ? "" : "hidden"}
>
<div className="p-6 card bordered">
<div className="form-control mb-4">
<label className="label" htmlFor="username">
<span className="label-text">Username: </span>
</label>
<input
type="text"
id="username"
className="input input-bordered"
readOnly
disabled
value={data.user?.username}
/>
</div>
<div className="form-control mb-4">
<label className="label" htmlFor="icon">
<span className="label-text">Icon: </span>
</label>
<input
type="text"
id="icon"
className="input"
defaultValue={data.user?.icon}
/>
</div>
<div className="form-control mb-4">
<label className="label" htmlFor="team">
<span className="label-text">Team: </span>
</label>
<input
type="text"
id="team"
className="input"
value={data.user?.team.id}
disabled
readOnly
/>
</div>
<div className="form-control mb-4">
<label className="label" htmlFor="password">
<span className="label-text">Change password: </span>
</label>
<input type="password" id="password" className="input" />
</div>
<div className="form-control">
<label className="label" htmlFor="confirmPassword">
<span className="label-text">Confirm password: </span>
</label>
<input
type="password"
id="confirmPassword"
className="input"
/>
</div>
</div>
</fieldset>
<div className="flex justify-center align-center mt-6">
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</Form>
<Outlet />
</div>
</main>
</>

View file

@ -0,0 +1,6 @@
import type { LoaderFunction } from "remix";
import { redirect } from "remix";
export const loader: LoaderFunction = async () => {
return redirect("/account/preferences");
};

View file

@ -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<LoaderData>();
const actionData = useActionData<ActionData>();
return (
<>
<div className="tabs tabs-boxed my-6 mr-auto">
<Link to="/account/preferences" className="tab lg:tab-lg">
Preferences
</Link>
<Link to="/account/manage" className="tab lg:tab-lg tab-active">
Manage
</Link>
</div>
<Form autoComplete="off" method="post">
<div className="form-control mb-4">
<label className="label" htmlFor="username">
<span className="label-text">Username: </span>
</label>
<input
type="text"
id="username"
className="input input-bordered"
readOnly
disabled
value={data.user?.username}
/>
</div>
<div className="form-control mb-3">
<label htmlFor="icon-input" className="label">
<span className="label-text">Icon</span>
</label>
<input
type="text"
id="icon-input"
name="icon"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.icon) ? " input-warning" : ""
}`}
defaultValue={
actionData?.fields?.icon ||
data.user?.icon ||
data.user?.username[0]
}
autoComplete="off"
aria-invalid={Boolean(actionData?.fieldErrors?.icon)}
aria-describedby={
actionData?.fieldErrors?.icon ? "icon-error" : undefined
}
/>
<label className="label">
<span className="label-text-alt">
Icon defaults to inital letter of username, can be a single
character or emoji
</span>
</label>
{actionData?.fieldErrors?.icon && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="icon-error">{actionData?.fieldErrors.icon}</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="password-input" className="label">
<span className="label-text">Password</span>
</label>
<input
id="password-input"
name="password"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.password) ? " input-error" : ""
}`}
defaultValue={actionData?.fields?.password}
autoComplete="new-password"
type="password"
aria-invalid={
Boolean(actionData?.fieldErrors?.password) || undefined
}
aria-describedby={
actionData?.fieldErrors?.password ? "password-error" : undefined
}
/>
{actionData?.fieldErrors?.password && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="password-error">
{actionData?.fieldErrors.password}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="confirmPassword-input" className="label">
<span className="label-text">Confirm password</span>
</label>
<input
id="confirmPassword-input"
name="confirmPassword"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.confirmPassword)
? " input-error"
: ""
}`}
defaultValue={actionData?.fields?.confirmPassword}
type="password"
autoComplete="new-password"
aria-invalid={
Boolean(actionData?.fieldErrors?.confirmPassword) || undefined
}
aria-describedby={
actionData?.fieldErrors?.confirmPassword
? "confirmPassword-error"
: undefined
}
/>
{actionData?.fieldErrors?.confirmPassword && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="confirmPassword-error">
{actionData?.fieldErrors.confirmPassword}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="teamId-input" className="label">
<span className="label-text">Team</span>
</label>
<input
type="text"
id="teamId-input"
name="teamId"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.teamId) ? " input-error" : ""
}`}
defaultValue={actionData?.fields?.teamId}
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
aria-describedby={
actionData?.fieldErrors?.teamId ? "teamid-error" : undefined
}
/>
{actionData?.fieldErrors?.teamId && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="teamid-error">
{actionData?.fieldErrors.teamId}
</label>
</div>
</div>
)}
{actionData?.success && (
<div className="alert alert-success mt-2" role="alert">
<div className="flex-1">
<Check className="w-6 h-6 mx-2 stroke-current" />
<label id="teamid-error">{actionData?.success}</label>
</div>
</div>
)}
</div>
<div className="flex justify-center align-center mt-6">
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</Form>
</>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return (
<div className="error-container">
<p>You must be logged in to set your preferences.</p>
<Link to="/login">Login</Link>
</div>
);
}
}
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}

View file

@ -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<LoaderData>();
const actionData = useActionData<ActionData>();
const activeTheme = data.user?.theme || "dark";
return (
<>
<div className="tabs tabs-boxed my-6 mr-auto">
<Link to="/account/preferences" className="tab lg:tab-lg tab-active">
Preferences
</Link>
<Link to="/account/manage" className="tab lg:tab-lg">
Manage
</Link>
</div>
<Form autoComplete="off" method="post">
<div className="p-6 card bordered">
<h3 id="theme" className="mb-4">
Theme
</h3>
{themes.map((theme) => (
<div className="form-control" key={theme}>
<label className="cursor-pointer label">
<span className="label-text">{theme}</span>
<input
type="radio"
name="theme"
defaultChecked={activeTheme === theme}
onChange={(e) => {
if (e.target.checked)
document
?.querySelector("html")
?.setAttribute("data-theme", theme);
}}
className="radio"
aria-labelledby="#theme"
value={theme}
/>
</label>
</div>
))}
{actionData?.fieldErrors?.theme && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="password-error">
{actionData?.fieldErrors.theme}
</label>
</div>
</div>
)}
{actionData?.formSuccess && (
<div className="alert alert-success mt-2" role="alert">
<div className="flex-1">
<Check className="w-6 h-6 mx-2 stroke-current" />
<label id="teamid-error">{actionData?.formSuccess}</label>
</div>
</div>
)}
</div>
<div className="flex justify-center align-center mt-6">
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</Form>
</>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 401) {
return (
<div className="error-container">
<p>You must be logged in to set your preferences.</p>
<Link to="/login">Login</Link>
</div>
);
}
}
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}

View file

@ -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}`);
};

View file

@ -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 },