246 lines
7.7 KiB
TypeScript
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
|