style login form
This commit is contained in:
parent
a483bd62ab
commit
60b5c74ed6
102
app/auth/components/auth-form.tsx
Normal file
102
app/auth/components/auth-form.tsx
Normal 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;
|
66
app/auth/components/labeled-text-field.tsx
Normal file
66
app/auth/components/labeled-text-field.tsx
Normal 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;
|
@ -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;
|
@ -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?
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
15
app/core/components/logo.tsx
Normal file
15
app/core/components/logo.tsx
Normal 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;
|
@ -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 {
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user