feat: user profile customization: username, password, and avatar (#315)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { Button, Card, CardHeader, CardFooter } from '@heroui/react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, Settings } from 'lucide-react';
|
||||
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { fetchMyProjects } from '@/utils/projectsControl';
|
||||
import { ProjectType } from '@/types/project';
|
||||
import PublicityChip from '@/components/PublicityChip';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
|
||||
@@ -17,6 +17,7 @@ type AccountPageMessages = {
|
||||
private: string;
|
||||
notOwnAnyProjects: string;
|
||||
findProjects: string;
|
||||
profileSettings: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -51,17 +52,28 @@ export default function AccountPage({ messages, locale }: Props) {
|
||||
<div className="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
|
||||
<div className="w-full p-3 flex items-center justify-between">
|
||||
<Card className="w-[600px]">
|
||||
<CardHeader className="flex gap-6">
|
||||
<Avatar
|
||||
size={48}
|
||||
name={context.token?.user?.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-xl font-bold">{context.token?.user?.username}</p>
|
||||
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
|
||||
<CardHeader className="flex gap-6 justify-between">
|
||||
<div className="flex gap-6">
|
||||
<UserAvatar
|
||||
size={48}
|
||||
username={context.token?.user?.username}
|
||||
avatarPath={context.token?.user?.avatarPath}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-xl font-bold">{context.token?.user?.username}</p>
|
||||
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
as={Link}
|
||||
href="/account/settings"
|
||||
locale={locale}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
startContent={<Settings size={16} />}
|
||||
>
|
||||
{messages.profileSettings}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function Page({ params }: PageType) {
|
||||
private: t('private'),
|
||||
notOwnAnyProjects: t('not_own_any_projects'),
|
||||
findProjects: t('find_projects'),
|
||||
profileSettings: t('profile_settings'),
|
||||
};
|
||||
|
||||
return <AccountPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
import { useState, useContext, useRef } from 'react';
|
||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type ProfileSettingsPageMessages = {
|
||||
profileSettings: string;
|
||||
changeUsername: string;
|
||||
newUsername: string;
|
||||
updateUsername: string;
|
||||
usernameUpdated: string;
|
||||
changePassword: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
updatePassword: string;
|
||||
passwordUpdated: string;
|
||||
changeAvatar: string;
|
||||
uploadAvatar: string;
|
||||
removeAvatar: string;
|
||||
avatarUpdated: string;
|
||||
avatarRemoved: string;
|
||||
maxFileSize5mb: string;
|
||||
onlyImagesAllowed: string;
|
||||
currentPasswordIncorrect: string;
|
||||
updateError: string;
|
||||
invalidPassword: string;
|
||||
passwordNotMatch: string;
|
||||
usernameEmpty: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messages: ProfileSettingsPageMessages;
|
||||
locale: LocaleCodeType;
|
||||
};
|
||||
|
||||
export default function ProfileSettingsPage({ messages }: Props) {
|
||||
const context = useContext(TokenContext);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
const handleUsernameUpdate = async () => {
|
||||
if (!username.trim()) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.usernameEmpty,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingUsername(true);
|
||||
try {
|
||||
const result = await updateUsername(context.token.access_token, username);
|
||||
if (result && result.user) {
|
||||
// refresh username
|
||||
const newToken = { ...context.token };
|
||||
if (newToken.user) {
|
||||
newToken.user.username = result.user.username;
|
||||
}
|
||||
context.setToken(newToken);
|
||||
context.storeTokenToLocalStorage(newToken);
|
||||
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.usernameUpdated,
|
||||
});
|
||||
setUsername('');
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error updating username:', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdatingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordUpdate = async () => {
|
||||
if (!currentPassword || !newPassword) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.updateError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.invalidPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.passwordNotMatch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingPassword(true);
|
||||
try {
|
||||
await updatePassword(context.token.access_token, currentPassword, newPassword);
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.passwordUpdated,
|
||||
});
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (error) {
|
||||
logError('Error updating password:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : messages.updateError;
|
||||
if (errorMessage.includes('incorrect')) {
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.currentPasswordIncorrect,
|
||||
});
|
||||
} else {
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsUpdatingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.onlyImagesAllowed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
addToast({
|
||||
title: 'Warning',
|
||||
color: 'warning',
|
||||
description: messages.maxFileSize5mb,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingAvatar(true);
|
||||
try {
|
||||
const result = await uploadAvatar(context.token.access_token, file);
|
||||
if (result && result.user) {
|
||||
const newToken = { ...context.token };
|
||||
if (newToken.user) {
|
||||
newToken.user = result.user;
|
||||
}
|
||||
context.setToken(newToken);
|
||||
context.storeTokenToLocalStorage(newToken);
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.avatarUpdated,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error uploading avatar:', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
setIsUploadingAvatar(true);
|
||||
try {
|
||||
const result = await deleteAvatar(context.token.access_token);
|
||||
if (result && result.user) {
|
||||
const newToken = { ...context.token };
|
||||
if (newToken.user) {
|
||||
newToken.user = result.user;
|
||||
}
|
||||
context.setToken(newToken);
|
||||
context.storeTokenToLocalStorage(newToken);
|
||||
addToast({
|
||||
title: 'Success',
|
||||
color: 'success',
|
||||
description: messages.avatarRemoved,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error removing avatar:', error);
|
||||
addToast({
|
||||
title: 'Error',
|
||||
color: 'danger',
|
||||
description: messages.updateError,
|
||||
});
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!context.isSignedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-xl pt-6 px-6 flex-grow">
|
||||
<h1 className="text-2xl font-bold mb-6">{messages.profileSettings}</h1>
|
||||
|
||||
{/* Change Username */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h2 className="text-large font-semibold">{messages.changeUsername}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
size="sm"
|
||||
autoComplete="username"
|
||||
label={messages.newUsername}
|
||||
placeholder={context.token?.user?.username || ''}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleUsernameUpdate}
|
||||
isLoading={isUpdatingUsername}
|
||||
isDisabled={!username.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{messages.updateUsername}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Password */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h2 className="text-large font-semibold">{messages.changePassword}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
{/* hidden username field for accessibility */}
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={context.token?.user?.username || ''}
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
label={messages.currentPassword}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={messages.newPassword}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={messages.confirmNewPassword}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handlePasswordUpdate}
|
||||
isLoading={isUpdatingPassword}
|
||||
isDisabled={!currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{messages.updatePassword}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Avatar */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h2 className="text-large font-semibold">{messages.changeAvatar}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<form>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar
|
||||
size={96}
|
||||
username={context.token?.user?.username}
|
||||
avatarPath={context.token?.user?.avatarPath}
|
||||
/>
|
||||
<div className="text-sm text-gray-500">{messages.maxFileSize5mb}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
<CardFooter className="flex justify-end">
|
||||
{context.token?.user?.avatarPath && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
className="me-2"
|
||||
onPress={handleAvatarRemove}
|
||||
isLoading={isUploadingAvatar}
|
||||
>
|
||||
{messages.removeAvatar}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" color="primary" onPress={() => fileInputRef.current?.click()} isLoading={isUploadingAvatar}>
|
||||
{messages.uploadAvatar}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/app/[locale]/account/settings/page.tsx
Normal file
35
frontend/src/app/[locale]/account/settings/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import ProfileSettingsPage from './ProfileSettingsPage';
|
||||
import { PageType } from '@/types/base';
|
||||
import { LocaleCodeType } from '@/types/locale';
|
||||
|
||||
export default function Page({ params }: PageType) {
|
||||
const t = useTranslations('Auth');
|
||||
const messages = {
|
||||
profileSettings: t('profile_settings'),
|
||||
changeUsername: t('change_username'),
|
||||
newUsername: t('new_username'),
|
||||
updateUsername: t('update_username'),
|
||||
usernameUpdated: t('username_updated'),
|
||||
changePassword: t('change_password'),
|
||||
currentPassword: t('current_password'),
|
||||
newPassword: t('new_password'),
|
||||
confirmNewPassword: t('confirm_new_password'),
|
||||
updatePassword: t('update_password'),
|
||||
passwordUpdated: t('password_updated'),
|
||||
changeAvatar: t('change_avatar'),
|
||||
uploadAvatar: t('upload_avatar'),
|
||||
removeAvatar: t('remove_avatar'),
|
||||
avatarUpdated: t('avatar_updated'),
|
||||
avatarRemoved: t('avatar_removed'),
|
||||
maxFileSize5mb: t('max_file_size_5mb'),
|
||||
onlyImagesAllowed: t('only_images_allowed'),
|
||||
currentPasswordIncorrect: t('current_password_incorrect'),
|
||||
updateError: t('update_error'),
|
||||
invalidPassword: t('invalid_password'),
|
||||
passwordNotMatch: t('password_not_match'),
|
||||
usernameEmpty: t('username_empty'),
|
||||
};
|
||||
|
||||
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||
}
|
||||
Reference in New Issue
Block a user