added more tests and ui updates
All checks were successful
Build / build (push) Successful in 11m48s
All checks were successful
Build / build (push) Successful in 11m48s
This commit is contained in:
parent
4d6600b383
commit
6498ac56d9
|
@ -14,25 +14,6 @@ const App: FunctionComponent = () => {
|
|||
<Navbar />
|
||||
<Container>
|
||||
<div className="flex flex-col">
|
||||
{/* <div className="grid grid-cols-3 justify-between text-slate-500 font-medium">
|
||||
<div className="flex-flex-row mr-auto text-lg">
|
||||
<Button text="GoShort" link="/" />
|
||||
</div>
|
||||
<div className="flex flex-row mx-auto">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Button text="Shorts" link="sht" />
|
||||
<Button text="Tokens" link="tkn" />
|
||||
<Button text="Sessions" link="ses" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row ml-auto">
|
||||
{isAuthenticated || <Button text="Signup" link="sgn" />}
|
||||
{isAuthenticated || <Button text="Login" link="lgn" />}
|
||||
{isAuthenticated && <Button text="Logout" link="lgo" />}
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ type DefaultButtonProps = DetailedHTMLProps<
|
|||
const Button: FunctionComponent<
|
||||
Pick<
|
||||
DefaultButtonProps,
|
||||
"className" | "onClick" | "type" | "disabled" | "children"
|
||||
"className" | "onClick" | "type" | "disabled" | "children" | "id"
|
||||
> & {
|
||||
color?: "blue" | "red" | "green"
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ export default function Navbar() {
|
|||
{ name: "Tokens", href: "tkn" },
|
||||
{ name: "Sessions", href: "ses" },
|
||||
]
|
||||
const unauthed = [{ name: "Signup", href: "sgn" }]
|
||||
const unauthed = [{ name: "Register", href: "sgn" }]
|
||||
return isAuthenticated ? authed : unauthed
|
||||
}, [isAuthenticated])
|
||||
|
||||
|
@ -72,7 +72,9 @@ export default function Navbar() {
|
|||
<Menu.Button className="relative py-2 justify-items-center flex rounded-lg focus:outline-none focus:ring-none">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<span className="my-auto">{user?.username}</span>
|
||||
<span className="my-auto">
|
||||
{user?.username.split("@")[0]}
|
||||
</span>
|
||||
<span className="my-auto">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 ml-1"
|
||||
|
@ -89,7 +91,24 @@ export default function Navbar() {
|
|||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none divide-solid divide-y w-min">
|
||||
<Menu.Item>
|
||||
<div className="block px-4 py-2 text-xs font-semibold text-gray-700">
|
||||
{user?.username}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to="/acc"
|
||||
className={classNames(
|
||||
active ? "bg-gray-100" : "",
|
||||
"block px-4 py-2 text-sm text-gray-700"
|
||||
)}>
|
||||
Account
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
|
|
|
@ -0,0 +1,217 @@
|
|||
import { ChangeEventHandler, FunctionComponent, useMemo, useState } from "react"
|
||||
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
} from "react-router-dom"
|
||||
|
||||
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 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
|
||||
|
||||
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 (
|
||||
<Form
|
||||
method="post"
|
||||
replace
|
||||
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8 max-w-min">
|
||||
<span className="text-3xl font-bold text-center mb-4">{opts.title}</span>
|
||||
{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>
|
||||
<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>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserForm
|
|
@ -1,13 +1,13 @@
|
|||
import { LoaderFunction, redirect, useRouteLoaderData } from "react-router-dom"
|
||||
|
||||
import { User } from "../types"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
import fetchAPI, { FetchAPIResult } from "../util/fetchAPI"
|
||||
|
||||
export type AuthProviderType = {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
signup(username: string, password: string): Promise<boolean>
|
||||
login(username: string, password: string): Promise<boolean>
|
||||
signup(username: string, password: string): Promise<FetchAPIResult<User>>
|
||||
login(username: string, password: string): Promise<FetchAPIResult<User>>
|
||||
logout(): Promise<boolean>
|
||||
}
|
||||
|
||||
|
@ -24,13 +24,13 @@ async function digestPassword(message: string): Promise<string> {
|
|||
return hashHex
|
||||
}
|
||||
|
||||
export const AuthProvider: AuthProviderType = {
|
||||
const AuthProvider: AuthProviderType = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
async signup(username, plaintextPassword) {
|
||||
const password = await digestPassword(plaintextPassword)
|
||||
|
||||
const response = await fetchAPI("/signup", {
|
||||
const response = await fetchAPI<User>("/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
@ -38,7 +38,7 @@ export const AuthProvider: AuthProviderType = {
|
|||
if (response.ok) {
|
||||
return this.login(username, plaintextPassword)
|
||||
}
|
||||
return false
|
||||
return response
|
||||
},
|
||||
async login(username, plaintextPassword) {
|
||||
const password = await digestPassword(plaintextPassword)
|
||||
|
@ -49,13 +49,16 @@ export const AuthProvider: AuthProviderType = {
|
|||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.user = response.data as User
|
||||
this.user = response.data
|
||||
this.isAuthenticated = true
|
||||
return true
|
||||
return response
|
||||
}
|
||||
return false
|
||||
return response
|
||||
},
|
||||
async logout() {
|
||||
if (!this.isAuthenticated) {
|
||||
return true
|
||||
}
|
||||
const response = await fetchAPI("/logout", {
|
||||
method: "POST",
|
||||
})
|
||||
|
@ -84,6 +87,8 @@ export const indexLoader: LoaderFunction =
|
|||
if (response.ok) {
|
||||
AuthProvider.user = response.data as User
|
||||
AuthProvider.isAuthenticated = true
|
||||
} else {
|
||||
// await AuthProvider.logout()
|
||||
}
|
||||
return {
|
||||
user: AuthProvider.user,
|
||||
|
@ -103,11 +108,15 @@ export const loginAction: LoaderFunction = async ({ request }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const ok = await AuthProvider.login(username, password)
|
||||
const r = await AuthProvider.login(username, password)
|
||||
// Sign in and redirect to the proper destination if successful.
|
||||
if (!ok) {
|
||||
if (!r.ok) {
|
||||
// Use a more user-friendly error
|
||||
if (r.error === "Unauthorized") {
|
||||
r.error = "Invalid username or password"
|
||||
}
|
||||
return {
|
||||
error: "Invalid login attempt",
|
||||
error: r.error,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -133,11 +142,11 @@ export const singupAction: LoaderFunction = async ({ request }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const ok = await AuthProvider.signup(username, password)
|
||||
const r = await AuthProvider.signup(username, password)
|
||||
// Sign in and redirect to the proper destination if successful.
|
||||
if (!ok) {
|
||||
if (!r.ok) {
|
||||
return {
|
||||
error: "Invalid signup attempt",
|
||||
error: r.error,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { FunctionComponent } from "react"
|
||||
|
||||
import { LoaderFunction, redirect } from "react-router-dom"
|
||||
|
||||
import Button from "../components/Button"
|
||||
import Header from "../components/Header"
|
||||
import { protectedLoader } from "../hooks/useAuth"
|
||||
import { useDelete, useDoubleclickDelete } from "../hooks/useCRUD"
|
||||
import { crudAction } from "../util/action"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
|
||||
export const Component: FunctionComponent = () => {
|
||||
const [deleting, del] = useDelete()
|
||||
const [deleteArmed, armDelete] = useDoubleclickDelete(del)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Account" />
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full p-8 border-red-500 border-4 rounded-lg ">
|
||||
<h1 className="text-3xl font-bold text-red-500">Danger Zone</h1>
|
||||
<p className="text-slate-600 font-semibold mt-4">
|
||||
Deleting your account is permanent and cannot be undone.
|
||||
</p>
|
||||
<Button
|
||||
onClick={armDelete}
|
||||
color="red"
|
||||
className="mt-12 px-8 py-3 max-w-fit">
|
||||
{deleting
|
||||
? "Deleting..."
|
||||
: deleteArmed
|
||||
? "Are you sure?"
|
||||
: "Delete my account"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const loader: LoaderFunction = async (args) => {
|
||||
const resp = await protectedLoader(args)
|
||||
if (resp) return resp
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const action = crudAction({
|
||||
DELETE: async () => {
|
||||
const res = await fetchAPI("/me", {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (res.ok) {
|
||||
return redirect("/lgo")
|
||||
}
|
||||
return res
|
||||
},
|
||||
})
|
|
@ -171,6 +171,7 @@ export const Component: FunctionComponent = () => {
|
|||
</span>
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="url"
|
||||
name="url"
|
||||
autoComplete="off"
|
||||
|
|
|
@ -1,79 +1,11 @@
|
|||
import {
|
||||
Form,
|
||||
Link,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
} from "react-router-dom"
|
||||
|
||||
import Button from "../components/Button"
|
||||
import Header from "../components/Header"
|
||||
import UserForm from "../components/UserForm"
|
||||
|
||||
export function Component() {
|
||||
const [params] = useSearchParams()
|
||||
const from = params.get("from") || "/"
|
||||
|
||||
const navigation = useNavigation()
|
||||
const isSigningUp = 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 signuplink = from ? `/sgn?from=${from}` : "/sgn"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="" />
|
||||
<Form
|
||||
method="post"
|
||||
replace
|
||||
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
||||
<span className="text-3xl font-bold text-center mb-4">Log in</span>
|
||||
{actionData && actionData.error ? (
|
||||
<p className="text-red-500 text-center font-medium">
|
||||
{actionData.error}
|
||||
</p>
|
||||
) : null}
|
||||
<input type="hidden" name="redirectTo" value={from} />
|
||||
<label className="flex flex-col">
|
||||
<input
|
||||
name="username"
|
||||
minLength={4}
|
||||
maxLength={128}
|
||||
required
|
||||
placeholder="username"
|
||||
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
placeholder="password"
|
||||
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
color="blue"
|
||||
disabled={isSigningUp}
|
||||
className="mt-6 px-8 py-3 max-w-fit mx-auto">
|
||||
{isSigningUp ? "Logging in..." : "Log in"}
|
||||
</Button>
|
||||
<span className="text-slate-500 font-light text-center text-sm">
|
||||
{`Don't have an account? `}
|
||||
<Link
|
||||
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
|
||||
to={signuplink}>
|
||||
Sign up
|
||||
</Link>
|
||||
</span>
|
||||
</Form>
|
||||
<UserForm action="login" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,79 +1,11 @@
|
|||
import {
|
||||
Form,
|
||||
Link,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
} from "react-router-dom"
|
||||
|
||||
import Button from "../components/Button"
|
||||
import Header from "../components/Header"
|
||||
import UserForm from "../components/UserForm"
|
||||
|
||||
export function Component() {
|
||||
const [params] = useSearchParams()
|
||||
const from = params.get("from") || "/"
|
||||
|
||||
const navigation = useNavigation()
|
||||
const isSigningUp = 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 loginLink = from ? `/lgn?from=${from}` : "/lgn"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="" />
|
||||
<Form
|
||||
method="post"
|
||||
replace
|
||||
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
||||
<span className="text-3xl font-bold text-center mb-4">Sign up</span>
|
||||
{actionData && actionData.error ? (
|
||||
<p className="text-red-500 text-center font-medium">
|
||||
{actionData.error}
|
||||
</p>
|
||||
) : null}
|
||||
<input type="hidden" name="redirectTo" value={from} />
|
||||
<label className="flex flex-col">
|
||||
<input
|
||||
name="username"
|
||||
minLength={4}
|
||||
maxLength={128}
|
||||
required
|
||||
placeholder="username"
|
||||
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
placeholder="password"
|
||||
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
color="blue"
|
||||
disabled={isSigningUp}
|
||||
className="mt-6 px-8 py-3 max-w-fit mx-auto">
|
||||
{isSigningUp ? "Signing up..." : "Sign up"}
|
||||
</Button>
|
||||
<span className="text-slate-500 font-light text-center text-sm">
|
||||
{`Have an account? `}
|
||||
<Link
|
||||
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
|
||||
to={loginLink}>
|
||||
Log in
|
||||
</Link>
|
||||
</span>
|
||||
</Form>
|
||||
<UserForm action="register" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -65,6 +65,11 @@ export default createBrowserRouter([
|
|||
path: "ses",
|
||||
lazy: () => import("./pages/Sessions"),
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
path: "acc",
|
||||
lazy: () => import("./pages/Account"),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFound />,
|
||||
|
|
|
@ -6,7 +6,7 @@ import { FetchAPIResult } from "./fetchAPI"
|
|||
|
||||
export type ActionHandler<T> = (
|
||||
formData: FormData
|
||||
) => Promise<FetchAPIResult<T>>
|
||||
) => Promise<FetchAPIResult<T> | Response>
|
||||
export type ActionHandlers<T> = {
|
||||
[method in Uppercase<FormMethod>]?: ActionHandler<T>
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { GenericItem } from "../types"
|
||||
|
||||
type ErrorResponse = {
|
||||
status: string
|
||||
error: string
|
||||
|
@ -14,7 +16,7 @@ export type FetchAPIResult<T> =
|
|||
}
|
||||
|
||||
// Fetch function that automatically points to the API URL
|
||||
export default async function <T>(
|
||||
export default async function <T = GenericItem>(
|
||||
path: string,
|
||||
args: Parameters<typeof fetch>[1] = {}
|
||||
): Promise<FetchAPIResult<T>> {
|
||||
|
|
|
@ -3,7 +3,8 @@ package userservice
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"git.maronato.dev/maronato/goshort/internal/config"
|
||||
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||
|
@ -14,20 +15,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// MinUsernameLength is the minimum length of a username.
|
||||
MinUsernameLength = 4
|
||||
// MaxUsernameLength is the maximum length of a username.
|
||||
MaxUsernameLength = 32
|
||||
// MinPasswordLength is the minimum length of a password.
|
||||
MinPasswordLength = 8
|
||||
)
|
||||
|
||||
var UsernameRegex = regexp.MustCompile(
|
||||
fmt.Sprintf(
|
||||
"^[a-zA-Z0-9_-]{%d,%d}$",
|
||||
MinUsernameLength,
|
||||
MaxUsernameLength,
|
||||
),
|
||||
// MaxPasswordLength is the maximum length of a password.
|
||||
MaxPasswordLength = 128
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
|
@ -48,7 +39,7 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
|
|||
// Check if the username is valid
|
||||
err := UsernameIsValid(username)
|
||||
if err != nil {
|
||||
return &models.User{}, fmt.Errorf("could not validate username: %w", err)
|
||||
return nil, fmt.Errorf("could not validate username: %w", err)
|
||||
}
|
||||
|
||||
// Get the user from storage
|
||||
|
@ -63,18 +54,18 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
|
|||
func (s *UserService) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
|
||||
// Check for disabled registration
|
||||
if s.disableRegistration {
|
||||
return &models.User{}, errs.ErrRegistrationDisabled
|
||||
return nil, errs.ErrRegistrationDisabled
|
||||
}
|
||||
|
||||
// Check if the user is valid
|
||||
err := UserIsValid(user)
|
||||
if err != nil {
|
||||
return &models.User{}, fmt.Errorf("could not validate user: %w", err)
|
||||
return nil, fmt.Errorf("could not validate user: %w", err)
|
||||
}
|
||||
|
||||
newUser, err := s.db.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
return &models.User{}, fmt.Errorf("could not create user in storage: %w", err)
|
||||
return nil, fmt.Errorf("could not create user in storage: %w", err)
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
|
@ -93,23 +84,26 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username, password s
|
|||
// Get user from storage
|
||||
user, err = s.FindUser(ctx, username)
|
||||
if err != nil {
|
||||
// Even if the user does not exist, hash a password to waste time
|
||||
// and not give away wether or not the user exists.
|
||||
_, _ = s.hasher.Hash("r4ndom_passw0rd")
|
||||
// Waste time if the user is not found
|
||||
// to mitigate timing attacks
|
||||
wasteErr := s.hasher.WasteTime()
|
||||
if wasteErr != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", wasteErr)
|
||||
}
|
||||
|
||||
return &models.User{}, fmt.Errorf("failed to find user: %w", err)
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
// Try to authenticate
|
||||
if password == "" || user.GetPasswordHash() == "" {
|
||||
return &models.User{}, errs.ErrFailedAuthentication
|
||||
return nil, errs.ErrFailedAuthentication
|
||||
}
|
||||
|
||||
match, err := s.hasher.Verify(password, user.GetPasswordHash())
|
||||
if err != nil {
|
||||
return &models.User{}, fmt.Errorf("failed to authenticate user: %w", err)
|
||||
return nil, fmt.Errorf("failed to authenticate user: %w", err)
|
||||
} else if !match {
|
||||
return &models.User{}, errs.ErrFailedAuthentication
|
||||
return nil, errs.ErrFailedAuthentication
|
||||
}
|
||||
|
||||
// Success
|
||||
|
@ -118,8 +112,8 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username, password s
|
|||
|
||||
func (s *UserService) SetPassword(_ context.Context, user *models.User, newPassword string) error {
|
||||
// Check if the password is valid
|
||||
if len(newPassword) < MinPasswordLength {
|
||||
return fmt.Errorf("password must be at least %d characters long: %w", MinPasswordLength, errs.ErrInvalidUser)
|
||||
if len(newPassword) < MinPasswordLength || len(newPassword) > MaxPasswordLength {
|
||||
return fmt.Errorf("password must be between %d and %d characters long: %w", MinPasswordLength, MaxPasswordLength, errs.ErrInvalidUser)
|
||||
}
|
||||
// Set the new password
|
||||
err := user.SetPassword(s.hasher, newPassword)
|
||||
|
@ -131,11 +125,13 @@ func (s *UserService) SetPassword(_ context.Context, user *models.User, newPassw
|
|||
}
|
||||
|
||||
func UsernameIsValid(username string) error {
|
||||
if !UsernameRegex.MatchString(username) {
|
||||
return errs.Errorf(fmt.Sprintf("username must match %s", UsernameRegex), errs.ErrInvalidUser)
|
||||
if !strings.Contains(username, "<") {
|
||||
if _, err := mail.ParseAddress(username); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return errs.Errorf("username must be a valid email address", errs.ErrInvalidUser)
|
||||
}
|
||||
|
||||
func UserIsValid(user *models.User) error {
|
||||
|
|
|
@ -401,11 +401,37 @@ func (s *BunStorage) CreateUser(ctx context.Context, user *models.User) (*models
|
|||
func (s *BunStorage) DeleteUser(ctx context.Context, user *models.User) error {
|
||||
// Delete user in transaction
|
||||
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Delete user short logs
|
||||
_, err := tx.NewDelete().
|
||||
Model((*ShortLogModel)(nil)).
|
||||
Where("short_id IN (?)", tx.NewSelect().
|
||||
Model((*ShortModel)(nil)).
|
||||
Column("id").
|
||||
Where("user_id = ?", user.ID)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errs.Errorf("failed to delete short logs from user's shorts", err)
|
||||
}
|
||||
|
||||
// Delete user shorts
|
||||
err := deleteUserShorts(ctx, tx, user)
|
||||
_, err = withShortDeleteUpdates(
|
||||
tx.NewUpdate().
|
||||
Model((*ShortModel)(nil)).
|
||||
Where("user_id = ?", user.ID),
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
return errs.Errorf("failed to delete user shorts", err)
|
||||
}
|
||||
|
||||
// Delete user tokens
|
||||
_, err = tx.NewDelete().
|
||||
Model((*TokenModel)(nil)).
|
||||
Where("user_id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errs.Errorf("failed to delete user", err)
|
||||
}
|
||||
|
||||
// Delete user
|
||||
_, err = tx.NewDelete().
|
||||
Model(user).
|
||||
|
@ -585,19 +611,6 @@ func withShortDeleteUpdates(q *bun.UpdateQuery) *bun.UpdateQuery {
|
|||
Set("user_id = ?", nil)
|
||||
}
|
||||
|
||||
func deleteUserShorts(ctx context.Context, db bun.IDB, user *models.User) error {
|
||||
_, err := withShortDeleteUpdates(
|
||||
db.NewUpdate().
|
||||
Model((*ShortModel)(nil)).
|
||||
Where("user_id = ?", user.ID),
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
return errs.Errorf("failed to delete user shorts", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNewID(ctx context.Context, db bun.IDB, table string) (string, error) {
|
||||
var newID string
|
||||
|
||||
|
|
|
@ -688,6 +688,29 @@ func ITestDeleteUser(t *testing.T, stg storage.Storage) {
|
|||
_ = baseUser.SetPassword(bh, "mypassword")
|
||||
|
||||
user, _ := stg.CreateUser(ctx, baseUser)
|
||||
short1, _ := stg.CreateShort(ctx, &models.Short{
|
||||
Name: "myshort",
|
||||
URL: "https://example.com",
|
||||
UserID: &user.ID,
|
||||
})
|
||||
short2, _ := stg.CreateShort(ctx, &models.Short{
|
||||
Name: "myshort2",
|
||||
URL: "https://example.com",
|
||||
UserID: &user.ID,
|
||||
})
|
||||
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||
ShortID: short1.ID,
|
||||
})
|
||||
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||
ShortID: short1.ID,
|
||||
})
|
||||
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||
ShortID: short2.ID,
|
||||
})
|
||||
_, _ = stg.CreateToken(ctx, &models.Token{
|
||||
Value: "myvalue",
|
||||
UserID: &user.ID,
|
||||
})
|
||||
|
||||
found, _ := stg.FindUser(ctx, user.Username)
|
||||
assert.NotNil(t, found, "Should find the user")
|
||||
|
@ -699,6 +722,16 @@ func ITestDeleteUser(t *testing.T, stg storage.Storage) {
|
|||
assert.ErrorIs(t, err, errs.ErrUserDoesNotExist, "Should return an error when finding the user")
|
||||
assert.Nil(t, found, "Should not find the user")
|
||||
|
||||
shorts, _ := stg.ListShorts(ctx, user)
|
||||
assert.Len(t, shorts, 0, "Should not have any shorts")
|
||||
|
||||
shortLogs1, _ := stg.ListShortLogs(ctx, short1)
|
||||
shortLogs2, _ := stg.ListShortLogs(ctx, short2)
|
||||
assert.Len(t, append(shortLogs1, shortLogs2...), 0, "Should not have any short logs")
|
||||
|
||||
tokens, _ := stg.ListTokens(ctx, user)
|
||||
assert.Len(t, tokens, 0, "Should not have any tokens")
|
||||
|
||||
err = stg.DeleteUser(ctx, user)
|
||||
assert.Nil(t, err, "Should not return an error when deleting a deleted user")
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
const (
|
||||
defaultMemory uint32 = 512 * 1024 // 512 MiB
|
||||
defaultIterations uint32 = 1
|
||||
defaultIterations uint32 = 2
|
||||
defaultParallelism uint8 = 8
|
||||
defaultSaltLength uint32 = 16
|
||||
defaultKeyLength uint32 = 32
|
||||
|
@ -85,6 +85,18 @@ func (h *ArgonHasher) Verify(password, encodedHash string) (match bool, err erro
|
|||
return false, nil
|
||||
}
|
||||
|
||||
func (h *ArgonHasher) WasteTime() error {
|
||||
// Const hash of "r4ndom_passw0rd"
|
||||
const hash = "argon2id$v=19,m=524288,t=2,p=8$yK93NxxW12tA3vE+FnbdTQ$U2cDKipQ/X3nRi0saQhUiSoLofBBWqpoJTIQyhsCJ3s"
|
||||
|
||||
_, err := h.Verify("oth3r_passw0rd", hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not waste time: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ArgonHasher) decodeHash(encodedHash string) (p argonParams, salt, hash []byte, err error) { //nolint:cyclop // Not refactoring this
|
||||
algorithm, strSalt, strHash, params, err := passwords.DecodePasswordHash(encodedHash)
|
||||
if err != nil {
|
||||
|
|
|
@ -45,3 +45,11 @@ func TestArgonVerify(t *testing.T) {
|
|||
|
||||
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "ArgonVerify should return an error for an invalid hash")
|
||||
}
|
||||
|
||||
func TestArgonWasteTime(t *testing.T) {
|
||||
ah := NewArgonHasher()
|
||||
|
||||
err := ah.WasteTime()
|
||||
|
||||
assert.Nil(t, err, "WasteTime should not return an error")
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const defaultCost = 13
|
||||
|
||||
type BcryptHasher struct {
|
||||
passwords.PasswordHasher
|
||||
cost int
|
||||
|
@ -15,7 +17,7 @@ type BcryptHasher struct {
|
|||
|
||||
func NewBcryptHasher() *BcryptHasher {
|
||||
return &BcryptHasher{
|
||||
cost: bcrypt.DefaultCost,
|
||||
cost: defaultCost,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,3 +45,15 @@ func (h *BcryptHasher) Verify(password, encodedHash string) (bool, error) {
|
|||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *BcryptHasher) WasteTime() error {
|
||||
// Const hash of "r4ndom_passw0rd"
|
||||
const hash = "$2a$13$IVy6l.btAXiQUNjV40nai.1HV1VL.4DvoBDrTSE7b16CioMe1i3eG"
|
||||
|
||||
_, err := h.Verify("oth3r_passw0rd", hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not waste time: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -45,3 +45,11 @@ func TestBcryptVerify(t *testing.T) {
|
|||
|
||||
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "BcryptVerify should return an error for an invalid hash")
|
||||
}
|
||||
|
||||
func TestBcryptWasteTime(t *testing.T) {
|
||||
bh := NewBcryptHasher()
|
||||
|
||||
err := bh.WasteTime()
|
||||
|
||||
assert.Nil(t, err, "WasteTime should not return an error")
|
||||
}
|
||||
|
|
|
@ -3,4 +3,5 @@ package passwords
|
|||
type PasswordHasher interface {
|
||||
Hash(password string) (string, error)
|
||||
Verify(password, encodedHash string) (bool, error)
|
||||
WasteTime() error
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user