Auth UI integration with email & polish (#1107)

This commit is contained in:
Mihovil Ilakovac 2023-04-06 20:29:05 +02:00 committed by GitHub
parent 809eb90386
commit 5be9d1fa5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 858 additions and 614 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,47 +0,0 @@
{{={= =}=}}
import { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { login } from '../actions/login';
import { errorMessage } from '../../../utils.js'
export const LoginForm = () => {
const history = useHistory()
const [emailFieldVal, setEmailFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleLogin = async (event) => {
event.preventDefault()
try {
await login({
email: emailFieldVal,
password: passwordFieldVal,
})
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleLogin} className="login-form auth-form">
<h2>Email</h2>
<input
type="email"
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Log in"/>
</div>
</form>
)
}

View File

@ -1,53 +0,0 @@
{{={= =}=}}
import { useState } from 'react'
import { useHistory } from 'react-router-dom'
import { signup } from '../actions/signup'
import { login } from '../actions/login'
import { errorMessage } from '../../../utils.js'
export const SignupForm = () => {
const history = useHistory()
const [emailFieldVal, setEmailFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleSignup = async (event) => {
event.preventDefault()
try {
await signup({ email: emailFieldVal, password: passwordFieldVal })
await login ({
email: emailFieldVal,
password: passwordFieldVal,
})
setEmailFieldVal('')
setPasswordFieldVal('')
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
return (
<form onSubmit={handleSignup} className='signup-form auth-form'>
<h2>Email</h2>
<input
type="email"
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
/>
<h2>Password</h2>
<input
type="password"
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
/>
<div>
<input type="submit" value="Sign up"/>
</div>
</form>
)
}

View File

@ -3,6 +3,3 @@ export { signup } from './actions/signup';
export { requestPasswordReset } from './actions/passwordReset';
export { resetPassword } from './actions/passwordReset';
export { verifyEmail } from './actions/verifyEmail';
export { SignupForm } from './components/Signup';
export { LoginForm } from './components/Login';

View File

@ -1,23 +1,26 @@
{{={= =}=}}
import React, { useState, useEffect } from 'react'
import { useHistory } from 'react-router-dom'
import { createStitches, createTheme } from '@stitches/react'
import { useState, FormEvent, useEffect, useCallback } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { createTheme } from '@stitches/react'
import { errorMessage } from '../../utils.js'
{=# isUsernameAndPasswordAuthEnabled =}
import signup from '../signup.js'
import login from '../login.js'
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
import { signup } from '../email/actions/signup.js'
import { login } from '../email/actions/login.js'
import { requestPasswordReset, resetPassword } from '../email/actions/passwordReset.js'
import { verifyEmail } from '../email/actions/verifyEmail.js'
{=/ isEmailAuthEnabled =}
{=# isExternalAuthEnabled =}
import * as SocialIcons from './SocialIcons'
import { SocialButton } from './SocialButton';
{=/ isExternalAuthEnabled =}
import config from '../../config.js'
import { styled, css } from '../../stitches.config'
const socialButtonsContainerStyle = {
maxWidth: '20rem'
}
import { styled } from '../../stitches.config'
import { State, CustomizationOptions } from './types'
const logoStyle = {
height: '3rem'
@ -36,7 +39,6 @@ const HeaderText = styled('h2', {
const SocialAuth = styled('div', {
marginTop: '1.5rem'
})
const SocialAuthLabel = styled('div', {
@ -73,31 +75,6 @@ const SocialAuthButtons = styled('div', {
}
})
const SocialButton = styled('a', {
display: 'flex',
justifyContent: 'center',
cursor: 'pointer',
// NOTE(matija): icon is otherwise blue, since that
// is link's default font color.
color: 'inherit',
backgroundColor: '#f0f0f0',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$gray600',
fontSize: '13px',
padding: '10px 15px',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:visited': {
color: 'inherit',
},
'&:hover': {
backgroundColor: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})
const OrContinueWith = styled('div', {
position: 'relative',
marginTop: '1.5rem'
@ -160,6 +137,13 @@ const FormInput = styled('input', {
borderColor: '$gray700',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
borderRadius: '0.375rem',
width: '100%',
@ -175,7 +159,6 @@ const SubmitButton = styled('button', {
justifyContent: 'center',
width: '100%',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$brand',
backgroundColor: '$brand',
@ -194,39 +177,90 @@ const SubmitButton = styled('button', {
backgroundColor: '$brandAccent',
borderColor: '$brandAccent',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})
const Message = styled('div', {
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
marginTop: '1rem',
background: '$gray400',
})
const ErrorMessage = styled(Message, {
background: '$errorBackground',
color: '$errorText',
})
const SuccessMessage = styled(Message, {
background: '$successBackground',
color: '$successText',
})
{=# isGoogleAuthEnabled =}
const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
{=/ isGoogleAuthEnabled =}
{=# isGitHubAuthEnabled =}
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
{=/ isGitHubAuthEnabled =}
// TODO(matija): introduce type for appearance
const Auth = ({ isLogin, appearance, logo, socialLayout } :
{ isLogin: boolean; logo: string; socialLayout: "horizontal" | "vertical" }) => {
const history = useHistory()
const [usernameFieldVal, setUsernameFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleSubmit = async (event) => {
event.preventDefault()
try {
if (!isLogin) {
await signup({ username: usernameFieldVal, password: passwordFieldVal })
}
await login (usernameFieldVal, passwordFieldVal)
setUsernameFieldVal('')
setPasswordFieldVal('')
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
state: State;
} & CustomizationOptions) {
const isLogin = state === "login";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
{=# isAnyPasswordBasedAuthEnabled =}
const history = useHistory();
const onErrorHandler = (error) => {
setErrorMessage(error.message)
};
{=/ isAnyPasswordBasedAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({
isLogin,
onError: onErrorHandler,
onSuccess() {
// Redirect to configured page, defaults to /.
history.push('{= onAuthSucceededRedirectTo =}')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
},
});
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({
isLogin,
onError: onErrorHandler,
showEmailVerificationPending() {
setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
},
onLoginSuccess() {
// Redirect to configured page, defaults to /.
history.push('{= onAuthSucceededRedirectTo =}')
}
});
{=/ isEmailAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
async function onSubmit (event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsLoading(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
await handleSubmit();
} finally {
setIsLoading(false);
}
}
{=/ isAnyPasswordBasedAuthEnabled =}
// TODO(matija): this is called on every render, is it a problem?
// If we do it in useEffect(), then there is a glitch between the default color and the
@ -234,19 +268,18 @@ const Auth = ({ isLogin, appearance, logo, socialLayout } :
const customTheme = createTheme(appearance)
const cta = isLogin ? 'Log in' : 'Sign up'
const title = isLogin ? 'Log in to your account' : 'Create a new account'
const titles: Record<State, string> = {
login: 'Log in to your account',
signup: 'Create a new account',
"forgot-password": "Forgot your password?",
"reset-password": "Reset your password",
"verify-email": "Email verification",
}
const title = titles[state]
const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
return (
<Container className={customTheme}>
<div>
{logo && (
<img style={logoStyle} src={logo} alt='Your Company' />
)}
<HeaderText>{title}</HeaderText>
</div>
const loginSignupForm = (<>
{=# isExternalAuthEnabled =}
<SocialAuth>
<SocialAuthLabel>{cta} with</SocialAuthLabel>
@ -262,7 +295,7 @@ const Auth = ({ isLogin, appearance, logo, socialLayout } :
</SocialAuth>
{=/ isExternalAuthEnabled =}
{=# areBothExternalAndUsernameAndPasswordAuthEnabled =}
{=# areBothSocialAndPasswordBasedAuthEnabled =}
<OrContinueWith>
<OrContinueWithLineContainer>
<OrContinueWithLine/>
@ -271,36 +304,298 @@ const Auth = ({ isLogin, appearance, logo, socialLayout } :
<OrContinueWithText>Or continue with</OrContinueWithText>
</OrContinueWithTextContainer>
</OrContinueWith>
{=/ areBothExternalAndUsernameAndPasswordAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
<UserPassForm onSubmit={handleSubmit}>
{=/ areBothSocialAndPasswordBasedAuthEnabled =}
{=# isAnyPasswordBasedAuthEnabled =}
<UserPassForm onSubmit={onSubmit}>
{=# isUsernameAndPasswordAuthEnabled =}
<FormItemGroup>
<FormLabel>Username</FormLabel>
<FormInput
type="text"
required
value={usernameFieldVal}
onChange={e => setUsernameFieldVal(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
type="email"
required
value={emailFieldVal}
onChange={e => setEmailFieldVal(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
{=/ isEmailAuthEnabled =}
<FormItemGroup>
<FormLabel>Password</FormLabel>
<FormInput
type="password"
required
value={passwordFieldVal}
onChange={e => setPasswordFieldVal(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
<FormItemGroup>
<SubmitButton type="submit">{cta}</SubmitButton>
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
</FormItemGroup>
</UserPassForm>
{=/ isUsernameAndPasswordAuthEnabled =}
{=/ isAnyPasswordBasedAuthEnabled =}
</>)
return (
<Container className={customTheme}>
<div>
{logo && (
<img style={logoStyle} src={logo} alt='Your Company' />
)}
<HeaderText>{title}</HeaderText>
</div>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
{successMessage && <SuccessMessage>{successMessage}</SuccessMessage>}
{(state === 'login' || state === 'signup') && loginSignupForm}
{=# isEmailAuthEnabled =}
{state === 'forgot-password' && (<ForgotPasswordForm
isLoading={isLoading}
setIsLoading={setIsLoading}
setErrorMessage={setErrorMessage}
setSuccessMessage={setSuccessMessage}
/>)}
{state === 'reset-password' && (<ResetPasswordForm
isLoading={isLoading}
setIsLoading={setIsLoading}
setErrorMessage={setErrorMessage}
setSuccessMessage={setSuccessMessage}
/>)}
{state === 'verify-email' && (<VerifyEmailForm
isLoading={isLoading}
setIsLoading={setIsLoading}
setErrorMessage={setErrorMessage}
setSuccessMessage={setSuccessMessage}
/>)}
{=/ isEmailAuthEnabled =}
</Container>
)
}
export default Auth
export default Auth;
{=# isEmailAuthEnabled =}
const ForgotPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const [email, setEmail] = useState('')
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
setErrorMessage(null)
setSuccessMessage(null)
try {
await requestPasswordReset({ email })
setSuccessMessage('Check your email for a password reset link.')
setEmail('')
} catch (error) {
setErrorMessage(error.message)
} finally {
setIsLoading(false)
}
}
return (
<>
<UserPassForm onSubmit={onSubmit}>
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
<FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}>Send password reset email</SubmitButton>
</FormItemGroup>
</UserPassForm>
</>
)
}
const ResetPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const location = useLocation()
const token = new URLSearchParams(location.search).get('token')
const [password, setPassword] = useState('')
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!token) {
setErrorMessage('The token is missing from the URL. Please check the link you received in your email.')
return
}
if (!password || password !== passwordConfirmation) {
setErrorMessage("Passwords don't match!")
return
}
setIsLoading(true)
setErrorMessage(null)
setSuccessMessage(null)
try {
await resetPassword({ password, token })
setSuccessMessage('Your password has been reset.')
setPassword('')
setPasswordConfirmation('')
} catch (error) {
setErrorMessage(error.message)
} finally {
setIsLoading(false)
}
}
return (
<>
<UserPassForm onSubmit={onSubmit}>
<FormItemGroup>
<FormLabel>New password</FormLabel>
<FormInput
type="password"
required
value={password}
onChange={e => setPassword(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
<FormItemGroup>
<FormLabel>Confirm new password</FormLabel>
<FormInput
type="password"
required
value={passwordConfirmation}
onChange={e => setPasswordConfirmation(e.target.value)}
disabled={isLoading}
/>
</FormItemGroup>
<FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}>Reset password</SubmitButton>
</FormItemGroup>
</UserPassForm>
</>
)
}
const VerifyEmailForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const location = useLocation()
const token = new URLSearchParams(location.search).get('token')
const submitForm = useCallback(async () => {
if (!token) {
setErrorMessage('The token is missing from the URL. Please check the link you received in your email.')
return
}
setIsLoading(true)
setErrorMessage(null)
setSuccessMessage(null)
try {
await verifyEmail({ token })
setSuccessMessage('Your email has been verified. You can now log in.')
} catch (error) {
setErrorMessage(error.message)
} finally {
setIsLoading(false)
}
})
useEffect(() => {
submitForm()
}, [location])
return (
<>
{isLoading && <Message>Verifying email...</Message>}
</>
)
}
{=/ isEmailAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
function useUsernameAndPassword({
onError,
onSuccess,
isLogin,
}: {
onError: (error: Error) => void;
onSuccess: () => void;
isLogin: boolean;
}) {
const [usernameFieldVal, setUsernameFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
async function handleSubmit () {
try {
if (!isLogin) {
await signup({ username: usernameFieldVal, password: passwordFieldVal })
}
await login (usernameFieldVal, passwordFieldVal)
setUsernameFieldVal('')
setPasswordFieldVal('')
onSuccess()
} catch (err: unknown) {
onError(err as Error)
}
}
return { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal }
}
{=/ isUsernameAndPasswordAuthEnabled =}
{=# isEmailAuthEnabled =}
function useEmail({
onError,
showEmailVerificationPending,
onLoginSuccess,
isLogin,
}: {
onError: (error: Error) => void;
showEmailVerificationPending: () => void;
onLoginSuccess: () => void;
isLogin: boolean;
}) {
const [emailFieldVal, setEmailFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
async function handleSubmit () {
try {
if (isLogin) {
await login({ email: emailFieldVal, password: passwordFieldVal })
onLoginSuccess()
} else {
await signup({ email: emailFieldVal, password: passwordFieldVal })
{=# isEmailVerificationRequired =}
showEmailVerificationPending()
{=/ isEmailVerificationRequired =}
{=^ isEmailVerificationRequired =}
await login ({ email: emailFieldVal, password: passwordFieldVal})
onLoginSuccess()
{=/ isEmailVerificationRequired =}
}
setEmailFieldVal('')
setPasswordFieldVal('')
} catch (err: unknown) {
onError(err as Error)
}
}
return { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal }
}
{=/ isEmailAuthEnabled =}

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function ForgotPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.ForgotPassword}
/>
)
}

View File

@ -1,15 +0,0 @@
{{={= =}=}}
import Auth from './Auth'
const LoginForm = ({ appearance, logo, socialLayout }) => {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
isLogin={true}
/>
)
}
export default LoginForm

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function LoginForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Login}
/>
)
}

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function ResetPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.ResetPassword}
/>
)
}

View File

@ -1,17 +0,0 @@
{{={= =}=}}
import Auth from './Auth'
const SignupForm = ({ appearance, logo, socialLayout }) => {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
isLogin={false}
/>
)
}
export default SignupForm

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function SignupForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Signup}
/>
)
}

View File

@ -0,0 +1,27 @@
import { styled } from '../../stitches.config'
export const SocialButton = styled('a', {
display: 'flex',
justifyContent: 'center',
cursor: 'pointer',
// NOTE(matija): icon is otherwise blue, since that
// is link's default font color.
color: 'inherit',
backgroundColor: '#f0f0f0',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$gray600',
fontSize: '13px',
padding: '0.5rem 0.75rem',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:visited': {
color: 'inherit',
},
'&:hover': {
backgroundColor: '$gray500',
color: 'inherit',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})

View File

@ -8,11 +8,11 @@ const defaultStyles = css({
export const Google = () => (
<svg
className={defaultStyles()}
ariaHidden="true"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 24 24">
<g id="brand" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="google" fill="#000000" fill-rule="nonzero">
<g id="google" fill="#000000" fillRule="nonzero">
<path d="M11.99,13.9 L11.99,10.18 L21.35,10.18 C21.49,10.81 21.6,11.4 21.6,12.23 C21.6,17.94 17.77,22 12,22 C6.48,22 2,17.52 2,12 C2,6.48 6.48,2 12,2 C14.7,2 16.96,2.99 18.69,4.61 L15.85,7.37 C15.13,6.69 13.87,5.89 12,5.89 C8.69,5.89 5.99,8.64 5.99,12.01 C5.99,15.38 8.69,18.13 12,18.13 C15.83,18.13 17.24,15.48 17.5,13.91 L11.99,13.91 L11.99,13.9 Z" id="Shape">
</path>
</g>
@ -23,7 +23,7 @@ export const Google = () => (
export const GitHub = () => (
<svg
className={defaultStyles()}
ariaHidden="true"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20">
<path fillRule="evenodd"

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function VerifyEmailForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.VerifyEmail}
/>
)
}

View File

@ -0,0 +1,15 @@
import { createTheme } from '@stitches/react'
export enum State {
Login = "login",
Signup = "signup",
ForgotPassword = "forgot-password",
ResetPassword = "reset-password",
VerifyEmail = "verify-email",
}
export type CustomizationOptions = {
logo?: string;
socialLayout?: "horizontal" | "vertical";
appearance?: Parameters<typeof createTheme>[0];
}

View File

@ -1,45 +1,15 @@
{{={= =}=}}
import config from '../../config.js'
import { SocialButton } from '../forms/SocialButton'
import * as SocialIcons from '../forms/SocialIcons'
export const signInUrl = `${config.apiUrl}{= signInPath =}`
export const logoUrl = '/images/{= iconName =}'
const containerStyle = {
border: '2px solid #cbd5e1',
margin: 0,
cursor: 'pointer',
borderRadius: '.375rem',
backgroundColor: '#f8fafc',
fontWeight: 600,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
outline: '2px solid transparent',
outlineOffset: '2px',
}
const linkStyle = {
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
color: '#1e293b',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
paddingTop: '.75rem',
paddingBottom: '.75rem',
}
const logoStyle = {
maxHeight: '24px',
marginRight: '0.75rem'
}
export function SignInButton() {
return (
<div style={containerStyle}>
<a href={signInUrl} style={linkStyle}>
<img alt="{= displayName =} Icon" src={logoUrl} style={logoStyle} />
<span>Log in with {= displayName =}</span>
</a>
</div>
<SocialButton href={signInUrl}>
<SocialIcons.{= displayName =} />
</SocialButton>
)
}

View File

@ -11,11 +11,18 @@ export const {
gray600: '#d1d5db',
gray500: 'gainsboro',
gray400: '#f0f0f0',
red: '#FED7D7',
green: '#C6F6D5',
brand: '$waspYellow',
brandAccent: '#ffdb46',
errorBackground: '$red',
errorText: '#2D3748',
successBackground: '$green',
successText: '#2D3748',
submitButtonText: 'black',
submitButtonText: 'black'
},
fontSizes: {
sm: '0.875rem'

View File

@ -137,7 +137,9 @@ async function sendEmailAndLogTimestamp(
} catch (e) {
rethrowError(e);
}
await emailSender.send(content);
emailSender.send(content).catch((e) => {
console.error(`Failed to send email for ${field}`, e);
});
}
export function isEmailResendAllowed(

View File

@ -76,7 +76,6 @@ waspComplexTest/.wasp/out/web-app/index.html
waspComplexTest/.wasp/out/web-app/netlify.toml
waspComplexTest/.wasp/out/web-app/package.json
waspComplexTest/.wasp/out/web-app/public/favicon.ico
waspComplexTest/.wasp/out/web-app/public/images/google-logo-icon.png
waspComplexTest/.wasp/out/web-app/public/manifest.json
waspComplexTest/.wasp/out/web-app/scripts/universal/validators.mjs
waspComplexTest/.wasp/out/web-app/scripts/validate-env.mjs
@ -86,9 +85,14 @@ waspComplexTest/.wasp/out/web-app/src/actions/core.js
waspComplexTest/.wasp/out/web-app/src/actions/index.ts
waspComplexTest/.wasp/out/web-app/src/api.ts
waspComplexTest/.wasp/out/web-app/src/auth/forms/Auth.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.jsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.jsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/ForgotPassword.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Login.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/ResetPassword.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/Signup.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialButton.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/SocialIcons.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/VerifyEmail.tsx
waspComplexTest/.wasp/out/web-app/src/auth/forms/types.ts
waspComplexTest/.wasp/out/web-app/src/auth/helpers/Google.jsx
waspComplexTest/.wasp/out/web-app/src/auth/helpers/user.ts
waspComplexTest/.wasp/out/web-app/src/auth/logout.js

View File

@ -524,13 +524,6 @@
],
"1481f03584b46b63182c1f8d26e61a95ce4d8fae032b2b4f3fe5e00ab7e96c23"
],
[
[
"file",
"web-app/public/images/google-logo-icon.png"
],
"e2087f585c3b213ba537a56c8bc8e6134c69d6fa1a5728d306df56d697b4e7ab"
],
[
[
"file",
@ -592,35 +585,70 @@
"file",
"web-app/src/auth/forms/Auth.tsx"
],
"3c15faec2c79eafeb0e796bb849c04eaf3c6c233636b96de6765352670816d97"
"f62b429c2a3749e8147198184d4612de7d1a82d1370696487b1aa65eb2e4b5b9"
],
[
[
"file",
"web-app/src/auth/forms/Login.jsx"
"web-app/src/auth/forms/ForgotPassword.tsx"
],
"cc899de975012417da95738016650b4d7416475a50fd730bae08c10b99c58d52"
"39eda745fc19798d235cbecf7e755ab1050187d99775dfb06304b06804e5c7fc"
],
[
[
"file",
"web-app/src/auth/forms/Signup.jsx"
"web-app/src/auth/forms/Login.tsx"
],
"413ba86964bbe7735ed102618d5c4efc8187eae2feb73cae72f2ddfda55a53b3"
"788a896a5717cd6e082db23cd5e1f4f46816253e24d6bbe60b2d64fb5a208d3c"
],
[
[
"file",
"web-app/src/auth/forms/ResetPassword.tsx"
],
"be24461d42102d53c028713146054029950137e79e27906a166a16cf9dd64add"
],
[
[
"file",
"web-app/src/auth/forms/Signup.tsx"
],
"bea6a9756a546820ccd240aabfa3db3d14faf655f7681c7b9ca0791677556c3a"
],
[
[
"file",
"web-app/src/auth/forms/SocialButton.tsx"
],
"fb14b39e617bf0cb7eec96428fd2882c2fbcda8bebd453ed00866b3203420043"
],
[
[
"file",
"web-app/src/auth/forms/SocialIcons.tsx"
],
"4e89c92b63539e28fe0d084b1fdcf870a426eb228ce37c57b374597005ff235c"
"18ce4ed1aec9fb0e71d4ce8376d5c42fcec1008d9b16d92dd18d51f34ee189a6"
],
[
[
"file",
"web-app/src/auth/forms/VerifyEmail.tsx"
],
"2cb996958b1332f383af1265a9fa7dd30d71aaef371cd2ac471f9feb44158568"
],
[
[
"file",
"web-app/src/auth/forms/types.ts"
],
"601917a95f8218f9db51148bf852a21925ea9806729fe9f8a65a96f84e8ba22f"
],
[
[
"file",
"web-app/src/auth/helpers/Google.jsx"
],
"c3d12e7a6cc1305c9e411d522e6aa8411d036337791ed0fc37e6048fe2d90cfd"
"c6677ed5052cf7dc9aca312935b48dd59eaf22420d581ac1b79c01070d3c109e"
],
[
[
@ -802,7 +830,7 @@
"file",
"web-app/src/stitches.config.js"
],
"25680fb34bd5200a8416e0a5ebfe8cb0e4240e7848ed3a065c56d1b55d7edd09"
"7de37836b80021870f286ff14d275e2ca7a1c2aa113ba5a5624ed0c77e178f76"
],
[
[

View File

@ -1,16 +1,13 @@
import React, { useState, useEffect } from 'react'
import { useHistory } from 'react-router-dom'
import { createStitches, createTheme } from '@stitches/react'
import { useState, FormEvent, useEffect, useCallback } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { createTheme } from '@stitches/react'
import { errorMessage } from '../../utils.js'
import * as SocialIcons from './SocialIcons'
import { SocialButton } from './SocialButton';
import config from '../../config.js'
import { styled, css } from '../../stitches.config'
const socialButtonsContainerStyle = {
maxWidth: '20rem'
}
import { styled } from '../../stitches.config'
import { State, CustomizationOptions } from './types'
const logoStyle = {
height: '3rem'
@ -29,7 +26,6 @@ const HeaderText = styled('h2', {
const SocialAuth = styled('div', {
marginTop: '1.5rem'
})
const SocialAuthLabel = styled('div', {
@ -66,31 +62,6 @@ const SocialAuthButtons = styled('div', {
}
})
const SocialButton = styled('a', {
display: 'flex',
justifyContent: 'center',
cursor: 'pointer',
// NOTE(matija): icon is otherwise blue, since that
// is link's default font color.
color: 'inherit',
backgroundColor: '#f0f0f0',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$gray600',
fontSize: '13px',
padding: '10px 15px',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:visited': {
color: 'inherit',
},
'&:hover': {
backgroundColor: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})
const OrContinueWith = styled('div', {
position: 'relative',
marginTop: '1.5rem'
@ -153,6 +124,13 @@ const FormInput = styled('input', {
borderColor: '$gray700',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
borderRadius: '0.375rem',
width: '100%',
@ -168,7 +146,6 @@ const SubmitButton = styled('button', {
justifyContent: 'center',
width: '100%',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$brand',
backgroundColor: '$brand',
@ -187,39 +164,43 @@ const SubmitButton = styled('button', {
backgroundColor: '$brandAccent',
borderColor: '$brandAccent',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})
const Message = styled('div', {
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
marginTop: '1rem',
background: '$gray400',
})
const ErrorMessage = styled(Message, {
background: '$errorBackground',
color: '$errorText',
})
const SuccessMessage = styled(Message, {
background: '$successBackground',
color: '$successText',
})
const googleSignInUrl = `${config.apiUrl}/auth/google/login`
const gitHubSignInUrl = `${config.apiUrl}/auth/github/login`
// TODO(matija): introduce type for appearance
const Auth = ({ isLogin, appearance, logo, socialLayout } :
{ isLogin: boolean; logo: string; socialLayout: "horizontal" | "vertical" }) => {
const history = useHistory()
const [usernameFieldVal, setUsernameFieldVal] = useState('')
const [passwordFieldVal, setPasswordFieldVal] = useState('')
const handleSubmit = async (event) => {
event.preventDefault()
try {
if (!isLogin) {
await signup({ username: usernameFieldVal, password: passwordFieldVal })
}
await login (usernameFieldVal, passwordFieldVal)
setUsernameFieldVal('')
setPasswordFieldVal('')
// Redirect to configured page, defaults to /.
history.push('/')
} catch (err) {
console.log(err)
window.alert(errorMessage(err))
}
}
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
state: State;
} & CustomizationOptions) {
const isLogin = state === "login";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// TODO(matija): this is called on every render, is it a problem?
// If we do it in useEffect(), then there is a glitch between the default color and the
@ -227,10 +208,28 @@ const Auth = ({ isLogin, appearance, logo, socialLayout } :
const customTheme = createTheme(appearance)
const cta = isLogin ? 'Log in' : 'Sign up'
const title = isLogin ? 'Log in to your account' : 'Create a new account'
const titles: Record<State, string> = {
login: 'Log in to your account',
signup: 'Create a new account',
"forgot-password": "Forgot your password?",
"reset-password": "Reset your password",
"verify-email": "Email verification",
}
const title = titles[state]
const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
const loginSignupForm = (<>
<SocialAuth>
<SocialAuthLabel>{cta} with</SocialAuthLabel>
<SocialAuthButtons gap='large' direction={socialButtonsDirection}>
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
</SocialAuthButtons>
</SocialAuth>
</>)
return (
<Container className={customTheme}>
<div>
@ -240,18 +239,13 @@ const Auth = ({ isLogin, appearance, logo, socialLayout } :
<HeaderText>{title}</HeaderText>
</div>
<SocialAuth>
<SocialAuthLabel>{cta} with</SocialAuthLabel>
<SocialAuthButtons gap='large' direction={socialButtonsDirection}>
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
</SocialAuthButtons>
</SocialAuth>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
{successMessage && <SuccessMessage>{successMessage}</SuccessMessage>}
{(state === 'login' || state === 'signup') && loginSignupForm}
</Container>
)
}
export default Auth
export default Auth;

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function ForgotPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.ForgotPassword}
/>
)
}

View File

@ -1,14 +0,0 @@
import Auth from './Auth'
const LoginForm = ({ appearance, logo, socialLayout }) => {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
isLogin={true}
/>
)
}
export default LoginForm

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function LoginForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Login}
/>
)
}

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function ResetPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.ResetPassword}
/>
)
}

View File

@ -1,16 +0,0 @@
import Auth from './Auth'
const SignupForm = ({ appearance, logo, socialLayout }) => {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
isLogin={false}
/>
)
}
export default SignupForm

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function SignupForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Signup}
/>
)
}

View File

@ -0,0 +1,27 @@
import { styled } from '../../stitches.config'
export const SocialButton = styled('a', {
display: 'flex',
justifyContent: 'center',
cursor: 'pointer',
// NOTE(matija): icon is otherwise blue, since that
// is link's default font color.
color: 'inherit',
backgroundColor: '#f0f0f0',
borderRadius: '0.375rem',
borderWidth: '1px',
borderColor: '$gray600',
fontSize: '13px',
padding: '0.5rem 0.75rem',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:visited': {
color: 'inherit',
},
'&:hover': {
backgroundColor: '$gray500',
color: 'inherit',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms'
})

View File

@ -8,11 +8,11 @@ const defaultStyles = css({
export const Google = () => (
<svg
className={defaultStyles()}
ariaHidden="true"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 24 24">
<g id="brand" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="google" fill="#000000" fill-rule="nonzero">
<g id="google" fill="#000000" fillRule="nonzero">
<path d="M11.99,13.9 L11.99,10.18 L21.35,10.18 C21.49,10.81 21.6,11.4 21.6,12.23 C21.6,17.94 17.77,22 12,22 C6.48,22 2,17.52 2,12 C2,6.48 6.48,2 12,2 C14.7,2 16.96,2.99 18.69,4.61 L15.85,7.37 C15.13,6.69 13.87,5.89 12,5.89 C8.69,5.89 5.99,8.64 5.99,12.01 C5.99,15.38 8.69,18.13 12,18.13 C15.83,18.13 17.24,15.48 17.5,13.91 L11.99,13.91 L11.99,13.9 Z" id="Shape">
</path>
</g>
@ -23,7 +23,7 @@ export const Google = () => (
export const GitHub = () => (
<svg
className={defaultStyles()}
ariaHidden="true"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20">
<path fillRule="evenodd"

View File

@ -0,0 +1,13 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
export function VerifyEmailForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.VerifyEmail}
/>
)
}

View File

@ -0,0 +1,15 @@
import { createTheme } from '@stitches/react'
export enum State {
Login = "login",
Signup = "signup",
ForgotPassword = "forgot-password",
ResetPassword = "reset-password",
VerifyEmail = "verify-email",
}
export type CustomizationOptions = {
logo?: string;
socialLayout?: "horizontal" | "vertical";
appearance?: Parameters<typeof createTheme>[0];
}

View File

@ -1,44 +1,14 @@
import config from '../../config.js'
import { SocialButton } from '../forms/SocialButton'
import * as SocialIcons from '../forms/SocialIcons'
export const signInUrl = `${config.apiUrl}/auth/google/login`
export const logoUrl = '/images/google-logo-icon.png'
const containerStyle = {
border: '2px solid #cbd5e1',
margin: 0,
cursor: 'pointer',
borderRadius: '.375rem',
backgroundColor: '#f8fafc',
fontWeight: 600,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
outline: '2px solid transparent',
outlineOffset: '2px',
}
const linkStyle = {
display: 'flex',
alignItems: 'center',
textDecoration: 'none',
color: '#1e293b',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
paddingTop: '.75rem',
paddingBottom: '.75rem',
}
const logoStyle = {
maxHeight: '24px',
marginRight: '0.75rem'
}
export function SignInButton() {
return (
<div style={containerStyle}>
<a href={signInUrl} style={linkStyle}>
<img alt="Google Icon" src={logoUrl} style={logoStyle} />
<span>Log in with Google</span>
</a>
</div>
<SocialButton href={signInUrl}>
<SocialIcons.Google />
</SocialButton>
)
}

View File

@ -11,11 +11,18 @@ export const {
gray600: '#d1d5db',
gray500: 'gainsboro',
gray400: '#f0f0f0',
red: '#FED7D7',
green: '#C6F6D5',
brand: '$waspYellow',
brandAccent: '#ffdb46',
errorBackground: '$red',
errorText: '#2D3748',
successBackground: '$green',
successText: '#2D3748',
submitButtonText: 'black',
submitButtonText: 'black'
},
fontSizes: {
sm: '0.875rem'

View File

@ -33,7 +33,7 @@ test('handles mock data', async () => {
})
test('handles multiple mock data sources', async () => {
mockQuery(getMe, { username: 'elon' })
mockQuery(getMe, { email: 'elon' })
mockQuery(getTasks, mockTasks)
renderInContext(<App><Todo /></App>)

View File

@ -1,43 +1,27 @@
import { useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Link, useHistory, useLocation } from 'react-router-dom'
import { verifyEmail } from '@wasp/auth/email'
import { Link } from 'react-router-dom'
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail'
import appearance from './appearance'
import todoLogo from '../../todoLogo.png'
export function EmailVerification() {
const location = useLocation()
const history = useHistory()
const verifyEmailInfo = useMutation<
{ success: boolean },
{ data: { success: boolean; reason?: string } },
{ token: string },
unknown
>({
mutationFn: verifyEmail,
})
useEffect(() => {
const token = new URLSearchParams(location.search).get('token')
if (!token) {
history.push('/login')
return
}
verifyEmailInfo.mutateAsync({ token })
}, [location])
return (
<div>
<h1>Email verification</h1>
{verifyEmailInfo.isLoading && <p>Verifying email...</p>}
{verifyEmailInfo.isError && (
<p>
Failed to verify email. Reason: {verifyEmailInfo.error.data?.reason}{' '}
token
</p>
)}
{verifyEmailInfo.isSuccess && verifyEmailInfo.data.success && (
<>
<p>Email verified successfully. </p>
<Link to="/login">Login</Link>
</>
)}
<div className="w-full h-full bg-white">
<div className="min-w-full min-h-[75vh] flex items-center justify-center">
<div className="w-full h-full max-w-sm p-5 bg-white">
<div>
<VerifyEmailForm
appearance={appearance}
logo={todoLogo}
socialLayout="horizontal"
/>
<br />
<span className="text-sm font-medium text-gray-900">
If everything is okay, <Link to="/login">go to login</Link>
</span>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,30 +1,31 @@
import React from 'react'
import { Link } from 'react-router-dom'
import LoginForm from '@wasp/auth/forms/Login'
import { LoginForm } from '@wasp/auth/forms/Login'
import appearance from './appearance'
import todoLogo from '../../todoLogo.png'
const Login = () => {
return (
<div className='w-full h-full bg-white'>
<div className='min-w-full min-h-[75vh] flex items-center justify-center'>
<div className='w-full h-full max-w-sm p-5 bg-white'>
<div className="w-full h-full bg-white">
<div className="min-w-full min-h-[75vh] flex items-center justify-center">
<div className="w-full h-full max-w-sm p-5 bg-white">
<div>
<LoginForm
appearance={appearance}
logo={todoLogo}
socialLayout='horizontal'
appearance={appearance}
logo={todoLogo}
socialLayout="horizontal"
/>
<br/>
<span className='text-sm font-medium text-gray-900'>
Don't have an account yet? (<Link to="/signup">go to signup</Link>).
<br />
<span className="text-sm font-medium text-gray-900">
Don't have an account yet? <Link to="/signup">go to signup</Link>.
</span>
<br />
<span className="text-sm font-medium text-gray-900">
Forgot your password?{' '}
<Link to="/request-password-reset">reset it</Link>.
</span>
<br/>
</div>
</div>
</div>
</div>

View File

@ -1,36 +1,27 @@
import { useHistory, useLocation } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { resetPassword } from '@wasp/auth/email'
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword'
import appearance from './appearance'
import todoLogo from '../../todoLogo.png'
export function PasswordReset() {
const location = useLocation()
const history = useHistory()
const token = new URLSearchParams(location.search).get('token')
const handleSubmit = async (e: any) => {
e.preventDefault()
if (!token) {
alert('Invalid token!')
return
}
const password = e.target[0].value as string
const passwordConfirmation = e.target[1].value as string
if (!password || password !== passwordConfirmation) {
alert("Passwords don't match!")
return
}
try {
await resetPassword({ password, token })
history.push('/login')
} catch (e: any) {
alert(e.message)
}
}
return (
<form className="flex flex-col gap-3 max-w-xs" onSubmit={handleSubmit}>
<h1>Reset password</h1>
<input type="password" placeholder="Enter new password" />
<input type="password" placeholder="Confirm new password" />
<button className="btn btn-primary">Reset password</button>
</form>
<div className="w-full h-full bg-white">
<div className="min-w-full min-h-[75vh] flex items-center justify-center">
<div className="w-full h-full max-w-sm p-5 bg-white">
<div>
<ResetPasswordForm
appearance={appearance}
logo={todoLogo}
socialLayout="horizontal"
/>
<br />
<span className="text-sm font-medium text-gray-900">
If everything is okay, <Link to="/login">go to login</Link>
</span>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,24 +1,21 @@
import { requestPasswordReset } from '@wasp/auth/email'
import { errorMessage } from '@wasp/utils'
import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword'
import appearance from './appearance'
import todoLogo from '../../todoLogo.png'
export function RequestPasswordReset() {
const handleSubmit = async (e: any) => {
e.preventDefault()
const email = e.target[0].value as string
try {
await requestPasswordReset({ email })
window.alert('Check your e-mail!')
} catch (e) {
window.alert(errorMessage(e))
}
}
return (
<div>
<form className="flex flex-col gap-3 max-w-xs" onSubmit={handleSubmit}>
<h1>Request password reset</h1>
<input type="email" placeholder="Enter your e-mail" />
<button className="btn btn-primary">Request password reset</button>
</form>
<div className="w-full h-full bg-white">
<div className="min-w-full min-h-[75vh] flex items-center justify-center">
<div className="w-full h-full max-w-sm p-5 bg-white">
<div>
<ForgotPasswordForm
appearance={appearance}
logo={todoLogo}
socialLayout="horizontal"
/>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'
import { SignupForm } from '@wasp/auth/email'
import { SignupForm } from '@wasp/auth/forms/Signup'
import getNumTasks from '@wasp/queries/getNumTasks'
import { useQuery } from '@wasp/queries'
import { getTotalTaskCountMessage } from './helpers'
@ -12,25 +12,24 @@ const Signup = () => {
const { data: numTasks } = useQuery(getNumTasks)
return (
<div className='w-full h-full bg-white'>
<div className='min-w-full min-h-[75vh] flex items-center justify-center'>
<div className='w-full h-full max-w-sm p-5 bg-white'>
<div className="w-full h-full bg-white">
<div className="min-w-full min-h-[75vh] flex items-center justify-center">
<div className="w-full h-full max-w-sm p-5 bg-white">
<div>
<SignupForm
appearance={appearance}
logo={todoLogo}
socialLayout='horizontal'
appearance={appearance}
logo={todoLogo}
socialLayout="horizontal"
/>
<br/>
<span className='text-sm font-medium text-gray-900'>
<br />
<span className="text-sm font-medium text-gray-900">
I already have an account (<Link to="/login">go to login</Link>).
</span>
<br/>
<br />
</div>
<br/><br/>
<br />
<br />
<span>{getTotalTaskCountMessage(numTasks)}</span>
</div>
</div>
</div>

View File

@ -1,10 +1,9 @@
const appearance = {
colors: {
brand: '#5969b8', // blue
brandAccent: '#de5998', // pink
submitButtonText: 'white',
}
},
}
export default appearance

View File

@ -17,12 +17,11 @@ app todoApp {
configFn: import { config } from "@server/auth/google.js",
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js"
},
// gitHub: {
// configFn: import { config } from "@server/auth/github.js",
// getUserFieldsFn: import { getUserFields } from "@server/auth/github.js"
// }
gitHub: {
// configFn: import { config } from "@server/auth/github.js",
// getUserFieldsFn: import { getUserFields } from "@server/auth/github.js"
},
email: {
allowUnverifiedLogin: true,
fromField: {
name: "ToDO App",
email: "mihovil@ilakovac.com"
@ -96,7 +95,7 @@ psl=}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import Signup from "@client/pages/auth/Signup.jsx"
component: import Signup from "@client/pages/auth/Signup.tsx"
}
route LoginRoute { path: "/login", to: LoginPage }

View File

@ -8,11 +8,11 @@ module Wasp.AppSpec.App.Auth
EmailAuthConfig (..),
usernameAndPasswordConfig,
isUsernameAndPasswordAuthEnabled,
areBothExternalAndUsernameAndPasswordAuthEnabled,
isExternalAuthEnabled,
isGoogleAuthEnabled,
isGitHubAuthEnabled,
isEmailAuthEnabled,
isEmailVerificationRequired,
)
where
@ -68,9 +68,6 @@ usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing
isUsernameAndPasswordAuthEnabled :: Auth -> Bool
isUsernameAndPasswordAuthEnabled = isJust . usernameAndPassword . methods
areBothExternalAndUsernameAndPasswordAuthEnabled :: Auth -> Bool
areBothExternalAndUsernameAndPasswordAuthEnabled auth = all ($ auth) [isExternalAuthEnabled, isUsernameAndPasswordAuthEnabled]
isExternalAuthEnabled :: Auth -> Bool
isExternalAuthEnabled auth = any ($ auth) [isGoogleAuthEnabled, isGitHubAuthEnabled]
@ -82,3 +79,8 @@ isGitHubAuthEnabled = isJust . gitHub . methods
isEmailAuthEnabled :: Auth -> Bool
isEmailAuthEnabled = isJust . email . methods
isEmailVerificationRequired :: Auth -> Bool
isEmailVerificationRequired auth = case email . methods $ auth of
Nothing -> False
Just emailAuthConfig -> allowUnverifiedLogin emailAuthConfig /= Just True

View File

@ -1,7 +1,6 @@
module Wasp.Generator.AuthProviders where
import Data.Maybe (fromJust)
import StrongPath (relfile)
import qualified Wasp.AppSpec.App.Dependency as App.Dependency
import Wasp.Generator.AuthProviders.Common (makeProviderId)
import qualified Wasp.Generator.AuthProviders.Email as E
@ -14,7 +13,6 @@ googleAuthProvider =
{ OA._providerId = fromJust $ makeProviderId "google",
OA._displayName = "Google",
OA._requiredScope = ["profile"],
OA._logoFileName = [relfile|google-logo-icon.png|],
OA._passportDependency = App.Dependency.make ("passport-google-oauth20", "2.0.0")
}
@ -24,7 +22,6 @@ gitHubAuthProvider =
{ OA._providerId = fromJust $ makeProviderId "github",
OA._displayName = "GitHub",
OA._requiredScope = [],
OA._logoFileName = [relfile|github-logo-icon.png|],
OA._passportDependency = App.Dependency.make ("passport-github2", "0.1.12")
}

View File

@ -4,7 +4,6 @@ module Wasp.Generator.AuthProviders.OAuth
serverOauthRedirectHandlerUrl,
providerId,
displayName,
logoFileName,
passportDependency,
scopeStr,
clientIdEnvVarName,
@ -15,7 +14,6 @@ where
import Data.Char (toUpper)
import Data.List (intercalate)
import StrongPath (File', Path', Rel')
import Wasp.AppSpec.App.Dependency (Dependency)
import Wasp.Generator.AuthProviders.Common (ProviderId, fromProviderId)
@ -25,7 +23,6 @@ data OAuthAuthProvider = OAuthAuthProvider
-- Used for pretty printing
_displayName :: String,
_requiredScope :: OAuthScope,
_logoFileName :: Path' Rel' File',
_passportDependency :: Dependency
}
@ -37,9 +34,6 @@ providerId = fromProviderId . _providerId
displayName :: OAuthAuthProvider -> String
displayName = _displayName
logoFileName :: OAuthAuthProvider -> Path' Rel' File'
logoFileName = _logoFileName
clientIdEnvVarName :: OAuthAuthProvider -> String
clientIdEnvVarName oai = upperCaseId oai ++ "_CLIENT_ID"

View File

@ -15,7 +15,6 @@ import StrongPath
Path',
Posix,
Rel,
reldir,
reldirP,
relfile,
(</>),
@ -23,14 +22,11 @@ import StrongPath
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
import qualified Wasp.AppSpec.App.Client as AS.App.Client
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import qualified Wasp.AppSpec.Entity as AS.Entity
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
import Wasp.Env (envVarsToDotEnvContent)
import Wasp.Generator.AuthProviders (gitHubAuthProvider, googleAuthProvider)
import qualified Wasp.Generator.AuthProviders.OAuth as OAuth
import Wasp.Generator.Common
( makeJsonWithEntityData,
nodeVersionRange,
@ -193,28 +189,13 @@ genPublicDir spec = do
[ genFaviconFd,
genManifestFd
]
<++> genSocialLoginIcons maybeAuth
where
maybeAuth = AS.App.auth $ snd $ getApp spec
genFaviconFd = C.mkTmplFd (C.asTmplFile [relfile|public/favicon.ico|])
genManifestFd =
let tmplData = object ["appName" .= (fst (getApp spec) :: String)]
tmplFile = C.asTmplFile [relfile|public/manifest.json|]
in C.mkTmplFdWithData tmplFile tmplData
genSocialLoginIcons :: Maybe AS.App.Auth.Auth -> Generator [FileDraft]
genSocialLoginIcons maybeAuth =
return $
[ C.mkTmplFd (C.asTmplFile fp)
| (isEnabled, fp) <- socialIcons,
(isEnabled <$> maybeAuth) == Just True
]
where
socialIcons =
[ (AS.App.Auth.isGoogleAuthEnabled, [reldir|public/images|] </> OAuth.logoFileName googleAuthProvider),
(AS.App.Auth.isGitHubAuthEnabled, [reldir|public/images|] </> OAuth.logoFileName gitHubAuthProvider)
]
genIndexHtml :: AppSpec -> Generator FileDraft
genIndexHtml spec =
return $

View File

@ -16,7 +16,6 @@ import Wasp.Generator.AuthProviders.Email
)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import Wasp.Generator.WebAppGenerator.Auth.Common (getOnAuthSucceededRedirectToOrDefault)
import Wasp.Generator.WebAppGenerator.Common as C
import Wasp.Util ((<++>))
@ -27,7 +26,6 @@ genEmailAuth auth
[ genIndex
]
<++> genActions
<++> genComponents auth
| otherwise = return []
genIndex :: Generator FileDraft
@ -73,24 +71,3 @@ genVerifyEmailAction =
C.mkTmplFdWithData
[relfile|src/auth/email/actions/verifyEmail.ts|]
(object ["verifyEmailPath" .= serverVerifyEmailUrl emailAuthProvider])
genComponents :: AS.Auth.Auth -> Generator [FileDraft]
genComponents auth =
sequence
[ genLoginComponent auth,
genSignupComponent auth
]
genLoginComponent :: AS.Auth.Auth -> Generator FileDraft
genLoginComponent auth =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/components/Login.jsx|]
(object ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth])
genSignupComponent :: AS.Auth.Auth -> Generator FileDraft
genSignupComponent auth =
return $
C.mkTmplFdWithData
[relfile|src/auth/email/components/Signup.jsx|]
(object ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth])

View File

@ -4,7 +4,6 @@ module Wasp.Generator.WebAppGenerator.Auth.OAuthAuthG
where
import Data.Aeson (object, (.=))
import Data.Maybe (fromJust)
import StrongPath (File', Path', Rel', reldir, relfile)
import qualified StrongPath as SP
import qualified Wasp.AppSpec.App.Auth as AS.Auth
@ -45,7 +44,6 @@ genHelpers auth =
tmplData =
object
[ "signInPath" .= OAuth.serverLoginUrl provider,
"iconName" .= (SP.fromRelFileP . fromJust . SP.relFileToPosix) (OAuth.logoFileName provider),
"displayName" .= OAuth.displayName provider
]

View File

@ -60,11 +60,16 @@ genUseAuth = return $ C.mkTmplFd (C.asTmplFile [relfile|src/auth/useAuth.js|])
genAuthForms :: AS.Auth.Auth -> Generator [FileDraft]
genAuthForms auth =
sequence
[ genLoginForm auth,
genSignupForm auth,
genAuthForm auth,
[ genAuthForm auth,
copyTmplFile [relfile|auth/forms/Login.tsx|],
copyTmplFile [relfile|auth/forms/Signup.tsx|],
copyTmplFile [relfile|auth/forms/ResetPassword.tsx|],
copyTmplFile [relfile|auth/forms/ForgotPassword.tsx|],
copyTmplFile [relfile|auth/forms/VerifyEmail.tsx|],
copyTmplFile [relfile|auth/forms/types.ts|],
copyTmplFile [relfile|stitches.config.js|],
copyTmplFile [relfile|auth/forms/SocialIcons.tsx|]
copyTmplFile [relfile|auth/forms/SocialIcons.tsx|],
copyTmplFile [relfile|auth/forms/SocialButton.tsx|]
]
where
copyTmplFile = return . C.mkSrcTmplFd
@ -74,30 +79,24 @@ genAuthForm auth =
compileTmplToSamePath
[relfile|auth/forms/Auth.tsx|]
[ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth,
"isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth,
"areBothExternalAndUsernameAndPasswordAuthEnabled" .= AS.Auth.areBothExternalAndUsernameAndPasswordAuthEnabled auth,
"areBothSocialAndPasswordBasedAuthEnabled" .= areBothSocialAndPasswordBasedAuthEnabled,
"isAnyPasswordBasedAuthEnabled" .= isAnyPasswordBasedAuthEnabled,
"isExternalAuthEnabled" .= AS.Auth.isExternalAuthEnabled auth,
-- Google
"isGoogleAuthEnabled" .= AS.Auth.isGoogleAuthEnabled auth,
"googleSignInPath" .= OAuth.serverLoginUrl googleAuthProvider,
-- GitHub
"isGitHubAuthEnabled" .= AS.Auth.isGitHubAuthEnabled auth,
"gitHubSignInPath" .= OAuth.serverLoginUrl gitHubAuthProvider
]
genLoginForm :: AS.Auth.Auth -> Generator FileDraft
genLoginForm auth =
compileTmplToSamePath
[relfile|auth/forms/Login.jsx|]
[ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth
]
genSignupForm :: AS.Auth.Auth -> Generator FileDraft
genSignupForm auth =
compileTmplToSamePath
[relfile|auth/forms/Signup.jsx|]
[ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth
"gitHubSignInPath" .= OAuth.serverLoginUrl gitHubAuthProvider,
-- Username and password
"isUsernameAndPasswordAuthEnabled" .= AS.Auth.isUsernameAndPasswordAuthEnabled auth,
-- Email
"isEmailAuthEnabled" .= AS.Auth.isEmailAuthEnabled auth,
"isEmailVerificationRequired" .= AS.Auth.isEmailVerificationRequired auth
]
where
areBothSocialAndPasswordBasedAuthEnabled = AS.Auth.isExternalAuthEnabled auth && isAnyPasswordBasedAuthEnabled
isAnyPasswordBasedAuthEnabled = AS.Auth.isUsernameAndPasswordAuthEnabled auth || AS.Auth.isEmailAuthEnabled auth
compileTmplToSamePath :: Path' Rel' File' -> [Pair] -> Generator FileDraft
compileTmplToSamePath tmplFileInTmplSrcDir keyValuePairs =