feat: signin

This commit is contained in:
Nicola Zambello 2022-02-14 15:47:07 +01:00
parent 276451f3bf
commit 0a98a0b365
3 changed files with 338 additions and 189 deletions

View file

@ -85,7 +85,7 @@ const Header = ({ user, route }: Props) => {
{user ? ( {user ? (
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div tabIndex={0} className="btn btn-ghost rounded-full p-0"> <div tabIndex={0} className="btn btn-ghost rounded-full p-0">
<div className="rounded-full w-10 h-10 m-1 inline-flex justify-center items-center bg-white text-3xl"> <div className="rounded-full w-10 h-10 m-1 inline-flex justify-center items-center bg-primary-content text-primary text-3xl">
{user.icon ?? user.username[0]} {user.icon ?? user.username[0]}
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
import type { ActionFunction, LinksFunction, MetaFunction } from "remix"; import type { ActionFunction, LinksFunction, MetaFunction } from "remix";
import { useActionData, json, Link, useSearchParams, Form } from "remix"; import { useActionData, json, Link, useSearchParams, Form } from "remix";
import { login, createUserSession, register } from "~/utils/session.server"; import { login, createUserSession } from "~/utils/session.server";
import Header from "../components/Header"; import Header from "../components/Header";
export const links: LinksFunction = () => { export const links: LinksFunction = () => {
@ -104,7 +104,7 @@ export default function Login() {
type="text" type="text"
id="username-input" id="username-input"
name="username" name="username"
className="input" className="input input-bordered"
defaultValue={actionData?.fields?.username} defaultValue={actionData?.fields?.username}
aria-invalid={Boolean(actionData?.fieldErrors?.username)} aria-invalid={Boolean(actionData?.fieldErrors?.username)}
aria-describedby={ aria-describedby={
@ -143,7 +143,7 @@ export default function Login() {
<input <input
id="password-input" id="password-input"
name="password" name="password"
className="input" className="input input-bordered"
defaultValue={actionData?.fields?.password} defaultValue={actionData?.fields?.password}
type="password" type="password"
aria-invalid={ aria-invalid={

View file

@ -1,7 +1,8 @@
import type { ActionFunction, LinksFunction, MetaFunction } from "remix"; import type { ActionFunction, LinksFunction, MetaFunction } from "remix";
import { useActionData, json, Link, useSearchParams, Form } from "remix"; import { useActionData, json, Link, useSearchParams, Form } from "remix";
import { db } from "~/utils/db.server"; import { db } from "~/utils/db.server";
import { login, createUserSession, register } from "~/utils/session.server"; import { createUserSession, register } from "~/utils/session.server";
import Header from "../components/Header";
export const links: LinksFunction = () => { export const links: LinksFunction = () => {
return []; return [];
@ -9,8 +10,8 @@ export const links: LinksFunction = () => {
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return { return {
title: "Explit | Login", title: "Explit | Sign in",
description: "Login to track and split your expenses!", description: "Sign in to track and split your expenses!",
}; };
}; };
@ -26,6 +27,18 @@ function validatePassword(password: unknown) {
} }
} }
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) { function validateTeamId(teamId: unknown) {
if (typeof teamId !== "string" || teamId.length < 1) { if (typeof teamId !== "string" || teamId.length < 1) {
return "You must indicate an arbitrary team ID"; return "You must indicate an arbitrary team ID";
@ -37,11 +50,14 @@ type ActionData = {
fieldErrors?: { fieldErrors?: {
username: string | undefined; username: string | undefined;
password: string | undefined; password: string | undefined;
teamId: string | undefined;
confirmPassword: string | undefined;
icon: string | undefined;
}; };
fields?: { fields?: {
loginType: string;
username: string; username: string;
password: string; password: string;
confirmPassword: string;
teamId?: string; teamId?: string;
icon?: string; icon?: string;
}; };
@ -51,73 +67,55 @@ const badRequest = (data: ActionData) => json(data, { status: 400 });
export const action: ActionFunction = async ({ request }) => { export const action: ActionFunction = async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const loginType = form.get("loginType");
const username = form.get("username"); const username = form.get("username");
const password = form.get("password"); const password = form.get("password");
const icon = form.get("icon"); const confirmPassword = form.get("confirmPassword");
const icon =
form.get("icon") ??
(typeof username === "string" ? username[0] : undefined);
const teamId = form.get("teamId"); const teamId = form.get("teamId");
const redirectTo = form.get("redirectTo") || "/expenses"; const redirectTo = form.get("redirectTo") || "/expenses";
if ( if (
typeof loginType !== "string" ||
typeof username !== "string" || typeof username !== "string" ||
typeof password !== "string" || typeof password !== "string" ||
(loginType === "register" && typeof confirmPassword !== "string" ||
(typeof icon !== "string" || typeof teamId !== "string" ||
typeof teamId !== "string" || typeof redirectTo !== "string"
typeof redirectTo !== "string"))
) { ) {
return badRequest({ return badRequest({
formError: `Form not submitted correctly.`, formError: `Form not submitted correctly.`,
}); });
} }
const fields = { loginType, username, password, teamId }; const fields = { username, password, confirmPassword, teamId };
const fieldErrors = { const fieldErrors = {
username: validateUsername(username), username: validateUsername(username),
password: validatePassword(password), password: validatePassword(password),
teamId: loginType === "register" && validateTeamId(teamId), confirmPassword: validateConfirmPassword(confirmPassword, password),
icon: validateIcon(icon),
teamId: validateTeamId(teamId),
}; };
if (Object.values(fieldErrors).some(Boolean)) if (Object.values(fieldErrors).some(Boolean))
return badRequest({ fieldErrors, fields }); return badRequest({ fieldErrors, fields });
switch (loginType) { const userExists = await db.user.findFirst({
case "login": { where: { username },
const user = await login({ username, password }); });
if (!user) { if (userExists) {
return badRequest({ console.error(userExists);
fields, return badRequest({
formError: `Username/Password combination is incorrect`, fields,
}); formError: `User with username ${username} already exists`,
} });
return createUserSession(user.id, redirectTo);
}
case "register": {
const userExists = await db.user.findFirst({
where: { username },
});
if (userExists) {
console.error(userExists);
return badRequest({
fields,
formError: `User with username ${username} already exists`,
});
}
const user = await register({ username, password, icon, teamId });
if (!user) {
return badRequest({
fields,
formError: `Something went wrong trying to create a new user.`,
});
}
return createUserSession(user.id, redirectTo);
}
default: {
return badRequest({
fields,
formError: `Login type invalid`,
});
}
} }
const user = await register({ username, password, icon, teamId });
if (!user) {
return badRequest({
fields,
formError: `Something went wrong trying to create a new user.`,
});
}
return createUserSession(user.id, redirectTo);
}; };
export default function Login() { export default function Login() {
@ -125,143 +123,294 @@ export default function Login() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
return ( return (
<div className="container"> <>
<div className="content" data-light=""> <Header />
<h1>Login</h1> <div className="container mx-auto min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Form <div className="card bg-base-200 w-full shadow-lg max-w-lg">
method="post" <div className="card-body w-full">
aria-describedby={ <h1 className="card-title">Sign In</h1>
actionData?.formError ? "form-error-message" : undefined <Form
} method="post"
> className="mt-5"
<input aria-describedby={
type="hidden" actionData?.formError ? "form-error-message" : undefined
name="redirectTo" }
value={searchParams.get("redirectTo") ?? undefined} >
/>
<fieldset>
<legend className="sr-only">Login or Register?</legend>
<label>
<input <input
type="radio" type="hidden"
name="loginType" name="redirectTo"
value="login" value={searchParams.get("redirectTo") ?? undefined}
defaultChecked={ />
!actionData?.fields?.loginType || <div className="form-control mb-3">
actionData?.fields?.loginType === "login" <label htmlFor="username-input" className="label">
} <span className="label-text">Username</span>
/>{" "} </label>
Login <input
</label> type="text"
<label> id="username-input"
<input name="username"
type="radio" className={`input input-bordered${
name="loginType" Boolean(actionData?.fieldErrors?.username)
value="register" ? " input-error"
defaultChecked={actionData?.fields?.loginType === "register"} : ""
/>{" "} }`}
Register defaultValue={actionData?.fields?.username}
</label> aria-invalid={Boolean(actionData?.fieldErrors?.username)}
</fieldset> aria-describedby={
<div> actionData?.fieldErrors?.username
<label htmlFor="username-input">Username</label> ? "username-error"
<input : undefined
type="text" }
id="username-input" />
name="username" {actionData?.fieldErrors?.username && (
defaultValue={actionData?.fields?.username} <div className="alert alert-error mt-2" role="alert">
aria-invalid={Boolean(actionData?.fieldErrors?.username)} <div className="flex-1">
aria-describedby={ <svg
actionData?.fieldErrors?.username ? "username-error" : undefined xmlns="http://www.w3.org/2000/svg"
} fill="none"
/> viewBox="0 0 24 24"
{actionData?.fieldErrors?.username ? ( className="w-6 h-6 mx-2 stroke-current"
<p >
className="form-validation-error" <path
role="alert" strokeLinecap="round"
id="username-error" strokeLinejoin="round"
> strokeWidth="2"
{actionData?.fieldErrors.username} 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"
</p> ></path>
) : null} </svg>
<label id="username-error">
{actionData?.fieldErrors.username}
</label>
</div>
</div>
)}
</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}
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}
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"
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>
)}
</div>
{actionData?.formError && (
<div className="alert alert-error mt-5" 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="form-error-message">
{actionData?.formError}
</label>
</div>
</div>
)}
<div className="text-center max-w-xs mx-auto mt-10">
<button type="submit" className="btn btn-primary btn-block">
Sign in
</button>
</div>
</Form>
</div> </div>
<div> </div>
<label htmlFor="username-input">Team ID</label>
<input
type="text"
id="team-id-input"
name="teamId"
defaultValue={actionData?.fields?.teamId}
aria-hidden={actionData?.fields?.loginType === "login"}
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
aria-describedby={
actionData?.fieldErrors?.teamId ? "teamId-error" : undefined
}
/>
{actionData?.fieldErrors?.teamId ? (
<p
className="form-validation-error"
role="alert"
id="teamId-error"
>
{actionData?.fieldErrors.teamId}
</p>
) : null}
</div>
<div>
<label htmlFor="username-input">Icon (letter or emoji)</label>
<input
type="text"
maxLength={1}
aria-hidden={actionData?.fields?.loginType === "login"}
id="icon-input"
name="icon"
defaultValue={actionData?.fields?.icon}
/>
</div>
<div>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
name="password"
defaultValue={actionData?.fields?.password}
type="password"
aria-invalid={
Boolean(actionData?.fieldErrors?.password) || undefined
}
aria-describedby={
actionData?.fieldErrors?.password ? "password-error" : undefined
}
/>
{actionData?.fieldErrors?.password ? (
<p
className="form-validation-error"
role="alert"
id="password-error"
>
{actionData?.fieldErrors.password}
</p>
) : null}
</div>
<div id="form-error-message">
{actionData?.formError ? (
<p className="form-validation-error" role="alert">
{actionData?.formError}
</p>
) : null}
</div>
<button type="submit" className="button">
Submit
</button>
</Form>
</div> </div>
<div className="links"> <ul className="menu px-3 menu-horizontal rounded-box max-w-xs mx-auto flex items-center justify-evenly">
<ul> <li>
<li> <Link to="/" className="btn btn-outline btn-accent">
<Link to="/">Home</Link> Home
</li> </Link>
</ul> </li>
</div> <li>
</div> <Link to="/login" className="btn btn-outline btn-accent">
Login
</Link>
</li>
</ul>
</>
); );
} }