feat: user profile customization: username, password, and avatar (#315)

This commit is contained in:
Copilot
2025-10-26 19:06:25 +09:00
committed by GitHub
parent 6495a90317
commit 7922996845
26 changed files with 1142 additions and 79 deletions

View File

@@ -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}

View File

@@ -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'),

View File

@@ -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}

View File

@@ -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>

View File

@@ -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} />;

View File

@@ -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>
);
}

View 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} />;
}

View File

@@ -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':

View File

@@ -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':

View File

@@ -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':

View File

@@ -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>