|
|
|
@@ -28,68 +28,71 @@ import { Loader2Icon, Pencil, Upload } from 'lucide-react'; |
|
|
|
import { useEffect, useState } from 'react'; |
|
|
|
import { useForm } from 'react-hook-form'; |
|
|
|
import { z } from 'zod'; |
|
|
|
function defineSchema( |
|
|
|
t: TFunction<'translation', string>, |
|
|
|
showPasswordForm = false, |
|
|
|
) { |
|
|
|
const baseSchema = z.object({ |
|
|
|
userName: z |
|
|
|
.string() |
|
|
|
.min(1, { message: t('usernameMessage') }) |
|
|
|
.trim(), |
|
|
|
avatarUrl: z.string().trim(), |
|
|
|
timeZone: z |
|
|
|
.string() |
|
|
|
.trim() |
|
|
|
.min(1, { message: t('timezonePlaceholder') }), |
|
|
|
email: z |
|
|
|
.string({ required_error: 'Please select an email to display.' }) |
|
|
|
.trim() |
|
|
|
.regex(/^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/, { |
|
|
|
message: 'Enter a valid email address.', |
|
|
|
}), |
|
|
|
}); |
|
|
|
|
|
|
|
function defineSchema(t: TFunction<'translation', string>) { |
|
|
|
return z |
|
|
|
.object({ |
|
|
|
userName: z |
|
|
|
.string() |
|
|
|
.min(1, { |
|
|
|
message: t('usernameMessage'), |
|
|
|
}) |
|
|
|
.trim(), |
|
|
|
avatarUrl: z.string().trim(), |
|
|
|
timeZone: z |
|
|
|
.string() |
|
|
|
.trim() |
|
|
|
.min(1, { |
|
|
|
message: t('timezonePlaceholder'), |
|
|
|
}), |
|
|
|
email: z |
|
|
|
.string({ |
|
|
|
required_error: 'Please select an email to display.', |
|
|
|
}) |
|
|
|
.trim() |
|
|
|
.regex( |
|
|
|
/^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/, |
|
|
|
{ |
|
|
|
message: 'Enter a valid email address.', |
|
|
|
}, |
|
|
|
), |
|
|
|
currPasswd: z |
|
|
|
.string() |
|
|
|
.trim() |
|
|
|
.min(1, { |
|
|
|
message: t('currentPasswordMessage'), |
|
|
|
}), |
|
|
|
newPasswd: z |
|
|
|
.string() |
|
|
|
.trim() |
|
|
|
.min(8, { |
|
|
|
message: t('confirmPasswordMessage'), |
|
|
|
}), |
|
|
|
confirmPasswd: z |
|
|
|
.string() |
|
|
|
.trim() |
|
|
|
.min(8, { |
|
|
|
message: t('newPasswordDescription'), |
|
|
|
}), |
|
|
|
}) |
|
|
|
.refine((data) => data.newPasswd === data.confirmPasswd, { |
|
|
|
message: t('confirmPasswordNonMatchMessage'), |
|
|
|
path: ['confirmPasswd'], |
|
|
|
}); |
|
|
|
} |
|
|
|
if (showPasswordForm) { |
|
|
|
return baseSchema |
|
|
|
.extend({ |
|
|
|
currPasswd: z |
|
|
|
.string({ |
|
|
|
required_error: t('currentPasswordMessage'), |
|
|
|
}) |
|
|
|
.trim() |
|
|
|
.min(1, { message: t('currentPasswordMessage') }), |
|
|
|
newPasswd: z |
|
|
|
.string({ |
|
|
|
required_error: t('confirmPasswordMessage'), |
|
|
|
}) |
|
|
|
.trim() |
|
|
|
.min(8, { message: t('confirmPasswordMessage') }), |
|
|
|
confirmPasswd: z |
|
|
|
.string({ |
|
|
|
required_error: t('newPasswordDescription'), |
|
|
|
}) |
|
|
|
.trim() |
|
|
|
.min(8, { message: t('newPasswordDescription') }), |
|
|
|
}) |
|
|
|
.refine((data) => data.newPasswd === data.confirmPasswd, { |
|
|
|
message: t('confirmPasswordNonMatchMessage'), |
|
|
|
path: ['confirmPasswd'], |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
return baseSchema; |
|
|
|
} |
|
|
|
export default function Profile() { |
|
|
|
const [avatarFile, setAvatarFile] = useState<File | null>(null); |
|
|
|
const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64 |
|
|
|
const { data: userInfo } = useFetchUserInfo(); |
|
|
|
const { saveSetting, loading: submitLoading } = useSaveSetting(); |
|
|
|
const { |
|
|
|
saveSetting, |
|
|
|
loading: submitLoading, |
|
|
|
data: saveUserData, |
|
|
|
} = useSaveSetting(); |
|
|
|
|
|
|
|
const { t } = useTranslate('setting'); |
|
|
|
const FormSchema = defineSchema(t); |
|
|
|
|
|
|
|
const [showPasswordForm, setShowPasswordForm] = useState(false); |
|
|
|
const FormSchema = defineSchema(t, showPasswordForm); |
|
|
|
const form = useForm<z.infer<typeof FormSchema>>({ |
|
|
|
resolver: zodResolver(FormSchema), |
|
|
|
defaultValues: { |
|
|
|
@@ -97,10 +100,11 @@ export default function Profile() { |
|
|
|
avatarUrl: '', |
|
|
|
timeZone: '', |
|
|
|
email: '', |
|
|
|
currPasswd: '', |
|
|
|
newPasswd: '', |
|
|
|
confirmPasswd: '', |
|
|
|
// currPasswd: '', |
|
|
|
// newPasswd: '', |
|
|
|
// confirmPasswd: '', |
|
|
|
}, |
|
|
|
shouldUnregister: true, |
|
|
|
}); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
@@ -108,10 +112,20 @@ export default function Profile() { |
|
|
|
form.setValue('email', userInfo?.email); // email |
|
|
|
form.setValue('userName', userInfo?.nickname); // nickname |
|
|
|
form.setValue('timeZone', userInfo?.timezone); // time zone |
|
|
|
form.setValue('currPasswd', ''); // current password |
|
|
|
// form.setValue('currPasswd', ''); // current password |
|
|
|
setAvatarBase64Str(userInfo?.avatar ?? ''); |
|
|
|
}, [userInfo]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (saveUserData === 0) { |
|
|
|
setShowPasswordForm(false); |
|
|
|
form.resetField('currPasswd'); |
|
|
|
form.resetField('newPasswd'); |
|
|
|
form.resetField('confirmPasswd'); |
|
|
|
} |
|
|
|
console.log('saveUserData', saveUserData); |
|
|
|
}, [saveUserData]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (avatarFile) { |
|
|
|
// make use of img compression transformFile2Base64 |
|
|
|
@@ -122,24 +136,34 @@ export default function Profile() { |
|
|
|
}, [avatarFile]); |
|
|
|
|
|
|
|
function onSubmit(data: z.infer<typeof FormSchema>) { |
|
|
|
// toast('You submitted the following values', { |
|
|
|
// description: ( |
|
|
|
// <pre className="mt-2 w-[320px] rounded-md bg-neutral-950 p-4"> |
|
|
|
// <code className="text-white">{JSON.stringify(data, null, 2)}</code> |
|
|
|
// </pre> |
|
|
|
// ), |
|
|
|
// }); |
|
|
|
// console.log('data=', data); |
|
|
|
// final submit form |
|
|
|
saveSetting({ |
|
|
|
const payload: Partial<{ |
|
|
|
nickname: string; |
|
|
|
password: string; |
|
|
|
new_password: string; |
|
|
|
avatar: string; |
|
|
|
timezone: string; |
|
|
|
}> = { |
|
|
|
nickname: data.userName, |
|
|
|
password: rsaPsw(data.currPasswd) as string, |
|
|
|
new_password: rsaPsw(data.newPasswd) as string, |
|
|
|
avatar: avatarBase64Str, |
|
|
|
timezone: data.timeZone, |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
if (showPasswordForm && 'currPasswd' in data && 'newPasswd' in data) { |
|
|
|
payload.password = rsaPsw(data.currPasswd!) as string; |
|
|
|
payload.new_password = rsaPsw(data.newPasswd!) as string; |
|
|
|
} |
|
|
|
saveSetting(payload); |
|
|
|
} |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (showPasswordForm) { |
|
|
|
form.register('currPasswd'); |
|
|
|
form.register('newPasswd'); |
|
|
|
form.register('confirmPasswd'); |
|
|
|
} else { |
|
|
|
form.unregister(['currPasswd', 'newPasswd', 'confirmPasswd']); |
|
|
|
} |
|
|
|
}, [showPasswordForm]); |
|
|
|
return ( |
|
|
|
<section className="p-8"> |
|
|
|
<h1 className="text-3xl font-bold">{t('profile')}</h1> |
|
|
|
@@ -152,12 +176,13 @@ export default function Profile() { |
|
|
|
onSubmit={form.handleSubmit(onSubmit)} |
|
|
|
className="block space-y-6" |
|
|
|
> |
|
|
|
{/* Username Field */} |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="userName" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0 "> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('username')} |
|
|
|
@@ -170,24 +195,26 @@ export default function Profile() { |
|
|
|
/> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="w-1/4"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
|
|
|
|
{/* Avatar Field */} |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="avatarUrl" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className="flex items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4"> |
|
|
|
Avatar |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/4"> |
|
|
|
<> |
|
|
|
<div className="flex justify-start items-end space-x-2"> |
|
|
|
<div className="relative group"> |
|
|
|
{!avatarBase64Str ? ( |
|
|
|
<div className="w-[64px] h-[64px] grid place-content-center"> |
|
|
|
@@ -198,18 +225,18 @@ export default function Profile() { |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<div className="w-[64px] h-[64px] relative grid place-content-center"> |
|
|
|
<Avatar className="w-[64px] h-[64px]"> |
|
|
|
<Avatar className="w-[64px] h-[64px] rounded-md"> |
|
|
|
<AvatarImage |
|
|
|
className=" block" |
|
|
|
className="block" |
|
|
|
src={avatarBase64Str} |
|
|
|
alt="" |
|
|
|
/> |
|
|
|
<AvatarFallback></AvatarFallback> |
|
|
|
<AvatarFallback className="rounded-md"></AvatarFallback> |
|
|
|
</Avatar> |
|
|
|
<div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60"> |
|
|
|
<Pencil |
|
|
|
size={20} |
|
|
|
className="absolute right-2 bottom-0 opacity-50 hidden group-hover:block" |
|
|
|
size={16} |
|
|
|
className="absolute right-1 bottom-1 opacity-50 hidden group-hover:block" |
|
|
|
/> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@@ -234,22 +261,27 @@ export default function Profile() { |
|
|
|
}} |
|
|
|
/> |
|
|
|
</div> |
|
|
|
</> |
|
|
|
<div className="margin-1 text-muted-foreground"> |
|
|
|
{t('avatarTip')} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="w-1/4"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
|
|
|
|
{/* Time Zone Field */} |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="timeZone" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className="items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('timezone')} |
|
|
|
@@ -269,37 +301,35 @@ export default function Profile() { |
|
|
|
</SelectContent> |
|
|
|
</Select> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="w-1/4"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
|
|
|
|
{/* Email Address Field */} |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="email" |
|
|
|
render={({ field }) => ( |
|
|
|
<div> |
|
|
|
<FormItem className="items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4"> |
|
|
|
{t('email')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/4"> |
|
|
|
<Input |
|
|
|
placeholder="Alex@gmail.com" |
|
|
|
disabled |
|
|
|
{...field} |
|
|
|
/> |
|
|
|
<>{field.value}</> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="w-1/4"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<p className="w-1/4"> </p> |
|
|
|
<p className="text-sm text-muted-foreground whitespace-nowrap w-3/4"> |
|
|
|
{t('emailDescription')} |
|
|
|
@@ -308,92 +338,110 @@ export default function Profile() { |
|
|
|
</div> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<div className="h-[10px]"></div> |
|
|
|
|
|
|
|
{/* Password Section */} |
|
|
|
<div className="pb-6"> |
|
|
|
<h1 className="text-3xl font-bold">{t('password')}</h1> |
|
|
|
<div className="flex items-center justify-start"> |
|
|
|
<h1 className="text-3xl font-bold">{t('password')}</h1> |
|
|
|
<Button |
|
|
|
type="button" |
|
|
|
className="bg-transparent hover:bg-transparent border text-muted-foreground hover:text-white ml-10" |
|
|
|
onClick={() => { |
|
|
|
setShowPasswordForm(!showPasswordForm); |
|
|
|
}} |
|
|
|
> |
|
|
|
{t('changePassword')} |
|
|
|
</Button> |
|
|
|
</div> |
|
|
|
<div className="text-sm text-muted-foreground"> |
|
|
|
{t('passwordDescription')} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div className="h-0 overflow-hidden absolute"> |
|
|
|
<input type="password" className=" w-0 height-0 opacity-0" /> |
|
|
|
</div> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="currPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('currentPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput {...field} /> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="newPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('newPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput {...field} /> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="confirmPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0"> |
|
|
|
<div className="flex w-[600px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('confirmPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput |
|
|
|
{...field} |
|
|
|
onBlur={() => { |
|
|
|
form.trigger('confirmPasswd'); |
|
|
|
}} |
|
|
|
onChange={(ev) => { |
|
|
|
form.setValue( |
|
|
|
'confirmPasswd', |
|
|
|
ev.target.value.trim(), |
|
|
|
); |
|
|
|
}} |
|
|
|
/> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[600px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"> </div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<div className="w-[600px] text-right space-x-4"> |
|
|
|
<Button variant="secondary">{t('cancel')}</Button> |
|
|
|
{/* Password Form */} |
|
|
|
{showPasswordForm && ( |
|
|
|
<> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="currPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className="items-center space-y-0"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('currentPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput {...field} /> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="newPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('newPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput {...field} /> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"></div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
<FormField |
|
|
|
control={form.control} |
|
|
|
name="confirmPasswd" |
|
|
|
render={({ field }) => ( |
|
|
|
<FormItem className=" items-center space-y-0"> |
|
|
|
<div className="flex w-[640px]"> |
|
|
|
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> |
|
|
|
<span className="text-red-600">*</span> |
|
|
|
{t('confirmPassword')} |
|
|
|
</FormLabel> |
|
|
|
<FormControl className="w-3/5"> |
|
|
|
<PasswordInput |
|
|
|
{...field} |
|
|
|
onBlur={() => { |
|
|
|
form.trigger('confirmPasswd'); |
|
|
|
}} |
|
|
|
onChange={(ev) => { |
|
|
|
form.setValue( |
|
|
|
'confirmPasswd', |
|
|
|
ev.target.value.trim(), |
|
|
|
); |
|
|
|
}} |
|
|
|
/> |
|
|
|
</FormControl> |
|
|
|
</div> |
|
|
|
<div className="flex w-[640px] pt-1"> |
|
|
|
<div className="min-w-[170px] max-w-[170px]"> |
|
|
|
|
|
|
|
</div> |
|
|
|
<FormMessage /> |
|
|
|
</div> |
|
|
|
</FormItem> |
|
|
|
)} |
|
|
|
/> |
|
|
|
</> |
|
|
|
)} |
|
|
|
<div className="w-[640px] text-right space-x-4"> |
|
|
|
<Button type="reset" variant="secondary"> |
|
|
|
{t('cancel')} |
|
|
|
</Button> |
|
|
|
<Button type="submit" disabled={submitLoading}> |
|
|
|
{submitLoading && <Loader2Icon className="animate-spin" />} |
|
|
|
{t('save', { keyPrefix: 'common' })} |