Merge pull request #319 from kimatata/develop
This commit is contained in:
@@ -37,6 +37,7 @@ export default function (sequelize) {
|
||||
const cases = caseRecords.map((c) => c.get({ plain: true }));
|
||||
|
||||
const clonedCases = cases.map((c) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c;
|
||||
return { ...clonedCase, folderId: targetFolderId };
|
||||
});
|
||||
@@ -47,6 +48,7 @@ export default function (sequelize) {
|
||||
|
||||
if (c.Steps) {
|
||||
const clonedSteps = c.Steps.map((s) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s;
|
||||
return clonedStep;
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function (sequelize) {
|
||||
const cases = folderCases.map((c) => c.get({ plain: true }));
|
||||
|
||||
const clonedCases = cases.map((c) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c;
|
||||
return { ...clonedCase, folderId: targetFolderId };
|
||||
});
|
||||
@@ -63,6 +64,7 @@ export default function (sequelize) {
|
||||
|
||||
if (c.Steps && c.Steps.length > 0) {
|
||||
const clonedSteps = c.Steps.map((s) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s;
|
||||
return clonedStep;
|
||||
});
|
||||
|
||||
140
backend/routes/users/updateAvatar.js
Normal file
140
backend/routes/users/updateAvatar.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import multer from 'multer';
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
const router = express.Router();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
// Create avatars folder if it does not exist
|
||||
const avatarDir = path.join(__dirname, '../../public/uploads/avatars');
|
||||
if (!fs.existsSync(avatarDir)) {
|
||||
fs.mkdirSync(avatarDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, avatarDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const fileName = `avatar_${req.userId}_${Date.now()}${ext}`;
|
||||
cb(null, fileName);
|
||||
},
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Accept images only
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB limit
|
||||
},
|
||||
});
|
||||
|
||||
// Upload avatar
|
||||
router.post('/avatar', verifySignedIn, upload.single('avatar'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
// Delete uploaded file if user not found
|
||||
fs.unlinkSync(file.path);
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
// Delete old avatar if exists
|
||||
if (user.avatarPath) {
|
||||
// Validate that avatarPath is within expected directory
|
||||
const oldAvatarPath = path.join(__dirname, '../../public', user.avatarPath);
|
||||
const avatarDirResolved = path.resolve(__dirname, '../../public/uploads/avatars');
|
||||
const oldAvatarResolved = path.resolve(oldAvatarPath);
|
||||
|
||||
// Ensure the path is within the avatars directory (prevent path traversal)
|
||||
if (oldAvatarResolved.startsWith(avatarDirResolved) && fs.existsSync(oldAvatarPath)) {
|
||||
fs.unlinkSync(oldAvatarPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user with new avatar path
|
||||
const avatarPath = `/uploads/avatars/${file.filename}`;
|
||||
await user.update({ avatarPath });
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Clean up uploaded file on error
|
||||
if (req.file) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete avatar
|
||||
router.delete('/avatar', 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');
|
||||
}
|
||||
|
||||
// Delete avatar file if exists
|
||||
if (user.avatarPath) {
|
||||
// Validate that avatarPath is within expected directory
|
||||
const avatarPath = path.join(__dirname, '../../public', user.avatarPath);
|
||||
const avatarDirResolved = path.resolve(__dirname, '../../public/uploads/avatars');
|
||||
const avatarPathResolved = path.resolve(avatarPath);
|
||||
|
||||
// Ensure the path is within the avatars directory (prevent path traversal)
|
||||
if (avatarPathResolved.startsWith(avatarDirResolved) && fs.existsSync(avatarPath)) {
|
||||
fs.unlinkSync(avatarPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user to remove avatar path
|
||||
await user.update({ avatarPath: null });
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
49
backend/routes/users/updatePassword.js
Normal file
49
backend/routes/users/updatePassword.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
const router = express.Router();
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
// Change user password
|
||||
router.put('/password', verifySignedIn, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).send('Current password and new password are required');
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).send('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).send('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
await user.update({ password: hashedPassword });
|
||||
|
||||
res.json({ message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
113
backend/routes/users/updatePassword.test.js
Normal file
113
backend/routes/users/updatePassword.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import updatePasswordRoute from './updatePassword.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,
|
||||
}));
|
||||
|
||||
// mock bcrypt (just add 'hashed_' prefix)
|
||||
vi.mock('bcrypt', () => ({
|
||||
default: {
|
||||
hashSync: (pw) => `hashed_${pw}`,
|
||||
compareSync: (pw, hashed) => hashed === `hashed_${pw}`,
|
||||
hash: async (pw) => `hashed_${pw}`,
|
||||
compare: async (pw, hashed) => hashed === `hashed_${pw}`,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('User Profile Routes', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', updatePasswordRoute(sequelize));
|
||||
|
||||
// Reset mock users
|
||||
mockUsers.clear();
|
||||
mockUserId = 1;
|
||||
|
||||
// Create a test user
|
||||
mockUsers.set(1, {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: 'hashed_testpassword123',
|
||||
role: 1,
|
||||
avatarPath: null,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should update password', async () => {
|
||||
const newPassword = 'newpassword123';
|
||||
const response = await request(app).put('/users/password').send({
|
||||
currentPassword: 'testpassword123',
|
||||
newPassword: newPassword,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toContain('successfully');
|
||||
});
|
||||
|
||||
it('should reject incorrect current password', async () => {
|
||||
const response = await request(app).put('/users/password').send({
|
||||
currentPassword: 'wrongpassword',
|
||||
newPassword: 'newpassword456',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject password shorter than 8 characters', async () => {
|
||||
const response = await request(app).put('/users/password').send({
|
||||
currentPassword: 'testpassword123',
|
||||
newPassword: 'short',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject missing current password', async () => {
|
||||
const response = await request(app).put('/users/password').send({
|
||||
newPassword: 'newpassword123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export default function (sequelize) {
|
||||
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
router.put('/:userId', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||
router.put('/:userId/role', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||
// param check
|
||||
const userId = req.params.userId;
|
||||
if (!userId) {
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import updateRoute from './update';
|
||||
import updateRoleRoute from './updateRole';
|
||||
import { roles } from './authSettings.js';
|
||||
|
||||
const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator');
|
||||
@@ -44,11 +44,11 @@ describe('updateUserRole', () => {
|
||||
app.use(express.json());
|
||||
|
||||
// Mount the update route
|
||||
app.use('/users', updateRoute(sequelize));
|
||||
app.use('/users', updateRoleRoute(sequelize));
|
||||
});
|
||||
|
||||
it('call update API without new role', async () => {
|
||||
const response = await request(app).put('/users/2').send();
|
||||
const response = await request(app).put('/users/2/role').send();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.text).toBe('newRole is required');
|
||||
@@ -56,7 +56,7 @@ describe('updateUserRole', () => {
|
||||
|
||||
it('promote not existing user to admin will return 404', async () => {
|
||||
mockUser.findByPk.mockResolvedValue(null); // No user found
|
||||
const response = await request(app).put('/users/2').send({
|
||||
const response = await request(app).put('/users/2/role').send({
|
||||
newRole: 0,
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('updateUserRole', () => {
|
||||
const targetUser = { id: 2, role: userRoleIndex, update: vi.fn() }; // Normal user
|
||||
mockUser.findByPk.mockResolvedValue(targetUser);
|
||||
|
||||
const response = await request(app).put('/users/2').send({
|
||||
const response = await request(app).put('/users/2/role').send({
|
||||
newRole: 0,
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('updateUserRole', () => {
|
||||
mockUser.findByPk.mockResolvedValue(targetUser);
|
||||
mockUser.count.mockResolvedValue(1); // Only one admin
|
||||
|
||||
const response = await request(app).put('/users/1').send({
|
||||
const response = await request(app).put('/users/1/role').send({
|
||||
newRole: 1,
|
||||
}); // Downgrading admin to user
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('updateUserRole', () => {
|
||||
// Simulate DB error
|
||||
mockUser.findByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).put('/users/1').send({
|
||||
const response = await request(app).put('/users/1/role').send({
|
||||
newRole: 0,
|
||||
});
|
||||
|
||||
40
backend/routes/users/updateUsername.js
Normal file
40
backend/routes/users/updateUsername.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import express from 'express';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import defineUser from '../../models/users.js';
|
||||
import authMiddleware from '../../middleware/auth.js';
|
||||
const router = express.Router();
|
||||
|
||||
export default function (sequelize) {
|
||||
const { verifySignedIn } = authMiddleware(sequelize);
|
||||
const User = defineUser(sequelize, DataTypes);
|
||||
|
||||
router.put('/username', verifySignedIn, async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username || username.trim().length === 0) {
|
||||
return res.status(400).send('Username is required');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
await user.update({ username: username.trim() });
|
||||
|
||||
// Return updated user without password
|
||||
const updatedUser = await User.findByPk(userId, {
|
||||
attributes: ['id', 'email', 'username', 'role', 'avatarPath'],
|
||||
});
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
86
backend/routes/users/updateUsername.test.js
Normal file
86
backend/routes/users/updateUsername.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import updateUsernameRoute from './updateUsername.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 Profile Routes', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
logging: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/users', updateUsernameRoute(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,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should update username', async () => {
|
||||
const newUsername = 'updatedusername';
|
||||
const response = await request(app).put('/users/username').send({ username: newUsername });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.username).toBe(newUsername);
|
||||
});
|
||||
|
||||
it('should reject empty username', async () => {
|
||||
const response = await request(app).put('/users/username').send({ username: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject whitespace-only username', async () => {
|
||||
const response = await request(app).put('/users/username').send({ username: ' ' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -50,13 +50,19 @@ app.use('/health', healthIndexRoute());
|
||||
import usersIndexRoute from './routes/users/index.js';
|
||||
import usersFindRoute from './routes/users/find.js';
|
||||
import usersSearchRoute from './routes/users/search.js';
|
||||
import usersUpdateRoute from './routes/users/update.js';
|
||||
import usersUpdateUsernameRoute from './routes/users/updateUsername.js';
|
||||
import usersUpdatePasswordRoute from './routes/users/updatePassword.js';
|
||||
import usersUpdateAvatarRoute from './routes/users/updateAvatar.js';
|
||||
import usersUpdateRoleRoute from './routes/users/updateRole.js';
|
||||
import signUpRoute from './routes/users/signup.js';
|
||||
import signInRoute from './routes/users/signin.js';
|
||||
app.use('/users', usersIndexRoute(sequelize));
|
||||
app.use('/users', usersFindRoute(sequelize));
|
||||
app.use('/users', usersSearchRoute(sequelize));
|
||||
app.use('/users', usersUpdateRoute(sequelize));
|
||||
app.use('/users', usersUpdateUsernameRoute(sequelize));
|
||||
app.use('/users', usersUpdatePasswordRoute(sequelize));
|
||||
app.use('/users', usersUpdateAvatarRoute(sequelize));
|
||||
app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||
app.use('/users', signUpRoute(sequelize));
|
||||
app.use('/users', signInRoute(sequelize));
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ erDiagram
|
||||
cases ||--o{ caseSteps: "has"
|
||||
cases ||--o{ caseAttachments: "has"
|
||||
attachments ||--o{ caseAttachments: "has"
|
||||
cases ||--o{ "caseTags(unimplemented)": "has"
|
||||
"tags(unimplemented)" ||--o{ "caseTags(unimplemented)": "has"
|
||||
cases ||--o{ "caseTags": "has"
|
||||
"tags" ||--o{ "caseTags": "has"
|
||||
|
||||
users {
|
||||
integer id PK
|
||||
@@ -139,7 +139,7 @@ erDiagram
|
||||
integer attachmentId FK
|
||||
}
|
||||
|
||||
"tags(unimplemented)" {
|
||||
"tags" {
|
||||
integer id PK
|
||||
string name
|
||||
integer projectId FK
|
||||
@@ -147,7 +147,7 @@ erDiagram
|
||||
timestamp deleted_at
|
||||
}
|
||||
|
||||
"caseTags(unimplemented)" {
|
||||
"caseTags" {
|
||||
integer id PK
|
||||
integer caseId FK
|
||||
integer tagId FK
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
||||
|
||||
# Self hosting
|
||||
|
||||
UnitTCMS is designed for self-hosted use. There are two ways to self-host the application: “Docker” and “Manual”.
|
||||
UnitTCMS is designed for self-hosted use. There are two ways to self-host the application: “Docker” and “From Source”.
|
||||
|
||||
- Docker (Recommended)
|
||||
- From Source
|
||||
|
||||
@@ -5,8 +5,6 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import eslintPluginReact from 'eslint-plugin-react';
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import * as eslintPluginImport from 'eslint-plugin-import';
|
||||
import eslintPluginUnusedImports from 'eslint-plugin-unused-imports';
|
||||
import eslintPluginOnlyWarn from 'eslint-plugin-only-warn';
|
||||
import eslintPluginNext from '@next/eslint-plugin-next';
|
||||
|
||||
export default tseslint.config(
|
||||
@@ -25,11 +23,9 @@ export default tseslint.config(
|
||||
},
|
||||
plugins: {
|
||||
import: eslintPluginImport,
|
||||
'unused-imports': eslintPluginUnusedImports,
|
||||
react: eslintPluginReact,
|
||||
'react-hooks': eslintPluginReactHooks,
|
||||
'@next/next': eslintPluginNext,
|
||||
'only-warn': eslintPluginOnlyWarn,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -46,16 +42,7 @@ export default tseslint.config(
|
||||
extends: [eslint.configs.recommended],
|
||||
rules: {
|
||||
'import/order': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'no-unused-vars': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -67,7 +54,6 @@ export default tseslint.config(
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'react/prop-types': 'off',
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function HealthPage({ messages, locale }: Props) {
|
||||
<TableBody>
|
||||
<TableRow key="1">
|
||||
<TableCell>{messages.unittcms_version}</TableCell>
|
||||
<TableCell>1.0.0-beta.21</TableCell>
|
||||
<TableCell>1.0.0-beta.22</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key="2">
|
||||
<TableCell>{messages.api_server}</TableCell>
|
||||
|
||||
@@ -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,10 @@ 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 UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
type Props = {
|
||||
members: MemberType[];
|
||||
@@ -56,14 +56,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 };
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -16,9 +16,7 @@
|
||||
"eslint-config-next": "^15.3.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
@@ -3438,15 +3436,6 @@
|
||||
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-only-warn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-only-warn/-/eslint-plugin-only-warn-1.1.0.tgz",
|
||||
"integrity": "sha512-2tktqUAT+Q3hCAU0iSf4xAN1k9zOpjK5WO8104mB0rT/dGhOa09582HN5HlbxNbPRZ0THV7nLGvzugcNOSjzfA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react": {
|
||||
"version": "7.37.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
||||
@@ -3517,21 +3506,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-unused-imports": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
|
||||
"integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
|
||||
"eslint": "^9.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
"eslint-config-next": "^15.3.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"express": "^4.21.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
|
||||
Reference in New Issue
Block a user