some change

This commit is contained in:
john 2025-05-16 16:09:35 +02:00
parent d4a1492d56
commit 313f1def49
38 changed files with 475 additions and 401 deletions

View file

@ -0,0 +1,51 @@
import { User } from '../user/userStore.ts'
import { dispatchMessage } from '../messageBus/messageBus.ts'
import client from '../api/client.ts'
export class AuthService {
constructor(private readonly user: User | null) {}
async login(username: string, password: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/login', {
body: { username, password },
credentials: 'include',
})
if (!res.data) {
throw new Error('invalid credentials')
}
dispatchMessage('auth:logged-in', { ...res.data })
}
async signup(username: string, password: string, signupCode: string) {
if (this.user != null) {
throw new Error('already logged in')
}
const res = await client.POST('/auth/register', {
body: { username, password, signupCode, email: null },
credentials: 'include',
})
if (!res.data) {
throw new Error('invalid credentials')
}
dispatchMessage('auth:registered', { ...res.data })
}
async logout() {
if (this.user == null) {
return
}
await client.DELETE('/auth/session', { credentials: 'include' })
dispatchMessage('auth:logged-out', null)
}
}

View file

@ -0,0 +1,13 @@
import { useUser } from '../../user/userStore.ts'
import { useNavigate, Outlet } from 'react-router-dom'
export default function Protected() {
const [user] = useUser()
const navigate = useNavigate()
if (!user) {
navigate('/login')
}
return <Outlet />
}

View file

@ -0,0 +1,17 @@
import { useNavigate } from 'react-router-dom'
import { useMessageListener } from '../../../hooks/useMessageListener.ts'
import { PropsWithChildren } from 'react'
type UnauthorizedHandlerProps = unknown
export default function UnauthorizedHandler({
children,
}: PropsWithChildren<UnauthorizedHandlerProps>) {
const navigate = useNavigate()
useMessageListener('auth:unauthorized', async () => {
console.debug('unauth triggered')
navigate('/logout')
})
return <>{children}</>
}

View file

@ -0,0 +1,91 @@
import { useRef, useState, FormEvent } from 'react'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import TextInput from '../../../components/TextInput.tsx'
import PrimaryButton from '../../../components/PrimaryButton.tsx'
import { AuthService } from '../authService.ts'
import { useNavigate } from 'react-router-dom'
import SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
interface LoginPageProps {
authService: AuthService
}
export default function LoginPage({ authService }: LoginPageProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const usernameInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
const navigate = useNavigate()
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!username) {
setError("you didn't username D:<")
return
}
if (!password) {
setError("you didn't password D:<")
return
}
setError(null)
setIsSubmitting(true)
try {
await authService.login(username, password)
navigate('/')
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'something went terribly wrong')
} finally {
setIsSubmitting(false)
}
}
return (
<SingleColumnLayout>
<main className="w-full mx-auto p-4">
<div className="mt-12">
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
<div className="flex flex-col gap-1">
<label htmlFor="username" className="text-sm text-gray-600">
username
</label>
<TextInput
ref={usernameInputRef}
id="username"
value={username}
onInput={setUsername}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm text-gray-600">
password
</label>
<TextInput
ref={passwordInputRef}
type="password"
id="password"
value={password}
onInput={setPassword}
/>
</div>
<PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
{isSubmitting ? 'wait...' : 'make login pls'}
</PrimaryButton>
<SecondaryNavButton to={'/signup'}>register instead?</SecondaryNavButton>
<span className="text-xs h-3 text-red-500">{error}</span>
</form>
</div>
</main>
</SingleColumnLayout>
)
}

View file

@ -0,0 +1,22 @@
import { useNavigate } from 'react-router-dom'
import { AuthService } from '../authService.ts'
import { useEffect } from 'react'
interface LogoutPageProps {
authService: AuthService
}
export default function LogoutPage({ authService }: LogoutPageProps) {
const navigate = useNavigate()
useEffect(() => {
const timeout = setTimeout(async () => {
navigate('/login')
await authService.logout()
})
return () => clearTimeout(timeout)
}, [authService, navigate])
return <></>
}

View file

@ -0,0 +1,214 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react'
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
import TextInput from '../../../components/TextInput.tsx'
import PrimaryButton from '../../../components/PrimaryButton.tsx'
import PrimaryLinkButton from '../../../components/PrimaryLinkButton.tsx'
import { invalid, valid, Validation } from '../../../utils/validation.ts'
import { AuthService } from '../authService.ts'
import SecondaryNavButton from '../../../components/SecondaryNavButton.tsx'
const SignupCodeKey = 'signupCode'
interface SignupPageProps {
authService: AuthService
}
export default function SignupPage({ authService }: SignupPageProps) {
const { code } = useParams()
const [signupCode, setSignupCode] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [username, setUsername, usernameError, validateUsername] =
useValidatedInput(isValidUsername)
const [password, setPassword, passwordError, validatePassword] =
useValidatedInput(isValidPassword)
const userNameInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
const dialogRef = useRef<HTMLDialogElement | null>(null)
const navigate = useNavigate()
useEffect(() => {
if (signupCode) return
let theSignupCode: string | null
if (code) {
theSignupCode = code
setSignupCode(theSignupCode)
localStorage.setItem(SignupCodeKey, theSignupCode)
} else {
theSignupCode = localStorage.getItem(SignupCodeKey)
}
if (!theSignupCode) {
dialogRef.current?.showModal()
}
}, [code, signupCode])
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!signupCode) {
throw new Error("there's no code")
}
const isUsernameValid = validateUsername()
const isPasswordValid = validatePassword()
if (!isPasswordValid) {
passwordInputRef.current?.focus()
}
if (!isUsernameValid) {
userNameInputRef.current?.focus()
}
if (!isUsernameValid || !isPasswordValid) {
return
}
setIsSubmitting(true)
try {
await authService.signup(username, password, signupCode)
navigate('/')
} finally {
setIsSubmitting(false)
}
}
return (
<SingleColumnLayout>
<main className="w-full mx-auto p-4">
<div className="mt-12">
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
<FormInput
id="username"
value={username}
onInput={setUsername}
error={usernameError}
ref={userNameInputRef}
/>
<FormInput
id="password"
value={password}
onInput={setPassword}
error={passwordError}
type="password"
ref={passwordInputRef}
/>
<PrimaryButton
className="mt-4"
disabled={isSubmitting || !!usernameError || !!passwordError}
type="submit"
>
{isSubmitting ? 'wait...' : 'give me an account pls'}
</PrimaryButton>
<SecondaryNavButton to={'/login'}>login instead?</SecondaryNavButton>
</form>
</div>
</main>
<dialog
id="go-away-dialog"
ref={dialogRef}
className="p-6 rounded-lg shadow-lg m-auto outline-none"
>
<div className="text-gray-600 flex flex-col gap-2">
<h1 className={`font-bold text-lg`}>STOP !!!</h1>
<p>You need an invitation to sign up</p>
<p>
I'm surprised you even found your way here without one and honestly I'd prefer it if you
would leave
</p>
<p>
If you <span className="italic">do</span> want to create an account, you should know who
to contact
</p>
<PrimaryLinkButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
I'm sorry I'll go somewhere else :(
</PrimaryLinkButton>
</div>
</dialog>
</SingleColumnLayout>
)
}
interface FormInputProps {
id: string
value: string
onInput: (value: string) => void
error: string | null
type?: 'text' | 'password'
ref: Ref<HTMLInputElement>
}
function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm text-gray-600">
{id}
</label>
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
<span className="text-xs h-3 text-red-500">{error}</span>
</div>
)
}
type UseValidateInputReturn = [string, (value: string) => void, string | null, () => boolean]
function useValidatedInput(validator: (value: string) => Validation): UseValidateInputReturn {
const [value, setValue] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const [keepValidating, setKeepValidating] = useState(false)
const validate = useCallback(() => {
const { isValid, error } = validator(value)
if (isValid) {
setError(null)
} else {
// We only want to validate on input after they have invalidly submitted once.
// It's annoying if we set error messages before they've even finished typing.
setKeepValidating(true)
setError(error)
}
return isValid
}, [validator, value])
useEffect(() => {
if (keepValidating) {
validate()
}
}, [keepValidating, validate])
return [value, setValue, error, validate]
}
function isValidUsername(username: string): Validation {
if (!username) return invalid('you need to enter a username :/')
if (username.length < 3) {
return invalid('not long enough :(')
}
const usernameRegex = /^[a-zA-Z0-9_-]+$/
if (usernameRegex.test(username)) {
return valid()
} else {
return invalid("that's not a good username :'(")
}
}
function isValidPassword(password: string): Validation {
if (password.length >= 6) {
return valid()
} else {
return invalid("that isn't a good password :/")
}
}