shortcut-next 0.2.2 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/templates/base/@core/configs/clientConfig.ts +1 -1
- package/templates/base/@core/context/AuthContext.tsx +21 -28
- package/templates/base/@core/hooks/useAbility.ts +58 -0
- package/templates/base/app/(dashboard)/dashboard/page.tsx +104 -0
- package/templates/base/app/(dashboard)/layout.tsx +97 -0
- package/templates/base/app/home/page.tsx +112 -0
- package/templates/base/app/login/page.tsx +296 -0
- package/templates/base/app/unauthorized/page.tsx +120 -0
- package/templates/base/components/MSWProvider.tsx +54 -0
- package/templates/base/components/auth/LoginForm.tsx +279 -0
- package/templates/base/components/auth/SignupForm.tsx +348 -0
- package/templates/base/components/loaders/Spinner.tsx +5 -24
- package/templates/base/components/ui/ErrorMessage.tsx +17 -0
- package/templates/base/components/ui/FormFieldWrapper.tsx +27 -0
- package/templates/base/docs/AuthorizationDocumentation.md +348 -0
- package/templates/base/lib/abilities/checkAuthorization.ts +74 -0
- package/templates/base/lib/abilities/index.ts +27 -0
- package/templates/base/lib/abilities/roles.ts +75 -0
- package/templates/base/lib/abilities/routeMap.ts +35 -0
- package/templates/base/lib/abilities/routeMatcher.ts +117 -0
- package/templates/base/lib/abilities/types.ts +68 -0
- package/templates/base/lib/mocks/browser.ts +11 -0
- package/templates/base/lib/mocks/db.ts +124 -0
- package/templates/base/lib/mocks/handlers/auth.ts +203 -0
- package/templates/base/lib/mocks/handlers/index.ts +16 -0
- package/templates/base/lib/mocks/index.ts +34 -0
- package/templates/base/lib/mocks/jwt.ts +99 -0
- package/templates/base/middleware.ts +147 -0
- package/templates/base/package-lock.json +725 -2
- package/templates/base/package.json +13 -2
- package/templates/base/providers/AppProviders.tsx +8 -5
- package/templates/base/public/locales/ar.json +73 -0
- package/templates/base/public/locales/en.json +73 -0
- package/templates/base/public/mockServiceWorker.js +349 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Stack, Typography, TextField, Button, Divider, IconButton, InputAdornment } from '@mui/material'
|
|
4
|
+
import { LoadingButton } from '@mui/lab'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { useForm, Controller } from 'react-hook-form'
|
|
7
|
+
import { yupResolver } from '@hookform/resolvers/yup'
|
|
8
|
+
import * as yup from 'yup'
|
|
9
|
+
import { motion } from 'framer-motion'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
import { Eye, EyeOff, ArrowLeft, Mail } from 'lucide-react'
|
|
12
|
+
import { Icon } from '@iconify/react'
|
|
13
|
+
import FormFieldWrapper from '@/components/ui/FormFieldWrapper'
|
|
14
|
+
import ErrorMessage from '@/components/ui/ErrorMessage'
|
|
15
|
+
import { useAuth } from '@/@core/context/AuthContext'
|
|
16
|
+
import useLanguage from '@/@core/hooks/useLanguage'
|
|
17
|
+
|
|
18
|
+
interface LoginFormData {
|
|
19
|
+
email: string
|
|
20
|
+
password: string
|
|
21
|
+
[key: string]: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface LoginFormProps {
|
|
25
|
+
showEmailForm: boolean
|
|
26
|
+
setShowEmailForm: (show: boolean) => void
|
|
27
|
+
onSwitchToSignup: () => void
|
|
28
|
+
onSocialLogin: (provider: 'google' | 'microsoft') => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MotionStack = motion.create(Stack)
|
|
32
|
+
|
|
33
|
+
const staggerContainer = {
|
|
34
|
+
hidden: {},
|
|
35
|
+
show: {
|
|
36
|
+
transition: {
|
|
37
|
+
staggerChildren: 0.08,
|
|
38
|
+
delayChildren: 0.08
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fadeInUp = {
|
|
44
|
+
hidden: { opacity: 0, y: 12 },
|
|
45
|
+
show: {
|
|
46
|
+
opacity: 1,
|
|
47
|
+
y: 0,
|
|
48
|
+
transition: { duration: 0.25, ease: 'easeOut' as const }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LoginForm: React.FC<LoginFormProps> = ({ showEmailForm, setShowEmailForm, onSwitchToSignup, onSocialLogin }) => {
|
|
53
|
+
const { t } = useTranslation()
|
|
54
|
+
const { login, isLoading } = useAuth()
|
|
55
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
56
|
+
const [errorMessage, setErrorMessage] = useState('')
|
|
57
|
+
const { language } = useLanguage()
|
|
58
|
+
|
|
59
|
+
const handleTogglePasswordVisibility = () => {
|
|
60
|
+
setShowPassword(!showPassword)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const loginSchema = yup.object().shape({
|
|
64
|
+
email: yup
|
|
65
|
+
.string()
|
|
66
|
+
.email(t('validation.invalidEmail', 'Invalid email address'))
|
|
67
|
+
.required(t('validation.emailRequired', 'Email is required')),
|
|
68
|
+
password: yup
|
|
69
|
+
.string()
|
|
70
|
+
.min(6, t('validation.passwordMin6', 'Password must be at least 6 characters'))
|
|
71
|
+
.required(t('validation.passwordRequired', 'Password is required'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
control,
|
|
76
|
+
handleSubmit,
|
|
77
|
+
formState: { errors }
|
|
78
|
+
} = useForm<LoginFormData>({
|
|
79
|
+
defaultValues: {
|
|
80
|
+
email: '',
|
|
81
|
+
password: ''
|
|
82
|
+
},
|
|
83
|
+
resolver: yupResolver(loginSchema)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const onSubmit = async (data: LoginFormData) => {
|
|
87
|
+
setErrorMessage('')
|
|
88
|
+
await login(data, error => {
|
|
89
|
+
setErrorMessage(typeof error === 'string' ? error : 'Login failed')
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<MotionStack
|
|
95
|
+
spacing={showEmailForm ? 3 : 4}
|
|
96
|
+
variants={staggerContainer}
|
|
97
|
+
initial='hidden'
|
|
98
|
+
animate='show'
|
|
99
|
+
exit='hidden'
|
|
100
|
+
>
|
|
101
|
+
<motion.div variants={fadeInUp}>
|
|
102
|
+
<Typography variant='h4' textAlign='center' mb={1}>
|
|
103
|
+
{t('login.brand', 'Welcome Back')}
|
|
104
|
+
</Typography>
|
|
105
|
+
</motion.div>
|
|
106
|
+
|
|
107
|
+
<motion.div variants={fadeInUp}>
|
|
108
|
+
<Typography variant='body2' textAlign='center' color='text.secondary' mb={showEmailForm ? 1 : 0}>
|
|
109
|
+
{t('login.subtitle', 'Sign in to continue to your account')}
|
|
110
|
+
</Typography>
|
|
111
|
+
</motion.div>
|
|
112
|
+
|
|
113
|
+
<motion.div variants={fadeInUp}>
|
|
114
|
+
{!showEmailForm ? (
|
|
115
|
+
<MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
|
|
116
|
+
<motion.div variants={fadeInUp}>
|
|
117
|
+
<Button
|
|
118
|
+
variant='outlined'
|
|
119
|
+
size='large'
|
|
120
|
+
fullWidth
|
|
121
|
+
startIcon={<Icon icon='mdi:google' />}
|
|
122
|
+
onClick={() => onSocialLogin('google')}
|
|
123
|
+
sx={{
|
|
124
|
+
borderColor: 'divider',
|
|
125
|
+
color: 'text.primary',
|
|
126
|
+
'&:hover': { bgcolor: 'action.hover' }
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{t('login.signInWithGoogle', 'Sign in with Google')}
|
|
130
|
+
</Button>
|
|
131
|
+
</motion.div>
|
|
132
|
+
|
|
133
|
+
<motion.div variants={fadeInUp}>
|
|
134
|
+
<Button
|
|
135
|
+
variant='outlined'
|
|
136
|
+
size='large'
|
|
137
|
+
fullWidth
|
|
138
|
+
startIcon={<Icon icon='mdi:microsoft' />}
|
|
139
|
+
onClick={() => onSocialLogin('microsoft')}
|
|
140
|
+
sx={{
|
|
141
|
+
borderColor: 'divider',
|
|
142
|
+
color: 'text.primary',
|
|
143
|
+
'&:hover': { bgcolor: 'action.hover' }
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{t('login.signInWithMicrosoft', 'Sign in with Microsoft')}
|
|
147
|
+
</Button>
|
|
148
|
+
</motion.div>
|
|
149
|
+
|
|
150
|
+
<motion.div variants={fadeInUp}>
|
|
151
|
+
<Divider sx={{ my: 3 }}>
|
|
152
|
+
<Typography variant='body2' textAlign='center' color='text.secondary'>
|
|
153
|
+
{t('login.or', 'or')}
|
|
154
|
+
</Typography>
|
|
155
|
+
</Divider>
|
|
156
|
+
</motion.div>
|
|
157
|
+
|
|
158
|
+
<motion.div variants={fadeInUp}>
|
|
159
|
+
<Button
|
|
160
|
+
variant='contained'
|
|
161
|
+
size='large'
|
|
162
|
+
fullWidth
|
|
163
|
+
startIcon={<Mail size={18} />}
|
|
164
|
+
onClick={() => setShowEmailForm(true)}
|
|
165
|
+
>
|
|
166
|
+
{t('login.signInWithEmail', 'Sign in with Email')}
|
|
167
|
+
</Button>
|
|
168
|
+
</motion.div>
|
|
169
|
+
|
|
170
|
+
<motion.div variants={fadeInUp}>
|
|
171
|
+
<Typography variant='body2' textAlign='center' color='text.secondary' sx={{ mt: 3 }}>
|
|
172
|
+
{t('login.dontHaveAccount', "Don't have an account?")}{' '}
|
|
173
|
+
<Button
|
|
174
|
+
variant='text'
|
|
175
|
+
color='primary'
|
|
176
|
+
onClick={() => {
|
|
177
|
+
setShowEmailForm(false)
|
|
178
|
+
onSwitchToSignup()
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{t('login.signUp', 'Sign Up')}
|
|
182
|
+
</Button>
|
|
183
|
+
</Typography>
|
|
184
|
+
</motion.div>
|
|
185
|
+
</MotionStack>
|
|
186
|
+
) : (
|
|
187
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
188
|
+
<MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
|
|
189
|
+
<ErrorMessage message={errorMessage} />
|
|
190
|
+
|
|
191
|
+
<motion.div variants={fadeInUp}>
|
|
192
|
+
<FormFieldWrapper title={t('login.email', 'Email')}>
|
|
193
|
+
<Controller
|
|
194
|
+
name='email'
|
|
195
|
+
control={control}
|
|
196
|
+
render={({ field }) => (
|
|
197
|
+
<TextField
|
|
198
|
+
{...field}
|
|
199
|
+
fullWidth
|
|
200
|
+
size='small'
|
|
201
|
+
placeholder={t('login.emailPlaceholder', 'your@email.com')}
|
|
202
|
+
error={!!errors.email}
|
|
203
|
+
helperText={errors.email?.message}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
/>
|
|
207
|
+
</FormFieldWrapper>
|
|
208
|
+
</motion.div>
|
|
209
|
+
|
|
210
|
+
<motion.div variants={fadeInUp}>
|
|
211
|
+
<FormFieldWrapper title={t('login.password', 'Password')}>
|
|
212
|
+
<Controller
|
|
213
|
+
name='password'
|
|
214
|
+
control={control}
|
|
215
|
+
render={({ field }) => (
|
|
216
|
+
<TextField
|
|
217
|
+
{...field}
|
|
218
|
+
fullWidth
|
|
219
|
+
size='small'
|
|
220
|
+
type={showPassword ? 'text' : 'password'}
|
|
221
|
+
placeholder='••••••••'
|
|
222
|
+
error={!!errors.password}
|
|
223
|
+
helperText={errors.password?.message}
|
|
224
|
+
slotProps={{
|
|
225
|
+
input: {
|
|
226
|
+
endAdornment: (
|
|
227
|
+
<InputAdornment position='end'>
|
|
228
|
+
<IconButton
|
|
229
|
+
edge='end'
|
|
230
|
+
onClick={handleTogglePasswordVisibility}
|
|
231
|
+
onMouseDown={e => e.preventDefault()}
|
|
232
|
+
size='small'
|
|
233
|
+
>
|
|
234
|
+
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
235
|
+
</IconButton>
|
|
236
|
+
</InputAdornment>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
</FormFieldWrapper>
|
|
244
|
+
</motion.div>
|
|
245
|
+
|
|
246
|
+
<motion.div variants={fadeInUp}>
|
|
247
|
+
<LoadingButton
|
|
248
|
+
loading={isLoading}
|
|
249
|
+
type='submit'
|
|
250
|
+
variant='contained'
|
|
251
|
+
size='large'
|
|
252
|
+
fullWidth
|
|
253
|
+
sx={{ mt: 2 }}
|
|
254
|
+
>
|
|
255
|
+
{!isLoading && t('login.signIn', 'Sign In')}
|
|
256
|
+
</LoadingButton>
|
|
257
|
+
</motion.div>
|
|
258
|
+
|
|
259
|
+
<motion.div variants={fadeInUp}>
|
|
260
|
+
<Button
|
|
261
|
+
variant='text'
|
|
262
|
+
onClick={() => setShowEmailForm(false)}
|
|
263
|
+
sx={{ color: 'text.secondary' }}
|
|
264
|
+
startIcon={
|
|
265
|
+
<ArrowLeft size={16} style={{ transform: language === 'ar' ? 'rotate(180deg)' : 'none' }} />
|
|
266
|
+
}
|
|
267
|
+
>
|
|
268
|
+
{t('login.backToSocial', 'Back to social login')}
|
|
269
|
+
</Button>
|
|
270
|
+
</motion.div>
|
|
271
|
+
</MotionStack>
|
|
272
|
+
</form>
|
|
273
|
+
)}
|
|
274
|
+
</motion.div>
|
|
275
|
+
</MotionStack>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default LoginForm
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Stack, Typography, TextField, Button, InputAdornment, IconButton, MenuItem } from '@mui/material'
|
|
4
|
+
import { LoadingButton } from '@mui/lab'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { useForm, Controller } from 'react-hook-form'
|
|
7
|
+
import { yupResolver } from '@hookform/resolvers/yup'
|
|
8
|
+
import * as yup from 'yup'
|
|
9
|
+
import { motion } from 'framer-motion'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
import { Eye, EyeOff } from 'lucide-react'
|
|
12
|
+
import FormFieldWrapper from '@/components/ui/FormFieldWrapper'
|
|
13
|
+
import ErrorMessage from '@/components/ui/ErrorMessage'
|
|
14
|
+
import { useAuth } from '@/@core/context/AuthContext'
|
|
15
|
+
import type { UserRole } from '@/lib/abilities'
|
|
16
|
+
|
|
17
|
+
interface SignupFormData {
|
|
18
|
+
firstName: string
|
|
19
|
+
lastName: string
|
|
20
|
+
email: string
|
|
21
|
+
password: string
|
|
22
|
+
confirmPassword: string
|
|
23
|
+
phone: string
|
|
24
|
+
role: UserRole
|
|
25
|
+
[key: string]: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SignupFormProps {
|
|
29
|
+
onSwitchToLogin: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MotionStack = motion.create(Stack)
|
|
33
|
+
|
|
34
|
+
const staggerContainer = {
|
|
35
|
+
hidden: {},
|
|
36
|
+
show: {
|
|
37
|
+
transition: {
|
|
38
|
+
staggerChildren: 0.08,
|
|
39
|
+
delayChildren: 0.08
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fadeInUp = {
|
|
45
|
+
hidden: { opacity: 0, y: 32 },
|
|
46
|
+
show: {
|
|
47
|
+
opacity: 1,
|
|
48
|
+
y: 0,
|
|
49
|
+
transition: { duration: 0.25, ease: 'easeOut' as const }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROLES: { value: UserRole; label: string; description: string }[] = [
|
|
54
|
+
{ value: 'admin', label: 'Admin', description: 'Full access to everything' },
|
|
55
|
+
{ value: 'manager', label: 'Manager', description: 'Manage users, tickets, and reports' },
|
|
56
|
+
{ value: 'agent', label: 'Agent', description: 'Handle tickets and view reports' },
|
|
57
|
+
{ value: 'viewer', label: 'Viewer', description: 'View-only access' }
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const SignupForm: React.FC<SignupFormProps> = ({ onSwitchToLogin }) => {
|
|
61
|
+
const { t } = useTranslation()
|
|
62
|
+
const { signup, isLoading } = useAuth()
|
|
63
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
64
|
+
const [errorMessage, setErrorMessage] = useState('')
|
|
65
|
+
|
|
66
|
+
const handleTogglePasswordVisibility = () => {
|
|
67
|
+
setShowPassword(!showPassword)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const signupSchema = yup.object().shape({
|
|
71
|
+
firstName: yup.string().required(t('validation.firstNameRequired', 'First name is required')),
|
|
72
|
+
lastName: yup.string().required(t('validation.lastNameRequired', 'Last name is required')),
|
|
73
|
+
email: yup
|
|
74
|
+
.string()
|
|
75
|
+
.email(t('validation.invalidEmail', 'Invalid email address'))
|
|
76
|
+
.required(t('validation.emailRequired', 'Email is required')),
|
|
77
|
+
phone: yup.string().required(t('validation.phoneRequired', 'Phone is required')),
|
|
78
|
+
role: yup
|
|
79
|
+
.string()
|
|
80
|
+
.oneOf(['admin', 'manager', 'agent', 'viewer'])
|
|
81
|
+
.required(t('validation.roleRequired', 'Role is required')),
|
|
82
|
+
password: yup
|
|
83
|
+
.string()
|
|
84
|
+
.min(8, t('validation.passwordMin8', 'Password must be at least 8 characters'))
|
|
85
|
+
.required(t('validation.passwordRequired', 'Password is required')),
|
|
86
|
+
confirmPassword: yup
|
|
87
|
+
.string()
|
|
88
|
+
.oneOf([yup.ref('password')], t('validation.passwordsDoNotMatch', 'Passwords do not match'))
|
|
89
|
+
.required(t('validation.confirmPasswordRequired', 'Please confirm your password'))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const {
|
|
93
|
+
control,
|
|
94
|
+
handleSubmit,
|
|
95
|
+
formState: { errors, isSubmitting }
|
|
96
|
+
} = useForm<SignupFormData>({
|
|
97
|
+
defaultValues: {
|
|
98
|
+
firstName: '',
|
|
99
|
+
lastName: '',
|
|
100
|
+
email: '',
|
|
101
|
+
phone: '',
|
|
102
|
+
password: '',
|
|
103
|
+
confirmPassword: '',
|
|
104
|
+
role: 'viewer'
|
|
105
|
+
},
|
|
106
|
+
resolver: yupResolver(signupSchema)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const onSubmit = async (data: SignupFormData) => {
|
|
110
|
+
setErrorMessage('')
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
112
|
+
const { confirmPassword, firstName, lastName, ...rest } = data
|
|
113
|
+
|
|
114
|
+
await signup(
|
|
115
|
+
{
|
|
116
|
+
...rest,
|
|
117
|
+
name: `${firstName} ${lastName}`
|
|
118
|
+
},
|
|
119
|
+
error => {
|
|
120
|
+
setErrorMessage(typeof error === 'string' ? error : 'Signup failed')
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
|
|
127
|
+
<motion.div variants={fadeInUp}>
|
|
128
|
+
<Typography variant='h4' textAlign='center' mb={1}>
|
|
129
|
+
{t('signup.title', 'Create Account')}
|
|
130
|
+
</Typography>
|
|
131
|
+
</motion.div>
|
|
132
|
+
|
|
133
|
+
<motion.div variants={fadeInUp}>
|
|
134
|
+
<Typography variant='body2' textAlign='center' color='text.secondary' mb={1}>
|
|
135
|
+
{t('signup.subtitle', 'Sign up to get started')}
|
|
136
|
+
</Typography>
|
|
137
|
+
</motion.div>
|
|
138
|
+
|
|
139
|
+
<motion.div variants={fadeInUp}>
|
|
140
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
141
|
+
<MotionStack spacing={3} variants={staggerContainer} initial='hidden' animate='show' exit='hidden'>
|
|
142
|
+
<ErrorMessage message={errorMessage} />
|
|
143
|
+
|
|
144
|
+
<MotionStack
|
|
145
|
+
direction={{ xs: 'column', sm: 'row' }}
|
|
146
|
+
gap={2}
|
|
147
|
+
variants={staggerContainer}
|
|
148
|
+
initial='hidden'
|
|
149
|
+
animate='show'
|
|
150
|
+
exit='hidden'
|
|
151
|
+
>
|
|
152
|
+
<motion.div variants={fadeInUp} style={{ width: '100%' }}>
|
|
153
|
+
<FormFieldWrapper title={t('signup.firstName', 'First Name')}>
|
|
154
|
+
<Controller
|
|
155
|
+
name='firstName'
|
|
156
|
+
control={control}
|
|
157
|
+
render={({ field }) => (
|
|
158
|
+
<TextField
|
|
159
|
+
{...field}
|
|
160
|
+
fullWidth
|
|
161
|
+
size='small'
|
|
162
|
+
placeholder={t('signup.firstNamePlaceholder', 'John')}
|
|
163
|
+
error={!!errors.firstName}
|
|
164
|
+
helperText={errors.firstName?.message}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
/>
|
|
168
|
+
</FormFieldWrapper>
|
|
169
|
+
</motion.div>
|
|
170
|
+
|
|
171
|
+
<motion.div variants={fadeInUp} style={{ width: '100%' }}>
|
|
172
|
+
<FormFieldWrapper title={t('signup.lastName', 'Last Name')}>
|
|
173
|
+
<Controller
|
|
174
|
+
name='lastName'
|
|
175
|
+
control={control}
|
|
176
|
+
render={({ field }) => (
|
|
177
|
+
<TextField
|
|
178
|
+
{...field}
|
|
179
|
+
fullWidth
|
|
180
|
+
size='small'
|
|
181
|
+
placeholder={t('signup.lastNamePlaceholder', 'Doe')}
|
|
182
|
+
error={!!errors.lastName}
|
|
183
|
+
helperText={errors.lastName?.message}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
</FormFieldWrapper>
|
|
188
|
+
</motion.div>
|
|
189
|
+
</MotionStack>
|
|
190
|
+
|
|
191
|
+
<motion.div variants={fadeInUp}>
|
|
192
|
+
<FormFieldWrapper title={t('signup.email', 'Email')}>
|
|
193
|
+
<Controller
|
|
194
|
+
name='email'
|
|
195
|
+
control={control}
|
|
196
|
+
render={({ field }) => (
|
|
197
|
+
<TextField
|
|
198
|
+
{...field}
|
|
199
|
+
fullWidth
|
|
200
|
+
size='small'
|
|
201
|
+
placeholder={t('signup.emailPlaceholder', 'your@email.com')}
|
|
202
|
+
error={!!errors.email}
|
|
203
|
+
helperText={errors.email?.message}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
/>
|
|
207
|
+
</FormFieldWrapper>
|
|
208
|
+
</motion.div>
|
|
209
|
+
|
|
210
|
+
<motion.div variants={fadeInUp}>
|
|
211
|
+
<FormFieldWrapper title={t('signup.phone', 'Phone')}>
|
|
212
|
+
<Controller
|
|
213
|
+
name='phone'
|
|
214
|
+
control={control}
|
|
215
|
+
render={({ field }) => (
|
|
216
|
+
<TextField
|
|
217
|
+
{...field}
|
|
218
|
+
fullWidth
|
|
219
|
+
size='small'
|
|
220
|
+
placeholder={t('signup.phonePlaceholder', '+1 234 567 8900')}
|
|
221
|
+
error={!!errors.phone}
|
|
222
|
+
helperText={errors.phone?.message}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
/>
|
|
226
|
+
</FormFieldWrapper>
|
|
227
|
+
</motion.div>
|
|
228
|
+
|
|
229
|
+
<motion.div variants={fadeInUp}>
|
|
230
|
+
<FormFieldWrapper title={t('signup.role', 'Role')}>
|
|
231
|
+
<Controller
|
|
232
|
+
name='role'
|
|
233
|
+
control={control}
|
|
234
|
+
render={({ field }) => (
|
|
235
|
+
<TextField
|
|
236
|
+
{...field}
|
|
237
|
+
select
|
|
238
|
+
fullWidth
|
|
239
|
+
size='small'
|
|
240
|
+
error={!!errors.role}
|
|
241
|
+
helperText={
|
|
242
|
+
errors.role?.message || t('signup.roleHelp', 'Select a role for testing authorization')
|
|
243
|
+
}
|
|
244
|
+
>
|
|
245
|
+
{ROLES.map(role => (
|
|
246
|
+
<MenuItem key={role.value} value={role.value}>
|
|
247
|
+
<Stack>
|
|
248
|
+
<Typography variant='body2' fontWeight={500}>
|
|
249
|
+
{role.label}
|
|
250
|
+
</Typography>
|
|
251
|
+
<Typography variant='caption' color='text.secondary'>
|
|
252
|
+
{role.description}
|
|
253
|
+
</Typography>
|
|
254
|
+
</Stack>
|
|
255
|
+
</MenuItem>
|
|
256
|
+
))}
|
|
257
|
+
</TextField>
|
|
258
|
+
)}
|
|
259
|
+
/>
|
|
260
|
+
</FormFieldWrapper>
|
|
261
|
+
</motion.div>
|
|
262
|
+
|
|
263
|
+
<motion.div variants={fadeInUp}>
|
|
264
|
+
<FormFieldWrapper title={t('signup.password', 'Password')}>
|
|
265
|
+
<Controller
|
|
266
|
+
name='password'
|
|
267
|
+
control={control}
|
|
268
|
+
render={({ field }) => (
|
|
269
|
+
<TextField
|
|
270
|
+
{...field}
|
|
271
|
+
fullWidth
|
|
272
|
+
size='small'
|
|
273
|
+
type={showPassword ? 'text' : 'password'}
|
|
274
|
+
placeholder='••••••••'
|
|
275
|
+
error={!!errors.password}
|
|
276
|
+
helperText={errors.password?.message}
|
|
277
|
+
slotProps={{
|
|
278
|
+
input: {
|
|
279
|
+
endAdornment: (
|
|
280
|
+
<InputAdornment position='end'>
|
|
281
|
+
<IconButton
|
|
282
|
+
edge='end'
|
|
283
|
+
onClick={handleTogglePasswordVisibility}
|
|
284
|
+
onMouseDown={e => e.preventDefault()}
|
|
285
|
+
size='small'
|
|
286
|
+
>
|
|
287
|
+
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
288
|
+
</IconButton>
|
|
289
|
+
</InputAdornment>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
295
|
+
/>
|
|
296
|
+
</FormFieldWrapper>
|
|
297
|
+
</motion.div>
|
|
298
|
+
|
|
299
|
+
<motion.div variants={fadeInUp}>
|
|
300
|
+
<FormFieldWrapper title={t('signup.confirmPassword', 'Confirm Password')}>
|
|
301
|
+
<Controller
|
|
302
|
+
name='confirmPassword'
|
|
303
|
+
control={control}
|
|
304
|
+
render={({ field }) => (
|
|
305
|
+
<TextField
|
|
306
|
+
{...field}
|
|
307
|
+
fullWidth
|
|
308
|
+
size='small'
|
|
309
|
+
type={showPassword ? 'text' : 'password'}
|
|
310
|
+
placeholder='••••••••'
|
|
311
|
+
error={!!errors.confirmPassword}
|
|
312
|
+
helperText={errors.confirmPassword?.message}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
/>
|
|
316
|
+
</FormFieldWrapper>
|
|
317
|
+
</motion.div>
|
|
318
|
+
|
|
319
|
+
<motion.div variants={fadeInUp}>
|
|
320
|
+
<LoadingButton
|
|
321
|
+
loading={isLoading}
|
|
322
|
+
type='submit'
|
|
323
|
+
variant='contained'
|
|
324
|
+
size='large'
|
|
325
|
+
fullWidth
|
|
326
|
+
disabled={isSubmitting}
|
|
327
|
+
sx={{ mt: 2 }}
|
|
328
|
+
>
|
|
329
|
+
{!isLoading && t('signup.create', 'Create Account')}
|
|
330
|
+
</LoadingButton>
|
|
331
|
+
</motion.div>
|
|
332
|
+
|
|
333
|
+
<motion.div variants={fadeInUp}>
|
|
334
|
+
<Typography variant='body2' textAlign='center' color='text.secondary' sx={{ mt: 3 }}>
|
|
335
|
+
{t('signup.alreadyHaveAccount', 'Already have an account?')}{' '}
|
|
336
|
+
<Button variant='text' onClick={onSwitchToLogin} color='primary'>
|
|
337
|
+
{t('signup.signIn', 'Sign In')}
|
|
338
|
+
</Button>
|
|
339
|
+
</Typography>
|
|
340
|
+
</motion.div>
|
|
341
|
+
</MotionStack>
|
|
342
|
+
</form>
|
|
343
|
+
</motion.div>
|
|
344
|
+
</MotionStack>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export default SignupForm
|
|
@@ -1,30 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import { CircularProgress, Stack } from '@mui/material'
|
|
3
4
|
|
|
4
5
|
export default function Spinner() {
|
|
5
6
|
return (
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
position: 'absolute',
|
|
10
|
-
inset: 0,
|
|
11
|
-
zIndex: 0,
|
|
12
|
-
pointerEvents: 'none',
|
|
13
|
-
background: `radial-gradient(circle at center,
|
|
14
|
-
rgba(59,130,246,.12) 0%,
|
|
15
|
-
rgba(59,130,246,.06) 20%,
|
|
16
|
-
rgba(0,0,0,0) 60%)`
|
|
17
|
-
}}
|
|
18
|
-
/>
|
|
19
|
-
<div className='spinner'>
|
|
20
|
-
{Array.from({ length: 6 }).map((_, i) => (
|
|
21
|
-
<div key={i} />
|
|
22
|
-
))}
|
|
23
|
-
</div>
|
|
24
|
-
<div className='loading-text'>
|
|
25
|
-
<span>Loading</span>
|
|
26
|
-
<span className='animated-dots' />
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
7
|
+
<Stack height='100vh' alignItems='center' justifyContent='center'>
|
|
8
|
+
<CircularProgress disableShrink size={16} />
|
|
9
|
+
</Stack>
|
|
29
10
|
)
|
|
30
11
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Alert } from '@mui/material'
|
|
4
|
+
|
|
5
|
+
interface ErrorMessageProps {
|
|
6
|
+
message?: string | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function ErrorMessage({ message }: ErrorMessageProps) {
|
|
10
|
+
if (!message) return null
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Alert severity='error' sx={{ mb: 2 }}>
|
|
14
|
+
{message}
|
|
15
|
+
</Alert>
|
|
16
|
+
)
|
|
17
|
+
}
|