Singular (admin) account system
This commit is contained in:
@ -13,14 +13,15 @@
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@redwoodjs/forms": "7.7.3",
|
||||
"@redwoodjs/router": "7.7.3",
|
||||
"@redwoodjs/web": "7.7.3",
|
||||
"@redwoodjs/auth-dbauth-web": "7.7.4",
|
||||
"@redwoodjs/forms": "7.7.4",
|
||||
"@redwoodjs/router": "7.7.4",
|
||||
"@redwoodjs/web": "7.7.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "7.7.3",
|
||||
"@redwoodjs/vite": "7.7.4",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
@ -4,14 +4,19 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
|
||||
import FatalErrorPage from 'src/pages/FatalErrorPage'
|
||||
import Routes from 'src/Routes'
|
||||
|
||||
import './scaffold.css'
|
||||
import { AuthProvider, useAuth } from './auth'
|
||||
|
||||
import './index.css'
|
||||
|
||||
const App = () => (
|
||||
<FatalErrorBoundary page={FatalErrorPage}>
|
||||
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
|
||||
<RedwoodApolloProvider>
|
||||
<Routes />
|
||||
</RedwoodApolloProvider>
|
||||
<AuthProvider>
|
||||
<RedwoodApolloProvider useAuth={useAuth}>
|
||||
<Routes />
|
||||
</RedwoodApolloProvider>
|
||||
</AuthProvider>
|
||||
</RedwoodProvider>
|
||||
</FatalErrorBoundary>
|
||||
)
|
||||
|
@ -1,19 +1,28 @@
|
||||
// In this file, all Page components from 'src/pages` are auto-imported. Nested
|
||||
// directories are supported, and should be uppercase. Each subdirectory will be
|
||||
// prepended onto the component name.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// 'src/pages/HomePage/HomePage.js' -> HomePage
|
||||
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
|
||||
|
||||
import { Router, Route, Set } from '@redwoodjs/router'
|
||||
|
||||
import { useAuth } from './auth'
|
||||
import AccountbarLayout from './layouts/AccountbarLayout/AccountbarLayout'
|
||||
import NavbarLayout from './layouts/NavbarLayout/NavbarLayout'
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Router useAuth={useAuth}>
|
||||
<Set wrap={AccountbarLayout} title="Create Admin Account">
|
||||
<Route path="/create-admin" page={SignupPage} name="signup" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={AccountbarLayout} title="Login">
|
||||
<Route path="/login" page={LoginPage} name="login" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={AccountbarLayout} title="Forgot Password">
|
||||
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={AccountbarLayout} title="Reset Password">
|
||||
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
</Set>
|
||||
|
5
web/src/auth.ts
Normal file
5
web/src/auth.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
|
||||
|
||||
const dbAuthClient = createDbAuthClient()
|
||||
|
||||
export const { AuthProvider, useAuth } = createAuth(dbAuthClient)
|
@ -1,26 +0,0 @@
|
||||
// Pass props to your component by passing an `args` object to your story
|
||||
//
|
||||
// ```tsx
|
||||
// export const Primary: Story = {
|
||||
// args: {
|
||||
// propName: propValue
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See https://storybook.js.org/docs/react/writing-stories/args.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const meta: Meta<typeof ThemeToggle> = {
|
||||
component: ThemeToggle,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof ThemeToggle>
|
||||
|
||||
export const Primary: Story = {}
|
@ -1,14 +0,0 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ThemeToggle />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
@ -9,11 +9,8 @@ const ThemeToggle = () => {
|
||||
)
|
||||
|
||||
const handleToggle = (e) => {
|
||||
if (e.target.checked) {
|
||||
setTheme('dark')
|
||||
} else {
|
||||
setTheme('light')
|
||||
}
|
||||
if (e.target.checked) setTheme('dark')
|
||||
else setTheme('light')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,26 +0,0 @@
|
||||
// Pass props to your component by passing an `args` object to your story
|
||||
//
|
||||
// ```tsx
|
||||
// export const Primary: Story = {
|
||||
// args: {
|
||||
// propName: propValue
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See https://storybook.js.org/docs/react/writing-stories/args.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import ToastNotification from './ToastNotification'
|
||||
|
||||
const meta: Meta<typeof ToastNotification> = {
|
||||
component: ToastNotification,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof ToastNotification>
|
||||
|
||||
export const Primary: Story = {}
|
@ -1,14 +0,0 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import ToastNotification from './ToastNotification'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('Toast', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ToastNotification />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
@ -37,22 +37,24 @@ const ToastNotification = ({ t, type, message }: Props) => {
|
||||
<div
|
||||
className={`${
|
||||
t.visible ? 'animate-enter' : 'animate-leave'
|
||||
} pointer-events-auto flex w-full max-w-56 items-center space-x-2 rounded-xl bg-base-200 p-2 shadow-xl ${
|
||||
} pointer-events-auto flex w-full max-w-64 items-center space-x-2 rounded-xl bg-base-200 p-2 shadow-xl ${
|
||||
type === 'blank' ? 'first:space-x-0' : ''
|
||||
}`}
|
||||
>
|
||||
{iconElement}
|
||||
<p className="w-full font-inter">{message}</p>
|
||||
<div className="flex flex-col items-center">{iconElement}</div>
|
||||
<div className="flex-1">
|
||||
<p className="hyphens-auto break-words font-inter">{message}</p>
|
||||
</div>
|
||||
{type !== 'loading' ? (
|
||||
<button
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
className="btn btn-ghost btn-xs"
|
||||
>
|
||||
<Icon path={mdiClose} className="w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
className="btn btn-ghost btn-xs"
|
||||
>
|
||||
<Icon path={mdiClose} className="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
20
web/src/components/ToasterWrapper/ToasterWrapper.tsx
Normal file
20
web/src/components/ToasterWrapper/ToasterWrapper.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Toaster } from '@redwoodjs/web/dist/toast'
|
||||
|
||||
import ToastNotification from '../ToastNotification/ToastNotification'
|
||||
|
||||
const ToasterWrapper = () => (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
containerClassName="-mr-2 mt-16"
|
||||
containerStyle={{
|
||||
zIndex: 50, // "z-50" does not work
|
||||
}}
|
||||
gutter={8}
|
||||
>
|
||||
{(t) => (
|
||||
<ToastNotification t={t} type={t.type} message={t.message.toString()} />
|
||||
)}
|
||||
</Toaster>
|
||||
)
|
||||
|
||||
export default ToasterWrapper
|
@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import AccountbarLayout from './AccountbarLayout'
|
||||
|
||||
const meta: Meta<typeof AccountbarLayout> = {
|
||||
component: AccountbarLayout,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof AccountbarLayout>
|
||||
|
||||
export const Primary: Story = {}
|
@ -1,14 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import HomePage from './HomePage'
|
||||
import AccountbarLayout from './AccountbarLayout'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('HomePage', () => {
|
||||
describe('AccountbarLayout', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<HomePage />)
|
||||
render(<AccountbarLayout />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
35
web/src/layouts/AccountbarLayout/AccountbarLayout.tsx
Normal file
35
web/src/layouts/AccountbarLayout/AccountbarLayout.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
import ToasterWrapper from 'src/components/ToasterWrapper/ToasterWrapper'
|
||||
|
||||
type AccountbarLayoutProps = {
|
||||
title: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const AccountbarLayout = ({ title, children }: AccountbarLayoutProps) => {
|
||||
return (
|
||||
<>
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<div className="navbar rounded-xl bg-base-300 shadow-xl">
|
||||
<div className="navbar-start">
|
||||
<p className="btn btn-ghost font-syne text-xl sm:hidden">{title}</p>
|
||||
</div>
|
||||
<div className="navbar-center">
|
||||
<p className="btn btn-ghost hidden font-syne text-xl sm:flex">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-inter">
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountbarLayout
|
@ -1,11 +1,11 @@
|
||||
import { mdiMenu } from '@mdi/js'
|
||||
import { mdiMenu, mdiLogout } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Toaster } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
import ToastNotification from 'src/components/ToastNotification/ToastNotification'
|
||||
import ToasterWrapper from 'src/components/ToasterWrapper/ToasterWrapper'
|
||||
|
||||
interface NavbarRoute {
|
||||
name: string
|
||||
@ -17,6 +17,8 @@ type NavbarLayoutProps = {
|
||||
}
|
||||
|
||||
const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
// TODO: populate with buttons to other page
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
{
|
||||
@ -34,22 +36,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
containerClassName="-mr-2 mt-16"
|
||||
containerStyle={{
|
||||
zIndex: 50, // "z-50" does not work
|
||||
}}
|
||||
gutter={8}
|
||||
>
|
||||
{(t) => (
|
||||
<ToastNotification
|
||||
t={t}
|
||||
type={t.type}
|
||||
message={t.message.toString()}
|
||||
/>
|
||||
)}
|
||||
</Toaster>
|
||||
<ToasterWrapper />
|
||||
<div className="sticky top-0 z-50 p-2">
|
||||
<div className="navbar rounded-xl bg-base-300 shadow-xl">
|
||||
<div className="navbar-start space-x-2 lg:first:space-x-0">
|
||||
@ -76,7 +63,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
to={routes.home()}
|
||||
className="btn btn-ghost hidden font-syne text-xl sm:flex"
|
||||
>
|
||||
Ahmed Al-Taiar
|
||||
{process.env.NAME}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar-center">
|
||||
@ -84,13 +71,20 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
to={routes.home()}
|
||||
className="btn btn-ghost font-syne text-xl sm:hidden"
|
||||
>
|
||||
Ahmed Al-Taiar
|
||||
{process.env.NAME}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar-center hidden lg:flex">
|
||||
<div className="space-x-2 font-syne">{navbarButtons()}</div>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<div className="navbar-end space-x-2">
|
||||
{isAuthenticated ? (
|
||||
<button className="btn btn-square btn-ghost" onClick={logOut}>
|
||||
<Icon path={mdiLogout} className="h-8 w-8" />
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
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 = {}
|
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
|
397
web/src/scaffold.css
Normal file
397
web/src/scaffold.css
Normal file
@ -0,0 +1,397 @@
|
||||
/*
|
||||
normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
|
||||
*/
|
||||
|
||||
.rw-scaffold *,
|
||||
.rw-scaffold ::after,
|
||||
.rw-scaffold ::before {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
.rw-scaffold main {
|
||||
color: #4a5568;
|
||||
display: block;
|
||||
}
|
||||
.rw-scaffold h1,
|
||||
.rw-scaffold h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.rw-scaffold a {
|
||||
background-color: transparent;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.rw-scaffold input {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold input::-ms-input-placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/*
|
||||
Style
|
||||
*/
|
||||
|
||||
.rw-scaffold,
|
||||
.rw-toast {
|
||||
background-color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
.rw-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem 1rem 2rem;
|
||||
}
|
||||
.rw-main {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.rw-segment {
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
scrollbar-color: #a1a1aa transparent;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar {
|
||||
height: initial;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
border-color: #e2e8f0;
|
||||
border-style: solid;
|
||||
border-radius: 0 0 10px 10px;
|
||||
border-width: 1px 0 0 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-thumb {
|
||||
background-color: #a1a1aa;
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rw-segment-header {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.rw-segment-main {
|
||||
background-color: #f7fafc;
|
||||
padding: 1rem;
|
||||
}
|
||||
.rw-link {
|
||||
color: #4299e1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rw-link:hover {
|
||||
color: #2b6cb0;
|
||||
}
|
||||
.rw-forgot-link {
|
||||
font-size: 0.75rem;
|
||||
color: #a0aec0;
|
||||
text-align: right;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.rw-forgot-link:hover {
|
||||
font-size: 0.75rem;
|
||||
color: #4299e1;
|
||||
}
|
||||
.rw-heading {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-heading.rw-heading-primary {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.rw-heading.rw-heading-secondary {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.rw-heading .rw-link {
|
||||
color: #4a5568;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rw-heading .rw-link:hover {
|
||||
color: #1a202c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rw-cell-error {
|
||||
font-size: 90%;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-form-wrapper {
|
||||
box-sizing: border-box;
|
||||
font-size: 0.875rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
.rw-cell-error,
|
||||
.rw-form-error-wrapper {
|
||||
padding: 1rem;
|
||||
background-color: #fff5f5;
|
||||
color: #c53030;
|
||||
border-width: 1px;
|
||||
border-color: #feb2b2;
|
||||
border-radius: 0.25rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.rw-form-error-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-form-error-list {
|
||||
margin-top: 0.5rem;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.rw-button {
|
||||
border: none;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 1rem;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.025em;
|
||||
border-radius: 0.25rem;
|
||||
line-height: 2;
|
||||
border: 0;
|
||||
}
|
||||
.rw-button:hover {
|
||||
background-color: #718096;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-small {
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.125rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
.rw-button.rw-button-green {
|
||||
background-color: #48bb78;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-green:hover {
|
||||
background-color: #38a169;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-blue {
|
||||
background-color: #3182ce;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-blue:hover {
|
||||
background-color: #2b6cb0;
|
||||
}
|
||||
.rw-button.rw-button-red {
|
||||
background-color: #e53e3e;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-red:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
.rw-button-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.rw-button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.75rem 0.5rem;
|
||||
}
|
||||
.rw-button-group .rw-button {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
.rw-form-wrapper .rw-button-group {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rw-label {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.rw-label.rw-label-error {
|
||||
color: #c53030;
|
||||
}
|
||||
.rw-input {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border-radius: 0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
.rw-check-radio-item-none {
|
||||
color: #4a5568;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
}
|
||||
.rw-input[type='checkbox'] {
|
||||
display: inline;
|
||||
width: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rw-input[type='radio'] {
|
||||
display: inline;
|
||||
width: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rw-input:focus {
|
||||
border-color: #a0aec0;
|
||||
}
|
||||
.rw-input-error {
|
||||
border-color: #c53030;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.rw-input-error:focus {
|
||||
outline: none;
|
||||
border-color: #c53030;
|
||||
box-shadow: 0 0 5px #c53030;
|
||||
}
|
||||
|
||||
.rw-field-error {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #c53030;
|
||||
}
|
||||
.rw-table-wrapper-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rw-table-wrapper-responsive .rw-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
.rw-table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.rw-table th,
|
||||
.rw-table td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.rw-table td {
|
||||
background-color: #ffffff;
|
||||
color: #1a202c;
|
||||
}
|
||||
.rw-table tr:nth-child(odd) td,
|
||||
.rw-table tr:nth-child(odd) th {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
.rw-table thead tr {
|
||||
color: #4a5568;
|
||||
}
|
||||
.rw-table th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.rw-table thead th {
|
||||
background-color: #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
.rw-table tbody th {
|
||||
text-align: right;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.rw-table tbody th {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
.rw-table tbody tr {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
.rw-table input {
|
||||
margin-left: 0;
|
||||
}
|
||||
.rw-table-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 17px;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.rw-table-actions .rw-button {
|
||||
background-color: transparent;
|
||||
}
|
||||
.rw-table-actions .rw-button:hover {
|
||||
background-color: #718096;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue {
|
||||
color: #3182ce;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue:hover {
|
||||
background-color: #3182ce;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-table-actions .rw-button-red {
|
||||
color: #e53e3e;
|
||||
}
|
||||
.rw-table-actions .rw-button-red:hover {
|
||||
background-color: #e53e3e;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.rw-login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24rem;
|
||||
margin: 4rem auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rw-login-container .rw-form-wrapper {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.rw-login-link {
|
||||
margin-top: 1rem;
|
||||
color: #4a5568;
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.rw-webauthn-wrapper {
|
||||
margin: 1.5rem 1rem 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.rw-webauthn-wrapper h2 {
|
||||
font-size: 150%;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
Reference in New Issue
Block a user