New: Username and Password authentication

This commit is contained in:
2024-10-03 22:59:04 +02:00
parent 7c21d11e8d
commit 77974e2c61
18 changed files with 1376 additions and 24 deletions

View File

@ -5,7 +5,11 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import { AuthProvider, useAuth } from './auth'
import './index.css'
import './scaffold.css'
interface AppProps {
children?: ReactNode
@ -14,7 +18,9 @@ interface AppProps {
const App = ({ children }: AppProps) => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>{children}</RedwoodApolloProvider>
<AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}>{children}</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>
)

View File

@ -9,8 +9,15 @@
import { Router, Route } from '@redwoodjs/router'
import { useAuth } from './auth'
const Routes = () => {
return (
<Router useAuth={useAuth}>
<Route path="/login" page={LoginPage} name="login" />
<Route path="/signup" page={SignupPage} name="signup" />
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} />
</Router>

5
web/src/auth.ts Normal file
View File

@ -0,0 +1,5 @@
import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
const dbAuthClient = createDbAuthClient()
export const { AuthProvider, useAuth } = createAuth(dbAuthClient)

View File

@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react'
import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms'
import { navigate, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
const ForgotPasswordPage = () => {
const { isAuthenticated, forgotPassword } = useAuth()
useEffect(() => {
if (isAuthenticated) {
navigate(routes.home())
}
}, [isAuthenticated])
const usernameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
usernameRef?.current?.focus()
}, [])
const onSubmit = async (data: { username: string }) => {
const response = await forgotPassword(data.username)
if (response.error) {
toast.error(response.error)
} else {
// The function `forgotPassword.handler` in api/src/functions/auth.js has
// been invoked, let the user know how to get the link to reset their
// password (sent in email, perhaps?)
toast.success(
'A link to reset your password was sent to ' + response.email
)
navigate(routes.login())
}
}
return (
<>
<Metadata title="Forgot Password" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Forgot Password
</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<div className="text-left">
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">Submit</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ForgotPasswordPage

View File

@ -0,0 +1,143 @@
import { useEffect, useRef } from 'react'
import {
Form,
Label,
TextField,
PasswordField,
Submit,
FieldError,
} from '@redwoodjs/forms'
import { Link, navigate, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
const LoginPage = () => {
const { isAuthenticated, logIn } = useAuth()
useEffect(() => {
if (isAuthenticated) {
navigate(routes.home())
}
}, [isAuthenticated])
const usernameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
usernameRef.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" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">Login</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" className="rw-field-error" />
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Password
</Label>
<PasswordField
name="password"
className="rw-input"
errorClassName="rw-input rw-input-error"
autoComplete="current-password"
validation={{
required: {
value: true,
message: 'Password is required',
},
}}
/>
<div className="rw-forgot-link">
<Link
to={routes.forgotPassword()}
className="rw-forgot-link"
>
Forgot Password?
</Link>
</div>
<FieldError name="password" className="rw-field-error" />
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">Login</Submit>
</div>
</Form>
</div>
</div>
</div>
<div className="rw-login-link">
<span>Don&apos;t have an account?</span>{' '}
<Link to={routes.signup()} className="rw-link">
Sign up!
</Link>
</div>
</div>
<a
href={`https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${
process.env.MICROSOFT_OAUTH_CLIENT_ID
}&grant_type=authorization_code&response_type=code&redirect_uri=${
process.env.MICROSOFT_OAUTH_REDIRECT_URI
}&scope=${process.env.MICROSOFT_OAUTH_SCOPES.split(' ').join('+')}`}
className="mx-auto block w-48 rounded bg-gray-800 px-4 py-2 text-center text-xs font-semibold uppercase tracking-wide text-white"
>
Login with GitHub
</a>
</main>
</>
)
}
export default LoginPage

View File

@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import {
Form,
Label,
PasswordField,
Submit,
FieldError,
} from '@redwoodjs/forms'
import { navigate, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { toast, Toaster } 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" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Reset Password
</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<div className="text-left">
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
New Password
</Label>
<PasswordField
name="password"
autoComplete="new-password"
className="rw-input"
errorClassName="rw-input rw-input-error"
disabled={!enabled}
ref={passwordRef}
validation={{
required: {
value: true,
message: 'New Password is required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit
className="rw-button rw-button-blue"
disabled={!enabled}
>
Submit
</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ResetPasswordPage

View File

@ -0,0 +1,126 @@
import { useEffect, useRef } from 'react'
import {
Form,
Label,
TextField,
PasswordField,
FieldError,
Submit,
} from '@redwoodjs/forms'
import { Link, navigate, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { toast, Toaster } from '@redwoodjs/web/toast'
import { useAuth } from 'src/auth'
const SignupPage = () => {
const { isAuthenticated, signUp } = useAuth()
useEffect(() => {
if (isAuthenticated) {
navigate(routes.home())
}
}, [isAuthenticated])
// focus on username box on page load
const usernameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
usernameRef.current?.focus()
}, [])
const onSubmit = async (data: Record<string, string>) => {
const response = await signUp({
username: data.username,
password: data.password,
})
if (response.message) {
toast(response.message)
} else if (response.error) {
toast.error(response.error)
} else {
// user is signed in automatically
toast.success('Welcome!')
}
}
return (
<>
<Metadata title="Signup" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">Signup</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" className="rw-field-error" />
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Password
</Label>
<PasswordField
name="password"
className="rw-input"
errorClassName="rw-input rw-input-error"
autoComplete="current-password"
validation={{
required: {
value: true,
message: 'Password is required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">
Sign Up
</Submit>
</div>
</Form>
</div>
</div>
</div>
<div className="rw-login-link">
<span>Already have an account?</span>{' '}
<Link to={routes.login()} className="rw-link">
Log in!
</Link>
</div>
</div>
</main>
</>
)
}
export default SignupPage

397
web/src/scaffold.css Normal file
View 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;
}