feat: Move language option to profile setting (#387)
This commit is contained in:
1
backend/config/locale.js
Normal file
1
backend/config/locale.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const SUPPORTED_LOCALES = ['de', 'en', 'pt-BR', 'zh-CN', 'ja'];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export async function up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('users', 'locale', {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('users', 'locale');
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ function defineUser(sequelize, DataTypes) {
|
|||||||
avatarPath: {
|
avatarPath: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
},
|
},
|
||||||
|
locale: {
|
||||||
|
type: DataTypes.STRING(20),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ underscored: true }
|
{ underscored: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function (sequelize) {
|
|||||||
|
|
||||||
// Return updated user without password
|
// Return updated user without password
|
||||||
const updatedUser = await User.findByPk(userId, {
|
const updatedUser = await User.findByPk(userId, {
|
||||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ user: updatedUser });
|
res.json({ user: updatedUser });
|
||||||
|
|||||||
46
backend/routes/users/updateLocale.js
Normal file
46
backend/routes/users/updateLocale.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import defineUser from '../../models/users.js';
|
||||||
|
import authMiddleware from '../../middleware/auth.js';
|
||||||
|
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export default function (sequelize) {
|
||||||
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
|
const User = defineUser(sequelize, DataTypes);
|
||||||
|
|
||||||
|
router.put('/locale', verifySignedIn, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).send('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = req.body;
|
||||||
|
|
||||||
|
const normalizedLocale = typeof locale === 'string' ? locale.trim() : '';
|
||||||
|
if (!normalizedLocale || normalizedLocale.length === 0) {
|
||||||
|
return res.status(400).send('Locale is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_LOCALES.includes(normalizedLocale)) {
|
||||||
|
return res.status(400).send('Invalid locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.update({ locale: normalizedLocale });
|
||||||
|
|
||||||
|
const updatedUser = await User.findByPk(userId, {
|
||||||
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
92
backend/routes/users/updateLocale.test.js
Normal file
92
backend/routes/users/updateLocale.test.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import { SUPPORTED_LOCALES } from '../../config/locale.js';
|
||||||
|
import updateLocaleRoute from './updateLocale.js';
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
let mockUserId = 1;
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = mockUserId; // Mock user ID
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineUser
|
||||||
|
const mockUsers = new Map();
|
||||||
|
const mockUser = {
|
||||||
|
findByPk: vi.fn((id) => {
|
||||||
|
const user = mockUsers.get(id);
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
update: vi.fn(async (data) => {
|
||||||
|
Object.assign(user, data);
|
||||||
|
return user;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../models/users.js', () => ({
|
||||||
|
default: () => mockUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('User Locale Routes', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/users', updateLocaleRoute(sequelize));
|
||||||
|
|
||||||
|
// Reset mock users
|
||||||
|
mockUsers.clear();
|
||||||
|
mockUserId = 1;
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
mockUsers.set(1, {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
username: 'testuser',
|
||||||
|
password: '',
|
||||||
|
role: 1,
|
||||||
|
avatarPath: null,
|
||||||
|
locale: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update locale', async () => {
|
||||||
|
const newLocale = SUPPORTED_LOCALES[0];
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale: newLocale });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user.locale).toBe(newLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace existing locale', async () => {
|
||||||
|
mockUsers.set(1, {
|
||||||
|
locale: SUPPORTED_LOCALES[0],
|
||||||
|
});
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale: SUPPORTED_LOCALES[1] });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.user.locale).toBe(SUPPORTED_LOCALES[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([' ', 'chinese', 'english'])('should reject not supported locale: %s', async (locale) => {
|
||||||
|
const response = await request(app).put('/users/locale').send({ locale });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export default function (sequelize) {
|
|||||||
|
|
||||||
// Return updated user without password
|
// Return updated user without password
|
||||||
const updatedUser = await User.findByPk(userId, {
|
const updatedUser = await User.findByPk(userId, {
|
||||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
attributes: ['id', 'email', 'username', 'role', 'avatarPath', 'locale'],
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ user: updatedUser });
|
res.json({ user: updatedUser });
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import usersSearchRoute from './routes/users/search.js';
|
|||||||
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
||||||
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
||||||
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
import usersAdminResetPasswordRoute from './routes/users/adminResetPassword.js';
|
||||||
|
import usersUpdateLocaleRoute from './routes/users/updateLocale.js';
|
||||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||||
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
||||||
import signUpRoute from './routes/users/signup.js';
|
import signUpRoute from './routes/users/signup.js';
|
||||||
@@ -63,6 +64,7 @@ app.use('/users', usersSearchRoute(sequelize));
|
|||||||
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
||||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||||
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
app.use('/users', usersAdminResetPasswordRoute(sequelize));
|
||||||
|
app.use('/users', usersUpdateLocaleRoute(sequelize));
|
||||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||||
app.use('/users', usersUpdateRoleRoute(sequelize));
|
app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||||
app.use('/users', signUpRoute(sequelize));
|
app.use('/users', signUpRoute(sequelize));
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Passwort (Bestätigung)",
|
"confirm_password": "Passwort (Bestätigung)",
|
||||||
"invalid_email": "Ungültige E-Mail",
|
"invalid_email": "Ungültige E-Mail",
|
||||||
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
"invalid_password": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
|
"invalid_locale": "Ungültige Sprache",
|
||||||
"username_empty": "Benutzername ist leer",
|
"username_empty": "Benutzername ist leer",
|
||||||
"password_empty": "Passwort ist leer",
|
"password_empty": "Passwort ist leer",
|
||||||
"password_not_match": "Passwörter stimmen nicht überein",
|
"password_not_match": "Passwörter stimmen nicht überein",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Neues Passwort bestätigen",
|
"confirm_new_password": "Neues Passwort bestätigen",
|
||||||
"update_password": "Passwort aktualisieren",
|
"update_password": "Passwort aktualisieren",
|
||||||
"password_updated": "Passwort erfolgreich geändert",
|
"password_updated": "Passwort erfolgreich geändert",
|
||||||
|
"change_locale": "Sprache ändern",
|
||||||
|
"update_locale": "Sprache aktualisieren",
|
||||||
|
"locale_updated": "Sprache erfolgreich geändert",
|
||||||
"change_avatar": "Avatar ändern",
|
"change_avatar": "Avatar ändern",
|
||||||
"upload_avatar": "Avatar hochladen",
|
"upload_avatar": "Avatar hochladen",
|
||||||
"remove_avatar": "Avatar entfernen",
|
"remove_avatar": "Avatar entfernen",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Password (confirm)",
|
"confirm_password": "Password (confirm)",
|
||||||
"invalid_email": "Invalid email",
|
"invalid_email": "Invalid email",
|
||||||
"invalid_password": "Password must be at least 8 characters",
|
"invalid_password": "Password must be at least 8 characters",
|
||||||
|
"invalid_locale": "Invalid language",
|
||||||
"username_empty": "username is empty",
|
"username_empty": "username is empty",
|
||||||
"password_empty": "password is empty",
|
"password_empty": "password is empty",
|
||||||
"password_not_match": "Pasword does not match",
|
"password_not_match": "Pasword does not match",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Confirm New Password",
|
"confirm_new_password": "Confirm New Password",
|
||||||
"update_password": "Update Password",
|
"update_password": "Update Password",
|
||||||
"password_updated": "Password updated successfully",
|
"password_updated": "Password updated successfully",
|
||||||
|
"change_locale": "Change Language",
|
||||||
|
"update_locale": "Update Language",
|
||||||
|
"locale_updated": "Language updated successfully",
|
||||||
"change_avatar": "Change Avatar",
|
"change_avatar": "Change Avatar",
|
||||||
"upload_avatar": "Upload Avatar",
|
"upload_avatar": "Upload Avatar",
|
||||||
"remove_avatar": "Remove Avatar",
|
"remove_avatar": "Remove Avatar",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "パスワード(確認)",
|
"confirm_password": "パスワード(確認)",
|
||||||
"invalid_email": "無効なメールアドレスです",
|
"invalid_email": "無効なメールアドレスです",
|
||||||
"invalid_password": "パスワードは8文字以上である必要があります",
|
"invalid_password": "パスワードは8文字以上である必要があります",
|
||||||
|
"invalid_locale": "無効な言語です",
|
||||||
"username_empty": "ユーザー名が入力されていません",
|
"username_empty": "ユーザー名が入力されていません",
|
||||||
"password_empty": "パスワードが入力されていません",
|
"password_empty": "パスワードが入力されていません",
|
||||||
"password_not_match": "パスワードが一致しません",
|
"password_not_match": "パスワードが一致しません",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "新しいパスワード(確認)",
|
"confirm_new_password": "新しいパスワード(確認)",
|
||||||
"update_password": "パスワードを更新",
|
"update_password": "パスワードを更新",
|
||||||
"password_updated": "パスワードが正常に更新されました",
|
"password_updated": "パスワードが正常に更新されました",
|
||||||
|
"change_locale": "言語の変更",
|
||||||
|
"update_locale": "言語を更新",
|
||||||
|
"locale_updated": "言語が正常に更新されました",
|
||||||
"change_avatar": "アバターの変更",
|
"change_avatar": "アバターの変更",
|
||||||
"upload_avatar": "アバターをアップロード",
|
"upload_avatar": "アバターをアップロード",
|
||||||
"remove_avatar": "アバターを削除",
|
"remove_avatar": "アバターを削除",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "Senha (confirmação)",
|
"confirm_password": "Senha (confirmação)",
|
||||||
"invalid_email": "E-mail inválido",
|
"invalid_email": "E-mail inválido",
|
||||||
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
"invalid_password": "A senha deve ter pelo menos 8 caracteres",
|
||||||
|
"invalid_locale": "Idioma inválido",
|
||||||
"username_empty": "O nome de usuário está vazio",
|
"username_empty": "O nome de usuário está vazio",
|
||||||
"password_empty": "A senha está vazia",
|
"password_empty": "A senha está vazia",
|
||||||
"password_not_match": "A senha não corresponde",
|
"password_not_match": "A senha não corresponde",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "Confirmar Nova Senha",
|
"confirm_new_password": "Confirmar Nova Senha",
|
||||||
"update_password": "Atualizar Senha",
|
"update_password": "Atualizar Senha",
|
||||||
"password_updated": "Senha atualizada com sucesso",
|
"password_updated": "Senha atualizada com sucesso",
|
||||||
|
"change_locale": "Alterar Idioma",
|
||||||
|
"update_locale": "Atualizar Idioma",
|
||||||
|
"locale_updated": "Idioma atualizado com sucesso",
|
||||||
"change_avatar": "Alterar Avatar",
|
"change_avatar": "Alterar Avatar",
|
||||||
"upload_avatar": "Enviar Avatar",
|
"upload_avatar": "Enviar Avatar",
|
||||||
"remove_avatar": "Remover Avatar",
|
"remove_avatar": "Remover Avatar",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
"confirm_password": "确认密码",
|
"confirm_password": "确认密码",
|
||||||
"invalid_email": "无效的邮箱地址",
|
"invalid_email": "无效的邮箱地址",
|
||||||
"invalid_password": "密码必须至少包含 8 个字符",
|
"invalid_password": "密码必须至少包含 8 个字符",
|
||||||
|
"invalid_locale": "无效的语言",
|
||||||
"username_empty": "用户名不能为空",
|
"username_empty": "用户名不能为空",
|
||||||
"password_empty": "密码不能为空",
|
"password_empty": "密码不能为空",
|
||||||
"password_not_match": "密码不匹配",
|
"password_not_match": "密码不匹配",
|
||||||
@@ -125,6 +126,9 @@
|
|||||||
"confirm_new_password": "确认新密码",
|
"confirm_new_password": "确认新密码",
|
||||||
"update_password": "更新密码",
|
"update_password": "更新密码",
|
||||||
"password_updated": "密码更新成功",
|
"password_updated": "密码更新成功",
|
||||||
|
"change_locale": "修改语言",
|
||||||
|
"update_locale": "更新语言",
|
||||||
|
"locale_updated": "语言更新成功",
|
||||||
"change_avatar": "修改头像",
|
"change_avatar": "修改头像",
|
||||||
"upload_avatar": "上传头像",
|
"upload_avatar": "上传头像",
|
||||||
"remove_avatar": "移除头像",
|
"remove_avatar": "移除头像",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function DropdownLanguage({ locale, onChangeLocale }: Props) {
|
|||||||
{locales.find((entry) => entry.code === locale)?.name || locale}
|
{locales.find((entry) => entry.code === locale)?.name || locale}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu aria-label="lacales">
|
<DropdownMenu aria-label="locales">
|
||||||
{locales.map((entry) => (
|
{locales.map((entry) => (
|
||||||
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
<DropdownItem key={entry.code} onPress={() => onChangeLocale(entry.code)}>
|
||||||
{entry.name}
|
{entry.name}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
|||||||
<ThemeSwitch />
|
<ThemeSwitch />
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
<DropdownAccount messages={messages} locale={locale} onItemPress={() => {}} />
|
||||||
<DropdownLanguage locale={locale} onChangeLocale={changeLocale} />
|
{!context.isSignedIn() && <DropdownLanguage locale={locale} onChangeLocale={changeLocale} />}
|
||||||
</div>
|
</div>
|
||||||
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
<NavbarMenuToggle className="md:hidden" onChange={() => setIsMenuOpen(!isMenuOpen)} />
|
||||||
</NavbarContent>
|
</NavbarContent>
|
||||||
@@ -224,51 +224,53 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
) : (
|
) : (
|
||||||
<Listbox
|
<>
|
||||||
aria-label="Account links"
|
<Listbox
|
||||||
itemClasses={{
|
aria-label="Account links"
|
||||||
base: 'h-10 text-large',
|
itemClasses={{
|
||||||
}}
|
base: 'h-10 text-large',
|
||||||
>
|
|
||||||
<ListboxItem
|
|
||||||
key="signin"
|
|
||||||
startContent={<ArrowRightToLine size={16} />}
|
|
||||||
title={messages.signIn}
|
|
||||||
onPress={() => {
|
|
||||||
router.push('/account/signin', { locale: locale });
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<ListboxItem
|
<ListboxItem
|
||||||
key="signup"
|
key="signin"
|
||||||
title={messages.signUp}
|
startContent={<ArrowRightToLine size={16} />}
|
||||||
startContent={<PenTool size={16} />}
|
title={messages.signIn}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push('/account/signup', { locale: locale });
|
router.push('/account/signin', { locale: locale });
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListboxItem
|
||||||
|
key="signup"
|
||||||
|
title={messages.signUp}
|
||||||
|
startContent={<PenTool size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
router.push('/account/signup', { locale: locale });
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Listbox>
|
||||||
|
<p className="font-bold">{messages.languages}</p>
|
||||||
|
<Listbox
|
||||||
|
aria-label="Language links"
|
||||||
|
itemClasses={{
|
||||||
|
base: 'h-10 text-large',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Listbox>
|
{locales.map((entry) => (
|
||||||
|
<ListboxItem
|
||||||
|
key={entry.code}
|
||||||
|
startContent={<Globe size={16} />}
|
||||||
|
title={entry.name}
|
||||||
|
onPress={() => {
|
||||||
|
changeLocale(entry.code);
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="font-bold">{messages.languages}</p>
|
|
||||||
<Listbox
|
|
||||||
aria-label="Language links"
|
|
||||||
itemClasses={{
|
|
||||||
base: 'h-10 text-large',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locales.map((entry) => (
|
|
||||||
<ListboxItem
|
|
||||||
key={entry.code}
|
|
||||||
startContent={<Globe size={16} />}
|
|
||||||
title={entry.name}
|
|
||||||
onPress={() => {
|
|
||||||
changeLocale(entry.code);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
</div>
|
||||||
</NavbarMenu>
|
</NavbarMenu>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function signInAsGuest() {
|
|||||||
username: 'Guest',
|
username: 'Guest',
|
||||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
|
locale: null,
|
||||||
};
|
};
|
||||||
const token = await signUp(guestUser);
|
const token = await signUp(guestUser);
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
|||||||
username: '',
|
username: '',
|
||||||
role: roles.findIndex((entry) => entry.uid === 'user'),
|
role: roles.findIndex((entry) => entry.uid === 'user'),
|
||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
|
locale: null,
|
||||||
});
|
});
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
@@ -83,14 +84,14 @@ export default function AuthPage({ isSignup, messages, locale }: Props) {
|
|||||||
|
|
||||||
context.setToken(token);
|
context.setToken(token);
|
||||||
context.storeTokenToLocalStorage(token);
|
context.storeTokenToLocalStorage(token);
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignInAsGuest = async () => {
|
const handleSignInAsGuest = async () => {
|
||||||
const token = await signInAsGuest();
|
const token = await signInAsGuest();
|
||||||
context.setToken(token);
|
context.setToken(token);
|
||||||
context.storeTokenToLocalStorage(token);
|
context.storeTokenToLocalStorage(token);
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: token.user?.locale ?? locale });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useContext, useRef } from 'react';
|
import { useState, useContext, useRef } from 'react';
|
||||||
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter } from '@heroui/react';
|
import { Button, Input, Card, CardHeader, CardBody, addToast, CardFooter, Select, SelectItem } from '@heroui/react';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar } from '@/utils/usersControl';
|
import { updateUsername, updatePassword, uploadAvatar, deleteAvatar, updateLocale } from '@/utils/usersControl';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
import { useRouter, usePathname } from '@/src/i18n/routing';
|
||||||
|
import { locales } from '@/config/selection';
|
||||||
|
import { LocaleType } from '@/types/locale';
|
||||||
|
|
||||||
type ProfileSettingsPageMessages = {
|
type ProfileSettingsPageMessages = {
|
||||||
profileSettings: string;
|
profileSettings: string;
|
||||||
@@ -19,6 +23,9 @@ type ProfileSettingsPageMessages = {
|
|||||||
confirmNewPassword: string;
|
confirmNewPassword: string;
|
||||||
updatePassword: string;
|
updatePassword: string;
|
||||||
passwordUpdated: string;
|
passwordUpdated: string;
|
||||||
|
changeLocale: string;
|
||||||
|
updateLocale: string;
|
||||||
|
localeUpdated: string;
|
||||||
changeAvatar: string;
|
changeAvatar: string;
|
||||||
uploadAvatar: string;
|
uploadAvatar: string;
|
||||||
removeAvatar: string;
|
removeAvatar: string;
|
||||||
@@ -31,6 +38,7 @@ type ProfileSettingsPageMessages = {
|
|||||||
invalidPassword: string;
|
invalidPassword: string;
|
||||||
passwordNotMatch: string;
|
passwordNotMatch: string;
|
||||||
usernameEmpty: string;
|
usernameEmpty: string;
|
||||||
|
invalidLocale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -38,15 +46,21 @@ type Props = {
|
|||||||
locale: LocaleCodeType;
|
locale: LocaleCodeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfileSettingsPage({ messages }: Props) {
|
export default function ProfileSettingsPage({ messages, locale: defaultLocale }: Props) {
|
||||||
const context = useContext(TokenContext);
|
const context = useContext(TokenContext);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [locale, setLocale] = useState<LocaleCodeType>(context.token?.user?.locale ?? defaultLocale);
|
||||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||||
|
const [isUpdatingLocale, setIsUpdatingLocale] = useState(false);
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
const handleUsernameUpdate = async () => {
|
const handleUsernameUpdate = async () => {
|
||||||
@@ -150,6 +164,53 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLocaleUpdate = async () => {
|
||||||
|
if (!locales.some((l) => l.code === locale)) {
|
||||||
|
addToast({
|
||||||
|
title: 'Warning',
|
||||||
|
color: 'warning',
|
||||||
|
description: messages.invalidLocale,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingLocale(true);
|
||||||
|
try {
|
||||||
|
const result = await updateLocale(context.token.access_token, locale);
|
||||||
|
if (result && result.user) {
|
||||||
|
// refresh locale
|
||||||
|
const newToken = { ...context.token };
|
||||||
|
if (newToken.user) {
|
||||||
|
newToken.user.locale = result.user.locale;
|
||||||
|
}
|
||||||
|
context.setToken(newToken);
|
||||||
|
context.storeTokenToLocalStorage(newToken);
|
||||||
|
|
||||||
|
addToast({
|
||||||
|
title: 'Success',
|
||||||
|
color: 'success',
|
||||||
|
description: messages.localeUpdated,
|
||||||
|
});
|
||||||
|
const nextLocale = result.user.locale ?? locale;
|
||||||
|
setLocale(nextLocale);
|
||||||
|
changeLocale(nextLocale);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error updating locale:', error);
|
||||||
|
addToast({
|
||||||
|
title: 'Error',
|
||||||
|
color: 'danger',
|
||||||
|
description: messages.updateError,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingLocale(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function changeLocale(nextLocale: LocaleCodeType) {
|
||||||
|
router.push(pathname, { locale: nextLocale });
|
||||||
|
}
|
||||||
|
|
||||||
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -333,6 +394,46 @@ export default function ProfileSettingsPage({ messages }: Props) {
|
|||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Change Locale */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<Globe size={16} />
|
||||||
|
<h2 className="text-large font-semibold ml-2">{messages.changeLocale}</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select<LocaleType>
|
||||||
|
fullWidth
|
||||||
|
aria-label="change locale"
|
||||||
|
selectedKeys={[locale]}
|
||||||
|
disabledKeys={[locale]}
|
||||||
|
onSelectionChange={(value) => {
|
||||||
|
const selectedLocale = locales.find((locale) => locale.code === value.currentKey);
|
||||||
|
if (!selectedLocale) return;
|
||||||
|
setLocale(selectedLocale.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locales.map((locale) => (
|
||||||
|
<SelectItem key={locale.code}>{locale.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={handleLocaleUpdate}
|
||||||
|
isLoading={isUpdatingLocale}
|
||||||
|
isDisabled={locale === context.token?.user?.locale}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{messages.updateLocale}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Change Avatar */}
|
{/* Change Avatar */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export default function Page({ params }: PageType) {
|
|||||||
confirmNewPassword: t('confirm_new_password'),
|
confirmNewPassword: t('confirm_new_password'),
|
||||||
updatePassword: t('update_password'),
|
updatePassword: t('update_password'),
|
||||||
passwordUpdated: t('password_updated'),
|
passwordUpdated: t('password_updated'),
|
||||||
|
changeLocale: t('change_locale'),
|
||||||
|
updateLocale: t('update_locale'),
|
||||||
|
localeUpdated: t('locale_updated'),
|
||||||
changeAvatar: t('change_avatar'),
|
changeAvatar: t('change_avatar'),
|
||||||
uploadAvatar: t('upload_avatar'),
|
uploadAvatar: t('upload_avatar'),
|
||||||
removeAvatar: t('remove_avatar'),
|
removeAvatar: t('remove_avatar'),
|
||||||
@@ -29,6 +32,7 @@ export default function Page({ params }: PageType) {
|
|||||||
invalidPassword: t('invalid_password'),
|
invalidPassword: t('invalid_password'),
|
||||||
passwordNotMatch: t('password_not_match'),
|
passwordNotMatch: t('password_not_match'),
|
||||||
usernameEmpty: t('username_empty'),
|
usernameEmpty: t('username_empty'),
|
||||||
|
invalidLocale: t('invalid_locale'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
return <ProfileSettingsPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
|||||||
avatarPath: '',
|
avatarPath: '',
|
||||||
role: -1,
|
role: -1,
|
||||||
username: '',
|
username: '',
|
||||||
|
locale: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type UserType = {
|
|||||||
username: string;
|
username: string;
|
||||||
role: number;
|
role: number;
|
||||||
avatarPath: string | null;
|
avatarPath: string | null;
|
||||||
|
locale: LocaleCodeType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TokenProps = {
|
export type TokenProps = {
|
||||||
|
|||||||
@@ -219,6 +219,36 @@ async function adminResetPassword(jwt: string, userId: number, newPassword: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateLocale(jwt: string, locale: string) {
|
||||||
|
const updateData = {
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/locale`;
|
||||||
|
|
||||||
|
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 locale:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
findUser,
|
findUser,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
@@ -228,4 +258,5 @@ export {
|
|||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
deleteAvatar,
|
deleteAvatar,
|
||||||
adminResetPassword,
|
adminResetPassword,
|
||||||
|
updateLocale,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user