style login form

This commit is contained in:
m5r 2021-09-25 20:58:28 +08:00
parent a483bd62ab
commit 60b5c74ed6
9 changed files with 234 additions and 67 deletions

View File

@ -0,0 +1,102 @@
import { useState, ReactNode, PropsWithoutRef } from "react";
import { FormProvider, useForm, UseFormProps } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Alert from "../../core/components/alert";
import clsx from "clsx";
import Logo from "../../core/components/logo";
import { Link, Routes } from "blitz";
export interface FormProps<S extends z.ZodType<any, any>>
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
/** All your form fields */
children?: ReactNode;
texts: {
title: string;
subtitle: ReactNode;
submit: string;
};
schema?: S;
onSubmit: (values: z.infer<S>) => Promise<void | OnSubmitResult>;
initialValues?: UseFormProps<z.infer<S>>["defaultValues"];
}
interface OnSubmitResult {
FORM_ERROR?: string;
[prop: string]: any;
}
export const FORM_ERROR = "FORM_ERROR";
export function AuthForm<S extends z.ZodType<any, any>>({
children,
texts,
schema,
initialValues,
onSubmit,
...props
}: FormProps<S>) {
const ctx = useForm<z.infer<S>>({
mode: "onBlur",
resolver: schema ? zodResolver(schema) : undefined,
defaultValues: initialValues,
});
const [formError, setFormError] = useState<string | null>(null);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-12 w-12" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">{texts.title}</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">{texts.subtitle}</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<FormProvider {...ctx}>
<form
onSubmit={ctx.handleSubmit(async (values) => {
const result = (await onSubmit(values)) || {};
for (const [key, value] of Object.entries(result)) {
if (key === FORM_ERROR) {
setFormError(value);
} else {
ctx.setError(key as any, {
type: "submit",
message: value,
});
}
}
})}
className="form"
{...props}
>
{children}
{formError ? (
<div role="alert" className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert title="Oops, there was an issue" message={formError} variant="error" />
</div>
) : null}
<button
type="submit"
disabled={ctx.formState.isSubmitting}
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": ctx.formState.isSubmitting,
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,
},
)}
>
{texts.submit}
</button>
</form>
</FormProvider>
</div>
</div>
);
}
export default AuthForm;

View File

@ -0,0 +1,66 @@
import { forwardRef, PropsWithoutRef } from "react";
import { Link, Routes } from "blitz";
import { useFormContext } from "react-hook-form";
import clsx from "clsx";
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string;
/** Field label. */
label: string;
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number";
showForgotPasswordLabel?: boolean;
}
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ label, name, showForgotPasswordLabel, ...props }, ref) => {
const {
register,
formState: { isSubmitting, errors },
} = useFormContext();
const error = Array.isArray(errors[name]) ? errors[name].join(", ") : errors[name]?.message || errors[name];
return (
<div className="mb-6">
<label
htmlFor="name"
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !showForgotPasswordLabel,
"flex justify-between": showForgotPasswordLabel,
})}
>
{label}
{showForgotPasswordLabel ? (
<div>
<Link href={Routes.ForgotPasswordPage()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Forgot your password?
</a>
</Link>
</div>
) : null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="name"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
disabled={isSubmitting}
{...register(name)}
{...props}
/>
</div>
{error ? (
<div role="alert" style={{ color: "red" }}>
{error}
</div>
) : null}
</div>
);
},
);
export default LabeledTextField;

View File

@ -1,55 +0,0 @@
import { AuthenticationError, Link, useMutation, Routes } from "blitz";
import { LabeledTextField } from "../../core/components/labeled-text-field";
import { Form, FORM_ERROR } from "../../core/components/form";
import login from "../../../app/auth/mutations/login";
import { Login } from "../validations";
type LoginFormProps = {
onSuccess?: () => void;
};
export const LoginForm = (props: LoginFormProps) => {
const [loginMutation] = useMutation(login);
return (
<div>
<h1>Login</h1>
<Form
submitText="Login"
schema={Login}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await loginMutation(values);
props.onSuccess?.();
} catch (error: any) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
} else {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again. - " + error.toString(),
};
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" type="email" />
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
<div>
<Link href={Routes.ForgotPasswordPage()}>
<a>Forgot your password?</a>
</Link>
</div>
</Form>
<div style={{ marginTop: "1rem" }}>
Or <Link href={Routes.SignUp()}>Sign Up</Link>
</div>
</div>
);
};
export default LoginForm;

View File

@ -1,23 +1,61 @@
import type { BlitzPage } from "blitz";
import { useRouter, Routes } from "blitz";
import { useRouter, Routes, AuthenticationError, Link, useMutation } from "blitz";
import BaseLayout from "../../core/layouts/base-layout";
import { LoginForm } from "../components/login-form";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { Login } from "../validations";
import { LabeledTextField } from "../components/labeled-text-field";
import login from "../mutations/login";
const SignIn: BlitzPage = () => {
const router = useRouter();
const [loginMutation] = useMutation(login);
return (
<div>
<LoginForm
onSuccess={() => {
<Form
texts={{
title: "Welcome back!",
subtitle: (
<>
Need an account?&nbsp;
<Link href={Routes.SignUp()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Create yours for free
</a>
</Link>
</>
),
submit: "Sign in",
}}
schema={Login}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await loginMutation(values);
const next = router.query.next
? decodeURIComponent(router.query.next as string)
: Routes.Messages();
router.push(next);
}}
} catch (error: any) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
} else {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again. - " + error.toString(),
};
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" type="email" />
<LabeledTextField
name="password"
label="Password"
placeholder="Password"
type="password"
showForgotPasswordLabel
/>
</div>
</Form>
);
};

View File

@ -0,0 +1,15 @@
import type { FunctionComponent } from "react";
import Image from "next/image";
import clsx from "clsx";
type Props = {
className?: string;
};
const Logo: FunctionComponent<Props> = ({ className }) => (
<div className={clsx("relative", className)}>
<Image src="/shellphone.png" layout="fill" alt="app logo" />
</div>
);
export default Logo;

View File

@ -5,6 +5,8 @@ import {
AuthenticationError,
AuthorizationError,
ErrorFallbackProps,
RedirectError,
Routes,
useQueryErrorResetBoundary,
getConfig,
useSession,
@ -12,7 +14,6 @@ import {
import Sentry from "../../integrations/sentry";
import ErrorComponent from "../core/components/error-component";
import LoginForm from "../auth/components/login-form";
import { usePanelbear } from "../core/hooks/use-panelbear";
import "app/core/styles/index.css";
@ -46,9 +47,9 @@ export default function App({ Component, pageProps }: AppProps) {
);
}
function RootErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
function RootErrorFallback({ error }: ErrorFallbackProps) {
if (error instanceof AuthenticationError) {
return <LoginForm onSuccess={resetErrorBoundary} />;
throw new RedirectError(Routes.SignIn());
} else if (error instanceof AuthorizationError) {
return <ErrorComponent statusCode={error.statusCode} title="Sorry, you are not authorized to access this" />;
} else {

View File

@ -4,7 +4,7 @@ import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import updateUser from "../mutations/update-user";
import Alert from "./alert";
import Alert from "../../core/components/alert";
import Button from "./button";
import SettingsSection from "./settings-section";
import useCurrentUser from "../../core/hooks/use-current-user";

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { useMutation } from "blitz";
import { useForm } from "react-hook-form";
import Alert from "./alert";
import Alert from "../../core/components/alert";
import Button from "./button";
import SettingsSection from "./settings-section";