goshort/frontend/src/components/UserForm.tsx
Gustavo Maronato 28b49fb78f
All checks were successful
Check / checks (push) Successful in 2m57s
use odc instead of oidc for url
2024-03-23 14:56:02 -04:00

246 lines
7.7 KiB
TypeScript

import { ChangeEventHandler, FunctionComponent, useMemo, useState } from "react"
import {
Form,
Link,
useActionData,
useNavigation,
useSearchParams,
} from "react-router-dom"
import { useServerConfig } from "../hooks/useServerConfig"
import Button from "./Button"
const actionOptions = {
login: {
title: "Log in",
buttonText: "Log in",
buttonLoadingText: "Logging in...",
buttonID: "login",
passAutoComplete: "current-password",
altLabel: "Don't have an account?",
altLinkText: "Register",
altLink: "/sgn",
},
register: {
title: "Register",
buttonText: "Register",
buttonLoadingText: "Registering...",
buttonID: "register",
passAutoComplete: "new-password",
altLabel: "Have an account?",
altLinkText: "Log in",
altLink: "/lgn",
},
}
/**
*
* @param password password to score
* @returns password score, unbound
*/
const passwordOMeter = (password: string): number => {
let score = 0
const length = password.length
if (length <= 8) return score
const lowercases = password.match(/[a-z]/)?.length || 0
const uppercases = password.match(/[A-Z]/)?.length || 0
const numbers = password.match(/[0-9]/)?.length || 0
const specials = password.match(/[^a-zA-Z0-9]/)?.length || 0
// Add 1 every 13 characters
score += Math.floor(length / 13)
// 1 for every 12 lowercases, 1 for every 12 uppercases, 1 for both
score += Math.ceil(lowercases / 12)
score += Math.ceil(uppercases / 12)
score += lowercases > 0 && uppercases > 0 ? 1 : 0
// 1 for every 8 numbers
score += Math.ceil(numbers / 8)
// 1 for every 2 special characters
score += Math.ceil(specials / 2)
// Divide the result by 3/5 and round up
return Math.ceil((score * 3) / 5)
}
const PassScore: FunctionComponent<{ score: number }> = ({ score }) => {
const color = useMemo(() => {
if (score <= 1) return "bg-red-500"
if (score <= 2) return "bg-orange-500"
if (score <= 3) return "bg-yellow-500"
else return "bg-green-500"
}, [score])
const text = useMemo(() => {
if (score <= 0) return "Very weak"
if (score <= 1) return "Weak"
if (score <= 2) return "Okay"
if (score <= 3) return "Good"
else return "Strong"
}, [score])
const maxCells = 4
const litCells = Math.min(score, maxCells)
const emptyCells = maxCells - litCells
return (
<div className="flex flex-col">
<div className="flex flex-row gap-x-1">
{Array.from({ length: litCells }).map((_, i) => (
<div key={i} className={`w-1/4 h-1 rounded-full ${color}`} />
))}
{Array.from({ length: emptyCells }).map((_, i) => (
<div key={i} className={`w-1/4 h-1 rounded-full bg-slate-200`} />
))}
</div>
<span className="text-slate-400 font-light text-xs ml-auto">{text}</span>
</div>
)
}
const UserForm: FunctionComponent<{
action: "login" | "register"
}> = ({ action }) => {
const [params] = useSearchParams()
const from = params.get("from") || "/"
const {
disableRegistration,
disableCredentialLogin,
oidcIssuerUrl,
oidcIssuerName,
} = useServerConfig()
const showRegisterButton = useMemo(
() => !disableRegistration && !disableCredentialLogin,
[disableRegistration, disableCredentialLogin]
)
const opts = actionOptions[action]
const navigation = useNavigation()
const isLoading = navigation.formData?.get("username") != null
const actionData = useActionData() as { error: string } | undefined
// Since a user may go from the login page to the signup page, we want to
// preserve the `from` query parameter so that we can redirect the user back
// to the page they were on before they logged in.
const altLink = from ? `${opts.altLink}?from=${from}` : opts.altLink
// Redirect user from signup if `showRegisterButton` is false
if (!showRegisterButton && action === "register") {
window.location.href = altLink
}
const [password, setPassword] = useState("")
const onPasswordChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const password = event.target.value
setPassword(password)
}
const canSubmit = useMemo(
() => action === "login" || password.length >= 8,
[password, action]
)
const passwordScore = useMemo(() => passwordOMeter(password), [password])
return (
<div className="flex flex-col mx-auto rounded-lg shadow-md p-8 max-w-md gap-y-6">
<span className="text-3xl font-bold text-center mb-4">{opts.title}</span>
{disableCredentialLogin ? null : (
<Form method="post" replace className="flex flex-col gap-y-2">
{actionData && actionData.error ? (
<p className="text-red-500 text-center font-medium">
{actionData.error}
</p>
) : null}
<input type="hidden" name="redirectTo" value={from} />
<section className="flex flex-col">
<label
htmlFor="email"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Email
</label>
<input
autoFocus
id="email"
name="username"
autoComplete="username"
type="email"
required
placeholder=" "
minLength={4}
maxLength={128}
className="p-1 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</section>
<section className="flex flex-col">
<label
htmlFor="password"
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
Password
</label>
<input
id={opts.passAutoComplete}
name="password"
autoComplete={opts.passAutoComplete}
aria-describedby={
action === "login" ? undefined : "password-constraints"
}
type="password"
placeholder=" "
onChange={onPasswordChange}
minLength={8}
maxLength={128}
required
className="mb-1 p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
{action === "login" ? null : (
<>
<PassScore score={passwordScore} />
<div
id="password-constraints"
className="mt-2 text-sm text-slate-500">
Password must be:
<ul className="list-disc list-inside">
<li className="list-item">At least 8 characters long</li>
</ul>
</div>
</>
)}
</section>
<Button
id={opts.buttonID}
type="submit"
color="blue"
disabled={isLoading || !canSubmit}
className="mt-6 px-8 py-3 max-w-fit mx-auto">
{isLoading ? opts.buttonLoadingText : opts.buttonText}
</Button>
</Form>
)}
{oidcIssuerUrl.length ? (
<a href={`${import.meta.env.VITE_OIDC_URL}/redirect`}>
<Button color="green" className="px-8 py-3 max-w-fit mx-auto">
{`${opts.buttonText} with ${oidcIssuerName}`}
</Button>
</a>
) : null}
{!showRegisterButton && action === "login" ? null : (
<span className="text-slate-500 font-light text-center text-sm">
{opts.altLabel + " "}
<Link
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
to={altLink}>
{opts.altLinkText}
</Link>
</span>
)}
</div>
)
}
export default UserForm