mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(auth): updated email template for auth, general login improvements with pw validation and better error handling
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -35,8 +36,10 @@ export default function LoginPage({
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
// Forgot password states
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
@@ -57,53 +60,91 @@ export default function LoginPage({
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
|
||||
try {
|
||||
const result = await client.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/w',
|
||||
})
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/w',
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
console.error('Login error:', ctx.error)
|
||||
let errorMessage = 'Invalid email or password'
|
||||
|
||||
// Handle all possible error cases from Better Auth
|
||||
if (ctx.error.message?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
return
|
||||
} else if (
|
||||
ctx.error.message?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||
) {
|
||||
errorMessage = 'Email sign in is currently disabled.'
|
||||
} else if (
|
||||
ctx.error.message?.includes('INVALID_CREDENTIALS') ||
|
||||
ctx.error.message?.includes('invalid password')
|
||||
) {
|
||||
errorMessage = 'Invalid email or password. Please try again.'
|
||||
} else if (
|
||||
ctx.error.message?.includes('USER_NOT_FOUND') ||
|
||||
ctx.error.message?.includes('not found')
|
||||
) {
|
||||
errorMessage = 'No account found with this email. Please sign up first.'
|
||||
} else if (ctx.error.message?.includes('MISSING_CREDENTIALS')) {
|
||||
errorMessage = 'Please enter both email and password.'
|
||||
} else if (ctx.error.message?.includes('EMAIL_PASSWORD_DISABLED')) {
|
||||
errorMessage = 'Email and password login is disabled.'
|
||||
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_SESSION')) {
|
||||
errorMessage = 'Failed to create session. Please try again later.'
|
||||
} else if (ctx.error.message?.includes('too many attempts')) {
|
||||
errorMessage =
|
||||
'Too many login attempts. Please try again later or reset your password.'
|
||||
} else if (ctx.error.message?.includes('account locked')) {
|
||||
errorMessage =
|
||||
'Your account has been locked for security. Please reset your password.'
|
||||
} else if (ctx.error.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
} else if (ctx.error.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many requests. Please wait a moment before trying again.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!result || result.error) {
|
||||
throw new Error(result?.error?.message || 'Authentication failed')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Invalid email or password'
|
||||
|
||||
if (err.message?.includes('not verified')) {
|
||||
// Redirect to verification page directly without asking for confirmation
|
||||
// Handle only the special verification case that requires a redirect
|
||||
if (err.message?.includes('not verified') || err.message?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
try {
|
||||
// Send a new verification OTP
|
||||
await client.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
|
||||
// Redirect to the verify page
|
||||
router.push(`/verify?email=${encodeURIComponent(email)}`)
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
|
||||
router.push(`/verify`)
|
||||
return
|
||||
} catch (verifyErr) {
|
||||
errorMessage = 'Failed to send verification code. Please try again later.'
|
||||
addNotification(
|
||||
'error',
|
||||
'Failed to send verification code. Please try again later.',
|
||||
null
|
||||
)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
} else if (err.message?.includes('not found')) {
|
||||
errorMessage = 'No account found with this email. Please sign up first.'
|
||||
} else if (err.message?.includes('invalid password')) {
|
||||
errorMessage = 'Invalid password. Please try again or use the forgot password link.'
|
||||
} else if (err.message?.includes('too many attempts')) {
|
||||
errorMessage = 'Too many login attempts. Please try again later or reset your password.'
|
||||
} else if (err.message?.includes('account locked')) {
|
||||
errorMessage = 'Your account has been locked for security. Please reset your password.'
|
||||
} else if (err.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
} else if (err.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many requests. Please wait a moment before trying again.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
// Prevent navigation on error
|
||||
return
|
||||
console.error('Uncaught login error:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -160,7 +201,8 @@ export default function LoginPage({
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
{mounted && <NotificationList />}
|
||||
{/* Ensure NotificationList is always rendered */}
|
||||
<NotificationList />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
|
||||
<Card className="w-full">
|
||||
@@ -211,13 +253,25 @@ export default function LoginPage({
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -19,6 +20,23 @@ import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
|
||||
|
||||
const PASSWORD_VALIDATIONS = {
|
||||
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
|
||||
uppercase: {
|
||||
regex: /(?=.*?[A-Z])/,
|
||||
message: 'Password must include at least one uppercase letter.',
|
||||
},
|
||||
lowercase: {
|
||||
regex: /(?=.*?[a-z])/,
|
||||
message: 'Password must include at least one lowercase letter.',
|
||||
},
|
||||
number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' },
|
||||
special: {
|
||||
regex: /(?=.*?[#?!@$%^&*-])/,
|
||||
message: 'Password must include at least one special character.',
|
||||
},
|
||||
}
|
||||
|
||||
export default function SignupPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
@@ -30,54 +48,133 @@ export default function SignupPage({
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Validate password and return array of error messages
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check each validation criteria
|
||||
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.minLength.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.uppercase.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.lowercase.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.number.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.special.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors
|
||||
validatePassword(newPassword)
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
const passwordValue = formData.get('password') as string
|
||||
const name = formData.get('name') as string
|
||||
|
||||
// Validate password on submit
|
||||
const errors = validatePassword(passwordValue)
|
||||
setPasswordErrors(errors)
|
||||
|
||||
// Only show validation errors if there are any
|
||||
setShowValidationError(errors.length > 0)
|
||||
|
||||
try {
|
||||
// Validate password length before attempting signup
|
||||
if (password.length < 8) {
|
||||
addNotification('error', 'Password must be at least 8 characters long', null)
|
||||
if (errors.length > 0) {
|
||||
// Show first error as notification
|
||||
addNotification('error', errors[0], null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await client.signUp.email({ email, password, name })
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email,
|
||||
password: passwordValue,
|
||||
name,
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
console.error('Signup error:', ctx.error)
|
||||
let errorMessage = 'Failed to create account'
|
||||
|
||||
// Pass fromSignup=true to indicate we're coming from signup
|
||||
router.push(`/verify?email=${encodeURIComponent(email)}&fromSignup=true`)
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to create account'
|
||||
// Handle all possible error cases from Better Auth
|
||||
if (ctx.error.status === 422 || ctx.error.message?.includes('already exists')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (
|
||||
ctx.error.message?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign up is not enabled')
|
||||
) {
|
||||
errorMessage = 'Email signup is currently disabled.'
|
||||
} else if (ctx.error.message?.includes('INVALID_EMAIL')) {
|
||||
errorMessage = 'Please enter a valid email address.'
|
||||
} else if (ctx.error.message?.includes('PASSWORD_TOO_SHORT')) {
|
||||
errorMessage = 'Password must be at least 8 characters long.'
|
||||
} else if (ctx.error.message?.includes('PASSWORD_TOO_LONG')) {
|
||||
errorMessage = 'Password must be less than 128 characters.'
|
||||
} else if (ctx.error.message?.includes('USER_ALREADY_EXISTS')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_USER')) {
|
||||
errorMessage = 'Failed to create account. Please try again later.'
|
||||
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_SESSION')) {
|
||||
errorMessage = 'Failed to create session. Please try again later.'
|
||||
} else if (ctx.error.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many signup attempts. Please try again later.'
|
||||
} else if (ctx.error.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
} else if (ctx.error.message?.includes('invalid name')) {
|
||||
errorMessage = 'Please enter a valid name.'
|
||||
}
|
||||
|
||||
if (err.message?.includes('Password is too short')) {
|
||||
errorMessage = 'Password must be at least 8 characters long'
|
||||
} else if (err.message?.includes('existing email')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (err.message?.includes('invalid email')) {
|
||||
errorMessage = 'Please enter a valid email address'
|
||||
} else if (err.message?.includes('password too long')) {
|
||||
errorMessage = 'Password must be less than 128 characters'
|
||||
} else if (err.message?.includes('rate limit')) {
|
||||
errorMessage = 'Too many signup attempts. Please try again later.'
|
||||
} else if (err.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
} else if (err.message?.includes('invalid name')) {
|
||||
errorMessage = 'Please enter a valid name'
|
||||
addNotification('error', errorMessage, null)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response || response.error) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
|
||||
router.push(`/verify?fromSignup=true`)
|
||||
} catch (err: any) {
|
||||
console.error('Uncaught signup error:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -85,7 +182,8 @@ export default function SignupPage({
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
{mounted && <NotificationList />}
|
||||
{/* Ensure NotificationList is always rendered */}
|
||||
<NotificationList />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
|
||||
<Card className="w-full">
|
||||
@@ -127,7 +225,34 @@ export default function SignupPage({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className="text-sm text-red-500 mt-1">
|
||||
<p>Password must:</p>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
{passwordErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
|
||||
@@ -48,11 +48,13 @@ export function useVerification({
|
||||
logger.info('Notification store state:', { addNotification: !!addNotification })
|
||||
}, [addNotification])
|
||||
|
||||
// Get email from URL query param
|
||||
useEffect(() => {
|
||||
const emailParam = searchParams.get('email')
|
||||
if (emailParam) {
|
||||
setEmail(decodeURIComponent(emailParam))
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
@@ -98,6 +100,12 @@ export function useVerification({
|
||||
// Check if verification was successful
|
||||
if (response && !response.error) {
|
||||
setIsVerified(true)
|
||||
|
||||
// Clear email from sessionStorage after successful verification
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
}
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => router.push('/w'), 2000)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -40,6 +40,24 @@ function VerificationForm({
|
||||
handleOtpChange,
|
||||
} = useVerification({ hasResendKey, isProduction })
|
||||
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isResendDisabled, setIsResendDisabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (countdown === 0 && isResendDisabled) {
|
||||
setIsResendDisabled(false)
|
||||
}
|
||||
}, [countdown, isResendDisabled])
|
||||
|
||||
const handleResend = () => {
|
||||
resendCode()
|
||||
setIsResendDisabled(true)
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
@@ -111,13 +129,19 @@ function VerificationForm({
|
||||
<CardFooter className="flex justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
className="text-primary hover:underline font-medium"
|
||||
onClick={resendCode}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
{countdown > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
Resend in <span className="font-medium text-primary">{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="text-primary hover:underline font-medium"
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { DiscordIcon } from '@/components/icons'
|
||||
import { baseStyles } from './base-styles'
|
||||
import { DiscordIcon, XIcon } from './email-icons'
|
||||
|
||||
interface OTPVerificationEmailProps {
|
||||
otp: string
|
||||
@@ -47,13 +48,17 @@ export const OTPVerificationEmail = ({
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>{getSubjectByType(type)}</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={baseStyles.header}>
|
||||
<Section style={{ ...baseStyles.header, textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/sim.png`}
|
||||
width="120"
|
||||
height="40"
|
||||
alt="Sim Studio"
|
||||
style={{ display: 'block', objectFit: 'contain' }}
|
||||
style={{
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
|
||||
@@ -39,13 +39,17 @@ export const ResetPasswordEmail = ({
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Reset your Sim Studio password</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={baseStyles.header}>
|
||||
<Section style={{ ...baseStyles.header, textAlign: 'center' }}>
|
||||
<Img
|
||||
src={`${baseUrl}/sim.png`}
|
||||
width="120"
|
||||
height="40"
|
||||
alt="Sim Studio"
|
||||
style={{ display: 'block', objectFit: 'contain' }}
|
||||
style={{
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
|
||||
Reference in New Issue
Block a user