feat: add account management
This commit is contained in:
parent
0a98a0b365
commit
f09572d8ef
20
app/icons/Check.tsx
Normal file
20
app/icons/Check.tsx
Normal 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;
|
||||||
42
app/root.tsx
42
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 { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix";
|
||||||
|
import { getUser } from "./utils/session.server";
|
||||||
|
|
||||||
import styles from "./tailwind.css";
|
import styles from "./tailwind.css";
|
||||||
import headerStyles from "./styles/header.css";
|
import headerStyles from "./styles/header.css";
|
||||||
|
|
@ -9,22 +16,6 @@ export const links: LinksFunction = () => {
|
||||||
{ rel: "stylesheet", href: styles },
|
{ rel: "stylesheet", href: styles },
|
||||||
{ rel: "stylesheet", href: headerStyles },
|
{ 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 = () => {
|
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({
|
function Document({
|
||||||
children,
|
children,
|
||||||
title = `Explit`,
|
title = `Explit`,
|
||||||
|
|
@ -47,8 +51,10 @@ function Document({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const data = useLoaderData<LoaderData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme={data?.user?.theme ?? "dark"}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { User, Team } from "@prisma/client";
|
import type { User, Team } from "@prisma/client";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { LinksFunction, LoaderFunction } from "remix";
|
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 { getUser } from "~/utils/session.server";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
|
||||||
|
|
@ -25,28 +24,8 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const themes = [
|
export default function AccountRoute() {
|
||||||
"light",
|
|
||||||
"dark",
|
|
||||||
"cupcake",
|
|
||||||
"bumblebee",
|
|
||||||
"emerald",
|
|
||||||
"corporate",
|
|
||||||
"synthwave",
|
|
||||||
"retro",
|
|
||||||
"cyberpunk",
|
|
||||||
"valentine",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExpensesRoute() {
|
|
||||||
const data = useLoaderData<LoaderData>();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -54,122 +33,7 @@ export default function ExpensesRoute() {
|
||||||
<main className="p-2 lg:py-4 lg:px-6">
|
<main className="p-2 lg:py-4 lg:px-6">
|
||||||
<div className="card shadow-lg p-4 lg:p-6">
|
<div className="card shadow-lg p-4 lg:p-6">
|
||||||
<h1 className="mb-2 lg:mb-6 text-2xl">Account</h1>
|
<h1 className="mb-2 lg:mb-6 text-2xl">Account</h1>
|
||||||
<div className="tabs tabs-boxed my-6 mr-auto">
|
<Outlet />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
6
app/routes/account/index.tsx
Normal file
6
app/routes/account/index.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { LoaderFunction } from "remix";
|
||||||
|
import { redirect } from "remix";
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async () => {
|
||||||
|
return redirect("/account/preferences");
|
||||||
|
};
|
||||||
379
app/routes/account/manage.tsx
Normal file
379
app/routes/account/manage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
app/routes/account/preferences.tsx
Normal file
206
app/routes/account/preferences.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import type { ActionFunction, LoaderFunction } from "remix";
|
import type { ActionFunction, LoaderFunction } from "remix";
|
||||||
import {
|
import {
|
||||||
useActionData,
|
useActionData,
|
||||||
|
|
@ -74,6 +73,11 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
const expense = await db.expense.create({
|
const expense = await db.expense.create({
|
||||||
data: { ...fields, userId: userId, teamId: user.teamId },
|
data: { ...fields, userId: userId, teamId: user.teamId },
|
||||||
});
|
});
|
||||||
|
if (!expense) {
|
||||||
|
return badRequest({
|
||||||
|
formError: `Could not create expense.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
return redirect(`/expenses/${expense.id}`);
|
return redirect(`/expenses/${expense.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ type RegisterForm = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateUserForm = {
|
||||||
|
id: string;
|
||||||
|
password?: string;
|
||||||
|
icon?: string;
|
||||||
|
teamId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function register({
|
export async function register({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
|
@ -36,6 +43,31 @@ export async function register({
|
||||||
return user;
|
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) {
|
export async function login({ username, password }: LoginForm) {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue