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 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 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 SignIn: BlitzPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [loginMutation] = useMutation(login);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Form
|
||||||
<LoginForm
|
texts={{
|
||||||
onSuccess={() => {
|
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
|
const next = router.query.next
|
||||||
? decodeURIComponent(router.query.next as string)
|
? decodeURIComponent(router.query.next as string)
|
||||||
: Routes.Messages();
|
: Routes.Messages();
|
||||||
router.push(next);
|
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,
|
AuthenticationError,
|
||||||
AuthorizationError,
|
AuthorizationError,
|
||||||
ErrorFallbackProps,
|
ErrorFallbackProps,
|
||||||
|
RedirectError,
|
||||||
|
Routes,
|
||||||
useQueryErrorResetBoundary,
|
useQueryErrorResetBoundary,
|
||||||
getConfig,
|
getConfig,
|
||||||
useSession,
|
useSession,
|
||||||
@ -12,7 +14,6 @@ import {
|
|||||||
|
|
||||||
import Sentry from "../../integrations/sentry";
|
import Sentry from "../../integrations/sentry";
|
||||||
import ErrorComponent from "../core/components/error-component";
|
import ErrorComponent from "../core/components/error-component";
|
||||||
import LoginForm from "../auth/components/login-form";
|
|
||||||
import { usePanelbear } from "../core/hooks/use-panelbear";
|
import { usePanelbear } from "../core/hooks/use-panelbear";
|
||||||
|
|
||||||
import "app/core/styles/index.css";
|
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) {
|
if (error instanceof AuthenticationError) {
|
||||||
return <LoginForm onSuccess={resetErrorBoundary} />;
|
throw new RedirectError(Routes.SignIn());
|
||||||
} else if (error instanceof AuthorizationError) {
|
} else if (error instanceof AuthorizationError) {
|
||||||
return <ErrorComponent statusCode={error.statusCode} title="Sorry, you are not authorized to access this" />;
|
return <ErrorComponent statusCode={error.statusCode} title="Sorry, you are not authorized to access this" />;
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,7 @@ import { useMutation } from "blitz";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import updateUser from "../mutations/update-user";
|
import updateUser from "../mutations/update-user";
|
||||||
import Alert from "./alert";
|
import Alert from "../../core/components/alert";
|
||||||
import Button from "./button";
|
import Button from "./button";
|
||||||
import SettingsSection from "./settings-section";
|
import SettingsSection from "./settings-section";
|
||||||
import useCurrentUser from "../../core/hooks/use-current-user";
|
import useCurrentUser from "../../core/hooks/use-current-user";
|
||||||
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
|||||||
import { useMutation } from "blitz";
|
import { useMutation } from "blitz";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import Alert from "./alert";
|
import Alert from "../../core/components/alert";
|
||||||
import Button from "./button";
|
import Button from "./button";
|
||||||
import SettingsSection from "./settings-section";
|
import SettingsSection from "./settings-section";
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user