Singular (admin) account system
This commit is contained in:
83
web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx
Normal file
83
web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { mdiAccount } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const { isAuthenticated, forgotPassword } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(routes.home())
|
||||
}, [isAuthenticated])
|
||||
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
emailRef?.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: { username: string }) => {
|
||||
toast.loading('Processing...', { id: `processing` })
|
||||
|
||||
const response = await forgotPassword(data.username)
|
||||
|
||||
if (response.error) toast.error(response.error, { id: `processing` })
|
||||
else {
|
||||
toast.success(
|
||||
`A link to reset your password was sent to ${response.email}`,
|
||||
{ id: `processing` }
|
||||
)
|
||||
|
||||
navigate(routes.login())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Forgot Password" />
|
||||
|
||||
<div className="mx-auto my-32 flex w-fit flex-wrap items-center justify-center rounded-xl bg-base-100 p-2">
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
<Label
|
||||
name="username"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="username"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiAccount} />
|
||||
</Label>
|
||||
<TextField
|
||||
name="username"
|
||||
className="grow"
|
||||
placeholder="Username"
|
||||
ref={emailRef}
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="username" className="text-sm text-error" />
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit className="btn btn-primary mx-auto">Submit</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPasswordPage
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import HomePage from './HomePage'
|
||||
|
||||
const meta: Meta<typeof HomePage> = {
|
||||
component: HomePage,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof HomePage>
|
||||
|
||||
export const Primary: Story = {}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import HomePage from './HomePage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<HomePage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
115
web/src/pages/LoginPage/LoginPage.tsx
Normal file
115
web/src/pages/LoginPage/LoginPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { mdiAccount, mdiKey } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import {
|
||||
Form,
|
||||
Label,
|
||||
TextField,
|
||||
PasswordField,
|
||||
Submit,
|
||||
FieldError,
|
||||
} from '@redwoodjs/forms'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
const LoginPage = () => {
|
||||
const { isAuthenticated, logIn } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(routes.home())
|
||||
}, [isAuthenticated])
|
||||
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
emailRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: Record<string, string>) => {
|
||||
const response = await logIn({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
if (response.message) toast(response.message)
|
||||
else if (response.error) toast.error(response.error)
|
||||
else toast.success('Welcome back!')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Login" />
|
||||
|
||||
<div className="mx-auto my-32 flex w-fit flex-wrap items-center justify-center rounded-xl bg-base-100 p-2">
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
<Label
|
||||
name="username"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="username"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiAccount} />
|
||||
</Label>
|
||||
<TextField
|
||||
name="username"
|
||||
className="grow"
|
||||
placeholder="Username"
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="username" className="text-sm text-error" />
|
||||
|
||||
<Label
|
||||
name="password"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="password"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiKey} />
|
||||
</Label>
|
||||
<PasswordField
|
||||
name="password"
|
||||
className="grow"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Must be 8 or more characters',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="password" className="text-sm text-error" />
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit className="btn btn-primary mx-auto">Log In</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -1,44 +1,12 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
|
||||
export default () => (
|
||||
<main>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html, body {
|
||||
margin: 0;
|
||||
}
|
||||
html * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
text-align: center;
|
||||
background-color: #E2E8F0;
|
||||
height: 100vh;
|
||||
}
|
||||
section {
|
||||
background-color: white;
|
||||
border-radius: 0.25rem;
|
||||
width: 32rem;
|
||||
padding: 1rem;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: #2D3748;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<section>
|
||||
<h1>
|
||||
<span>404 Page Not Found</span>
|
||||
</h1>
|
||||
</section>
|
||||
</main>
|
||||
<div className="flex h-screen items-center justify-center space-x-2 font-inter">
|
||||
<Link to={routes.home()} className="btn btn-primary">
|
||||
404
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)
|
||||
|
||||
119
web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx
Normal file
119
web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { mdiKey } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import {
|
||||
Form,
|
||||
Label,
|
||||
PasswordField,
|
||||
Submit,
|
||||
FieldError,
|
||||
} from '@redwoodjs/forms'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => {
|
||||
const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } =
|
||||
useAuth()
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(routes.home())
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const validateToken = async () => {
|
||||
const response = await validateResetToken(resetToken)
|
||||
if (response.error) {
|
||||
setEnabled(false)
|
||||
toast.error(response.error)
|
||||
} else {
|
||||
setEnabled(true)
|
||||
}
|
||||
}
|
||||
validateToken()
|
||||
}, [resetToken, validateResetToken])
|
||||
|
||||
const passwordRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
passwordRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: Record<string, string>) => {
|
||||
const response = await resetPassword({
|
||||
resetToken,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
toast.error(response.error)
|
||||
} else {
|
||||
toast.success('Password changed!')
|
||||
await reauthenticate()
|
||||
navigate(routes.login())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Reset Password" />
|
||||
|
||||
<div className="mx-auto my-32 flex w-fit flex-wrap items-center justify-center rounded-xl bg-base-100 p-2">
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
<Label
|
||||
name="password"
|
||||
className={`${
|
||||
!enabled ? 'input-disabled' : ''
|
||||
} input input-bordered flex items-center gap-2`}
|
||||
errorClassName={`${
|
||||
!enabled ? 'input-disabled' : ''
|
||||
} input input-bordered flex items-center gap-2 input-error`}
|
||||
>
|
||||
<Label
|
||||
name="password"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiKey} />
|
||||
</Label>
|
||||
<PasswordField
|
||||
name="password"
|
||||
className="grow"
|
||||
placeholder="New Password"
|
||||
ref={passwordRef}
|
||||
disabled={!enabled}
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Must be 8 or more characters',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="password" className="text-sm text-error" />
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit
|
||||
className={`btn btn-primary mx-auto ${
|
||||
!enabled ? 'btn-disabled' : ''
|
||||
}`}
|
||||
disabled={!enabled}
|
||||
>
|
||||
Submit
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPasswordPage
|
||||
163
web/src/pages/SignupPage/SignupPage.tsx
Normal file
163
web/src/pages/SignupPage/SignupPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { mdiEmail, mdiKey, mdiAccount } from '@mdi/js'
|
||||
import { Icon } from '@mdi/react'
|
||||
|
||||
import {
|
||||
Form,
|
||||
Label,
|
||||
TextField,
|
||||
PasswordField,
|
||||
Submit,
|
||||
FieldError,
|
||||
} from '@redwoodjs/forms'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { Metadata, useQuery } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
const QUERY = gql`
|
||||
query UserCount {
|
||||
userCount
|
||||
}
|
||||
`
|
||||
|
||||
const SignupPage = () => {
|
||||
const { data, loading } = useQuery(QUERY)
|
||||
const { isAuthenticated, signUp } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data.userCount >= 1) {
|
||||
toast.error('Account already exists')
|
||||
|
||||
console.log('here - redirect')
|
||||
console.log(data)
|
||||
navigate(routes.home())
|
||||
}
|
||||
}, [data, loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(routes.home())
|
||||
}, [isAuthenticated])
|
||||
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
emailRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (data: Record<string, string>) => {
|
||||
const response = await signUp({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
email: data.email.toLowerCase(),
|
||||
})
|
||||
|
||||
if (response.message) toast(response.message)
|
||||
else if (response.error) toast.error(response.error)
|
||||
else toast.success('Welcome!')
|
||||
}
|
||||
|
||||
if (loading) return <span className="loading loading-spinner loading-lg" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Sign Up" />
|
||||
|
||||
<div className="mx-auto my-32 flex w-fit flex-wrap items-center justify-center rounded-xl bg-base-100 p-2">
|
||||
<Form onSubmit={onSubmit} className="space-y-2">
|
||||
<Label
|
||||
name="username"
|
||||
className="input input-bordered input-disabled flex items-center gap-2"
|
||||
errorClassName="input input-bordered input-disabled flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="username"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiAccount} />
|
||||
</Label>
|
||||
<TextField
|
||||
name="username"
|
||||
className="grow"
|
||||
value="admin"
|
||||
readOnly
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
name="email"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="email"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiEmail} />
|
||||
</Label>
|
||||
<TextField
|
||||
name="email"
|
||||
className="grow"
|
||||
placeholder="Email"
|
||||
ref={emailRef}
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
pattern: {
|
||||
value: new RegExp(
|
||||
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
|
||||
),
|
||||
message: 'Invalid email',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="email" className="text-sm text-error" />
|
||||
|
||||
<Label
|
||||
name="password"
|
||||
className="input input-bordered flex items-center gap-2"
|
||||
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
||||
>
|
||||
<Label
|
||||
name="password"
|
||||
className="h-4 w-4 opacity-70"
|
||||
errorClassName="h-4 w-4 text-error"
|
||||
>
|
||||
<Icon path={mdiKey} />
|
||||
</Label>
|
||||
<PasswordField
|
||||
name="password"
|
||||
className="grow"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
validation={{
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required',
|
||||
},
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Must be 8 or more characters',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<FieldError name="password" className="text-sm text-error" />
|
||||
|
||||
<div className="flex w-full">
|
||||
<Submit className="btn btn-primary mx-auto">Create</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignupPage
|
||||
Reference in New Issue
Block a user