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 { 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
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 {
|
||||
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}`);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue