feat: user profile customization: username, password, and avatar (#315)
This commit is contained in:
@@ -1,20 +1,30 @@
|
||||
import { Avatar as HeroUiAvatar } from '@heroui/react';
|
||||
import { User } from 'lucide-react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { TokenContextType } from '@/types/user';
|
||||
import Config from '@/config/config';
|
||||
const apiServer = Config.apiServer;
|
||||
|
||||
type Props = {
|
||||
context: TokenContextType;
|
||||
size: number;
|
||||
username: string | undefined | null;
|
||||
avatarPath?: string | undefined | null;
|
||||
};
|
||||
|
||||
export default function PublicityChip({ context }: Props) {
|
||||
return context.isSignedIn() ? (
|
||||
<Avatar
|
||||
size={16}
|
||||
name={context.token?.user?.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
) : (
|
||||
<User size={16} />
|
||||
);
|
||||
export default function UserAvatar({ size, username, avatarPath }: Props) {
|
||||
if (username) {
|
||||
if (avatarPath) {
|
||||
return <HeroUiAvatar style={{ width: size, height: size }} src={`${apiServer}${avatarPath}`} />;
|
||||
} else {
|
||||
return (
|
||||
<Avatar
|
||||
size={size}
|
||||
name={username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return <User size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"projects": "Projects",
|
||||
"admin": "Administration",
|
||||
"account": "Account",
|
||||
"profile_settings": "Profile Settings",
|
||||
"signup": "Sign up",
|
||||
"signin": "Sign in",
|
||||
"signout": "Sign out",
|
||||
@@ -112,7 +113,27 @@
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"not_own_any_projects": "You don't own any projects.",
|
||||
"find_projects": "Find projects"
|
||||
"find_projects": "Find projects",
|
||||
"profile_settings": "Profile Settings",
|
||||
"change_username": "Change Username",
|
||||
"new_username": "New Username",
|
||||
"update_username": "Update Username",
|
||||
"username_updated": "Username updated successfully",
|
||||
"change_password": "Change Password",
|
||||
"current_password": "Current Password",
|
||||
"new_password": "New Password",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"update_password": "Update Password",
|
||||
"password_updated": "Password updated successfully",
|
||||
"change_avatar": "Change Avatar",
|
||||
"upload_avatar": "Upload Avatar",
|
||||
"remove_avatar": "Remove Avatar",
|
||||
"avatar_updated": "Avatar updated successfully",
|
||||
"avatar_removed": "Avatar removed successfully",
|
||||
"max_file_size_5mb": "Max. file size: 5MB",
|
||||
"only_images_allowed": "Only image files are allowed",
|
||||
"current_password_incorrect": "Current password is incorrect",
|
||||
"update_error": "Update failed"
|
||||
},
|
||||
"Health": {
|
||||
"health_check": "Health Check",
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"projects": "プロジェクト",
|
||||
"admin": "管理",
|
||||
"account": "アカウント",
|
||||
"profile_settings": "プロフィール設定",
|
||||
"signup": "新規登録",
|
||||
"signin": "サインイン",
|
||||
"signout": "サインアウト",
|
||||
@@ -113,7 +114,27 @@
|
||||
"public": "パブリック",
|
||||
"private": "プライベート",
|
||||
"not_own_any_projects": "所有しているプロジェクトがありません。",
|
||||
"find_projects": "プロジェクトを探す"
|
||||
"find_projects": "プロジェクトを探す",
|
||||
"profile_settings": "プロフィール設定",
|
||||
"change_username": "ユーザー名の変更",
|
||||
"new_username": "新しいユーザー名",
|
||||
"update_username": "ユーザー名を更新",
|
||||
"username_updated": "ユーザー名が正常に更新されました",
|
||||
"change_password": "パスワードの変更",
|
||||
"current_password": "現在のパスワード",
|
||||
"new_password": "新しいパスワード",
|
||||
"confirm_new_password": "新しいパスワード(確認)",
|
||||
"update_password": "パスワードを更新",
|
||||
"password_updated": "パスワードが正常に更新されました",
|
||||
"change_avatar": "アバターの変更",
|
||||
"upload_avatar": "アバターをアップロード",
|
||||
"remove_avatar": "アバターを削除",
|
||||
"avatar_updated": "アバターが正常に更新されました",
|
||||
"avatar_removed": "アバターが正常に削除されました",
|
||||
"max_file_size_5mb": "最大ファイルサイズ:5MB",
|
||||
"only_images_allowed": "画像ファイルのみ許可されています",
|
||||
"current_password_incorrect": "現在のパスワードが正しくありません",
|
||||
"update_error": "更新に失敗しました"
|
||||
},
|
||||
"Admin": {
|
||||
"user_management": "ユーザー管理",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"projects": "Projetos",
|
||||
"admin": "Administração",
|
||||
"account": "Conta",
|
||||
"profile_settings": "Configurações do Perfil",
|
||||
"signup": "Cadastre-se",
|
||||
"signin": "Entrar",
|
||||
"signout": "Sair",
|
||||
@@ -112,7 +113,27 @@
|
||||
"public": "Público",
|
||||
"private": "Privado",
|
||||
"not_own_any_projects": "Você não possui nenhum projeto.",
|
||||
"find_projects": "Encontrar projetos"
|
||||
"find_projects": "Encontrar projetos",
|
||||
"profile_settings": "Configurações do Perfil",
|
||||
"change_username": "Alterar Nome de Usuário",
|
||||
"new_username": "Novo Nome de Usuário",
|
||||
"update_username": "Atualizar Nome de Usuário",
|
||||
"username_updated": "Nome de usuário atualizado com sucesso",
|
||||
"change_password": "Alterar Senha",
|
||||
"current_password": "Senha Atual",
|
||||
"new_password": "Nova Senha",
|
||||
"confirm_new_password": "Confirmar Nova Senha",
|
||||
"update_password": "Atualizar Senha",
|
||||
"password_updated": "Senha atualizada com sucesso",
|
||||
"change_avatar": "Alterar Avatar",
|
||||
"upload_avatar": "Enviar Avatar",
|
||||
"remove_avatar": "Remover Avatar",
|
||||
"avatar_updated": "Avatar atualizado com sucesso",
|
||||
"avatar_removed": "Avatar removido com sucesso",
|
||||
"max_file_size_5mb": "Tamanho máximo do arquivo: 5MB",
|
||||
"only_images_allowed": "Apenas arquivos de imagem são permitidos",
|
||||
"current_password_incorrect": "A senha atual está incorreta",
|
||||
"update_error": "Falha na atualização"
|
||||
},
|
||||
"Health": {
|
||||
"health_check": "Verificação de Saúde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { Button, DropdownTrigger, Dropdown, DropdownMenu, DropdownItem } from '@heroui/react';
|
||||
import { ChevronDown, PenTool, ArrowRightFromLine, ArrowRightToLine } from 'lucide-react';
|
||||
import { ChevronDown, PenTool, ArrowRightFromLine, ArrowRightToLine, Settings } from 'lucide-react';
|
||||
import { useContext } from 'react';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
import { useRouter } from '@/src/i18n/routing';
|
||||
@@ -31,12 +31,23 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
|
||||
{
|
||||
uid: 'account',
|
||||
title: messages.account,
|
||||
icon: <UserAvatar context={context} />,
|
||||
icon: (
|
||||
<UserAvatar size={16} username={context.token?.user?.username} avatarPath={context.token?.user?.avatarPath} />
|
||||
),
|
||||
onPress: () => {
|
||||
router.push('/account', { locale: locale });
|
||||
onItemPress();
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'profile',
|
||||
title: messages.profileSettings,
|
||||
icon: <Settings size={16} />,
|
||||
onPress: () => {
|
||||
router.push('/account/settings', { locale: locale });
|
||||
onItemPress();
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'signout',
|
||||
title: messages.signOut,
|
||||
@@ -75,7 +86,13 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
startContent={<UserAvatar context={context} />}
|
||||
startContent={
|
||||
<UserAvatar
|
||||
size={16}
|
||||
username={context.token?.user?.username}
|
||||
avatarPath={context.token?.user?.avatarPath}
|
||||
/>
|
||||
}
|
||||
endContent={<ChevronDown size={16} />}
|
||||
>
|
||||
{context.isSignedIn() ? context.token?.user?.username : messages.signIn}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function Header(params: { locale: LocaleCodeType }) {
|
||||
docs: t('docs'),
|
||||
roadmap: t('roadmap'),
|
||||
account: t('account'),
|
||||
profileSettings: t('profile_settings'),
|
||||
signUp: t('signup'),
|
||||
signIn: t('signin'),
|
||||
signOut: t('signout'),
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ListboxItem,
|
||||
Listbox,
|
||||
} from '@heroui/react';
|
||||
import { ArrowRightFromLine, ArrowRightToLine, File, Globe, MoveUpRight, PenTool } from 'lucide-react';
|
||||
import { ArrowRightFromLine, ArrowRightToLine, File, Globe, MoveUpRight, PenTool, Settings } from 'lucide-react';
|
||||
import DropdownAccount from './DropdownAccount';
|
||||
import DropdownLanguage from './DropdownLanguage';
|
||||
import { ThemeSwitch } from '@/components/ThemeSwitch';
|
||||
@@ -31,6 +31,7 @@ type NabbarMenuMessages = {
|
||||
docs: string;
|
||||
roadmap: string;
|
||||
account: string;
|
||||
profileSettings: string;
|
||||
signUp: string;
|
||||
signIn: string;
|
||||
signOut: string;
|
||||
@@ -185,12 +186,27 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
||||
<ListboxItem
|
||||
key="account"
|
||||
title={messages.account}
|
||||
startContent={<UserAvatar context={context} />}
|
||||
startContent={
|
||||
<UserAvatar
|
||||
size={16}
|
||||
username={context.token?.user?.username}
|
||||
avatarPath={context.token?.user?.avatarPath}
|
||||
/>
|
||||
}
|
||||
onPress={() => {
|
||||
router.push('/account', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ListboxItem
|
||||
key="profile"
|
||||
title={messages.profileSettings}
|
||||
startContent={<Settings size={16} />}
|
||||
onPress={() => {
|
||||
router.push('/account/settings', { locale: locale });
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ListboxItem
|
||||
key="signout"
|
||||
title={messages.signOut}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
DropdownItem,
|
||||
} from '@heroui/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { UserType, AdminMessages } from '@/types/user';
|
||||
import { roles } from '@/config/selection';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type Props = {
|
||||
users: UserType[];
|
||||
@@ -62,14 +62,7 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
|
||||
|
||||
switch (columnKey) {
|
||||
case 'avatar':
|
||||
return (
|
||||
<Avatar
|
||||
size={24}
|
||||
name={user.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
);
|
||||
return <UserAvatar size={24} username={user.username} avatarPath={user.avatarPath} />;
|
||||
case 'id':
|
||||
return <span>{cellValue}</span>;
|
||||
case 'email':
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { UserType } from '@/types/user';
|
||||
import { MembersMessages } from '@/types/member';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type Props = {
|
||||
candidates: UserType[];
|
||||
@@ -24,14 +24,7 @@ export default function MembersTable({ candidates, onAddPress, messages }: Props
|
||||
|
||||
switch (columnKey) {
|
||||
case 'avatar':
|
||||
return (
|
||||
<Avatar
|
||||
size={16}
|
||||
name={candidate.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
);
|
||||
return <UserAvatar size={24} username={candidate.username} avatarPath={candidate.avatarPath} />;
|
||||
case 'email':
|
||||
return cellValue;
|
||||
case 'username':
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
DropdownItem,
|
||||
} from '@heroui/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { MemberType, UserType } from '@/types/user';
|
||||
import { memberRoles } from '@/config/selection';
|
||||
import { MembersMessages } from '@/types/member';
|
||||
import Config from '@/config/config';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type Props = {
|
||||
members: MemberType[];
|
||||
@@ -56,14 +57,7 @@ export default function MembersTable({ members, isDisabled, onChangeRole, onDele
|
||||
|
||||
switch (columnKey) {
|
||||
case 'avatar':
|
||||
return (
|
||||
<Avatar
|
||||
size={24}
|
||||
name={member.User.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
);
|
||||
return <UserAvatar size={24} username={member.User.username} avatarPath={member.User.avatarPath} />;
|
||||
case 'email':
|
||||
return member.User.email;
|
||||
case 'username':
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { Pencil, Trash } from 'lucide-react';
|
||||
import { SettingsMessages } from '@/types/settings';
|
||||
import { TokenContext } from '@/utils/TokenProvider';
|
||||
@@ -13,6 +12,7 @@ import ProjectDialog from '@/components/ProjectDialog';
|
||||
import { UserType } from '@/types/user';
|
||||
import { findUser } from '@/utils/usersControl';
|
||||
import { logError } from '@/utils/errorHandler';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -130,12 +130,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
||||
<TableCell>{messages.projectOwner}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar
|
||||
size={24}
|
||||
name={owner.username}
|
||||
variant="beam"
|
||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||
/>
|
||||
<UserAvatar size={24} username={owner.username} avatarPath={owner.avatarPath} />
|
||||
<p className="">{owner.username}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -25,6 +25,7 @@ export type TokenType = {
|
||||
export type TokenContextType = {
|
||||
token: {
|
||||
access_token: string;
|
||||
expires_at: number;
|
||||
user: UserType | null;
|
||||
};
|
||||
isSignedIn: () => boolean;
|
||||
@@ -87,6 +88,7 @@ export type AdminMessages = {
|
||||
|
||||
export type AccountDropDownMessages = {
|
||||
account: string;
|
||||
profileSettings: string;
|
||||
signUp: string;
|
||||
signIn: string;
|
||||
signOut: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ function removeTokenFromLocalStorage() {
|
||||
const defaultContext = {
|
||||
token: {
|
||||
access_token: '',
|
||||
expires_at: 0,
|
||||
user: null,
|
||||
},
|
||||
isSignedIn: () => false,
|
||||
|
||||
@@ -62,7 +62,7 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
|
||||
body: JSON.stringify(updateUserData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/${userId}`;
|
||||
const url = `${apiServer}/users/${userId}/role`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
@@ -76,4 +76,117 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export { findUser, searchUsers, updateUserRole };
|
||||
async function updateUsername(jwt: string, username: string) {
|
||||
const updateData = {
|
||||
username,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/username`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating username:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(jwt: string, currentPassword: string, newPassword: string) {
|
||||
const updateData = {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
};
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/password`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
logError('Error updating password:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar(jwt: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: formData,
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/avatar`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
logError('Error uploading avatar:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAvatar(jwt: string) {
|
||||
const fetchOptions = {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiServer}/users/avatar`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
logError('Error deleting avatar:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar };
|
||||
|
||||
Reference in New Issue
Block a user