feat: user profile customization: username, password, and avatar (#315)
This commit is contained in:
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 { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
|
||||||
const User = defineUser(sequelize, DataTypes);
|
const User = defineUser(sequelize, DataTypes);
|
||||||
|
|
||||||
router.put('/:userId', verifySignedIn, verifyAdmin, async (req, res) => {
|
router.put('/:userId/role', verifySignedIn, verifyAdmin, async (req, res) => {
|
||||||
// param check
|
// param check
|
||||||
const userId = req.params.userId;
|
const userId = req.params.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { Sequelize } from 'sequelize';
|
import { Sequelize } from 'sequelize';
|
||||||
import updateRoute from './update';
|
import updateRoleRoute from './updateRole';
|
||||||
import { roles } from './authSettings.js';
|
import { roles } from './authSettings.js';
|
||||||
|
|
||||||
const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator');
|
const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator');
|
||||||
@@ -44,11 +44,11 @@ describe('updateUserRole', () => {
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Mount the update route
|
// Mount the update route
|
||||||
app.use('/users', updateRoute(sequelize));
|
app.use('/users', updateRoleRoute(sequelize));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('call update API without new role', async () => {
|
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.status).toBe(400);
|
||||||
expect(response.text).toBe('newRole is required');
|
expect(response.text).toBe('newRole is required');
|
||||||
@@ -56,7 +56,7 @@ describe('updateUserRole', () => {
|
|||||||
|
|
||||||
it('promote not existing user to admin will return 404', async () => {
|
it('promote not existing user to admin will return 404', async () => {
|
||||||
mockUser.findByPk.mockResolvedValue(null); // No user found
|
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,
|
newRole: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe('updateUserRole', () => {
|
|||||||
const targetUser = { id: 2, role: userRoleIndex, update: vi.fn() }; // Normal user
|
const targetUser = { id: 2, role: userRoleIndex, update: vi.fn() }; // Normal user
|
||||||
mockUser.findByPk.mockResolvedValue(targetUser);
|
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,
|
newRole: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ describe('updateUserRole', () => {
|
|||||||
mockUser.findByPk.mockResolvedValue(targetUser);
|
mockUser.findByPk.mockResolvedValue(targetUser);
|
||||||
mockUser.count.mockResolvedValue(1); // Only one admin
|
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,
|
newRole: 1,
|
||||||
}); // Downgrading admin to user
|
}); // Downgrading admin to user
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ describe('updateUserRole', () => {
|
|||||||
// Simulate DB error
|
// Simulate DB error
|
||||||
mockUser.findByPk.mockRejectedValue(new Error('Database 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,
|
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 usersIndexRoute from './routes/users/index.js';
|
||||||
import usersFindRoute from './routes/users/find.js';
|
import usersFindRoute from './routes/users/find.js';
|
||||||
import usersSearchRoute from './routes/users/search.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 signUpRoute from './routes/users/signup.js';
|
||||||
import signInRoute from './routes/users/signin.js';
|
import signInRoute from './routes/users/signin.js';
|
||||||
app.use('/users', usersIndexRoute(sequelize));
|
app.use('/users', usersIndexRoute(sequelize));
|
||||||
app.use('/users', usersFindRoute(sequelize));
|
app.use('/users', usersFindRoute(sequelize));
|
||||||
app.use('/users', usersSearchRoute(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', signUpRoute(sequelize));
|
||||||
app.use('/users', signInRoute(sequelize));
|
app.use('/users', signInRoute(sequelize));
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
|
import { Avatar as HeroUiAvatar } from '@heroui/react';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
import Avatar from 'boring-avatars';
|
import Avatar from 'boring-avatars';
|
||||||
import { TokenContextType } from '@/types/user';
|
import Config from '@/config/config';
|
||||||
|
const apiServer = Config.apiServer;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: TokenContextType;
|
size: number;
|
||||||
|
username: string | undefined | null;
|
||||||
|
avatarPath?: string | undefined | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PublicityChip({ context }: Props) {
|
export default function UserAvatar({ size, username, avatarPath }: Props) {
|
||||||
return context.isSignedIn() ? (
|
if (username) {
|
||||||
|
if (avatarPath) {
|
||||||
|
return <HeroUiAvatar style={{ width: size, height: size }} src={`${apiServer}${avatarPath}`} />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
size={16}
|
size={size}
|
||||||
name={context.token?.user?.username}
|
name={username}
|
||||||
variant="beam"
|
variant="beam"
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<User size={16} />
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return <User size={size} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"admin": "Administration",
|
"admin": "Administration",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
|
"profile_settings": "Profile Settings",
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
"signin": "Sign in",
|
"signin": "Sign in",
|
||||||
"signout": "Sign out",
|
"signout": "Sign out",
|
||||||
@@ -112,7 +113,27 @@
|
|||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
"not_own_any_projects": "You don't own any projects.",
|
"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": {
|
||||||
"health_check": "Health Check",
|
"health_check": "Health Check",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"projects": "プロジェクト",
|
"projects": "プロジェクト",
|
||||||
"admin": "管理",
|
"admin": "管理",
|
||||||
"account": "アカウント",
|
"account": "アカウント",
|
||||||
|
"profile_settings": "プロフィール設定",
|
||||||
"signup": "新規登録",
|
"signup": "新規登録",
|
||||||
"signin": "サインイン",
|
"signin": "サインイン",
|
||||||
"signout": "サインアウト",
|
"signout": "サインアウト",
|
||||||
@@ -113,7 +114,27 @@
|
|||||||
"public": "パブリック",
|
"public": "パブリック",
|
||||||
"private": "プライベート",
|
"private": "プライベート",
|
||||||
"not_own_any_projects": "所有しているプロジェクトがありません。",
|
"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": {
|
"Admin": {
|
||||||
"user_management": "ユーザー管理",
|
"user_management": "ユーザー管理",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"projects": "Projetos",
|
"projects": "Projetos",
|
||||||
"admin": "Administração",
|
"admin": "Administração",
|
||||||
"account": "Conta",
|
"account": "Conta",
|
||||||
|
"profile_settings": "Configurações do Perfil",
|
||||||
"signup": "Cadastre-se",
|
"signup": "Cadastre-se",
|
||||||
"signin": "Entrar",
|
"signin": "Entrar",
|
||||||
"signout": "Sair",
|
"signout": "Sair",
|
||||||
@@ -112,7 +113,27 @@
|
|||||||
"public": "Público",
|
"public": "Público",
|
||||||
"private": "Privado",
|
"private": "Privado",
|
||||||
"not_own_any_projects": "Você não possui nenhum projeto.",
|
"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": {
|
||||||
"health_check": "Verificação de Saúde",
|
"health_check": "Verificação de Saúde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Button, DropdownTrigger, Dropdown, DropdownMenu, DropdownItem } from '@heroui/react';
|
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 { useContext } from 'react';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { useRouter } from '@/src/i18n/routing';
|
import { useRouter } from '@/src/i18n/routing';
|
||||||
@@ -31,12 +31,23 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
|
|||||||
{
|
{
|
||||||
uid: 'account',
|
uid: 'account',
|
||||||
title: messages.account,
|
title: messages.account,
|
||||||
icon: <UserAvatar context={context} />,
|
icon: (
|
||||||
|
<UserAvatar size={16} username={context.token?.user?.username} avatarPath={context.token?.user?.avatarPath} />
|
||||||
|
),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: locale });
|
||||||
onItemPress();
|
onItemPress();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
uid: 'profile',
|
||||||
|
title: messages.profileSettings,
|
||||||
|
icon: <Settings size={16} />,
|
||||||
|
onPress: () => {
|
||||||
|
router.push('/account/settings', { locale: locale });
|
||||||
|
onItemPress();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
uid: 'signout',
|
uid: 'signout',
|
||||||
title: messages.signOut,
|
title: messages.signOut,
|
||||||
@@ -75,7 +86,13 @@ export default function DropdownAccount({ messages, locale, onItemPress }: Props
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
startContent={<UserAvatar context={context} />}
|
startContent={
|
||||||
|
<UserAvatar
|
||||||
|
size={16}
|
||||||
|
username={context.token?.user?.username}
|
||||||
|
avatarPath={context.token?.user?.avatarPath}
|
||||||
|
/>
|
||||||
|
}
|
||||||
endContent={<ChevronDown size={16} />}
|
endContent={<ChevronDown size={16} />}
|
||||||
>
|
>
|
||||||
{context.isSignedIn() ? context.token?.user?.username : messages.signIn}
|
{context.isSignedIn() ? context.token?.user?.username : messages.signIn}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function Header(params: { locale: LocaleCodeType }) {
|
|||||||
docs: t('docs'),
|
docs: t('docs'),
|
||||||
roadmap: t('roadmap'),
|
roadmap: t('roadmap'),
|
||||||
account: t('account'),
|
account: t('account'),
|
||||||
|
profileSettings: t('profile_settings'),
|
||||||
signUp: t('signup'),
|
signUp: t('signup'),
|
||||||
signIn: t('signin'),
|
signIn: t('signin'),
|
||||||
signOut: t('signout'),
|
signOut: t('signout'),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ListboxItem,
|
ListboxItem,
|
||||||
Listbox,
|
Listbox,
|
||||||
} from '@heroui/react';
|
} 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 DropdownAccount from './DropdownAccount';
|
||||||
import DropdownLanguage from './DropdownLanguage';
|
import DropdownLanguage from './DropdownLanguage';
|
||||||
import { ThemeSwitch } from '@/components/ThemeSwitch';
|
import { ThemeSwitch } from '@/components/ThemeSwitch';
|
||||||
@@ -31,6 +31,7 @@ type NabbarMenuMessages = {
|
|||||||
docs: string;
|
docs: string;
|
||||||
roadmap: string;
|
roadmap: string;
|
||||||
account: string;
|
account: string;
|
||||||
|
profileSettings: string;
|
||||||
signUp: string;
|
signUp: string;
|
||||||
signIn: string;
|
signIn: string;
|
||||||
signOut: string;
|
signOut: string;
|
||||||
@@ -185,12 +186,27 @@ export default function HeaderNavbarMenu({ messages, locale }: Props) {
|
|||||||
<ListboxItem
|
<ListboxItem
|
||||||
key="account"
|
key="account"
|
||||||
title={messages.account}
|
title={messages.account}
|
||||||
startContent={<UserAvatar context={context} />}
|
startContent={
|
||||||
|
<UserAvatar
|
||||||
|
size={16}
|
||||||
|
username={context.token?.user?.username}
|
||||||
|
avatarPath={context.token?.user?.avatarPath}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push('/account', { locale: locale });
|
router.push('/account', { locale: locale });
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ListboxItem
|
||||||
|
key="profile"
|
||||||
|
title={messages.profileSettings}
|
||||||
|
startContent={<Settings size={16} />}
|
||||||
|
onPress={() => {
|
||||||
|
router.push('/account/settings', { locale: locale });
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ListboxItem
|
<ListboxItem
|
||||||
key="signout"
|
key="signout"
|
||||||
title={messages.signOut}
|
title={messages.signOut}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button, Card, CardHeader, CardFooter } from '@heroui/react';
|
import { Button, Card, CardHeader, CardFooter } from '@heroui/react';
|
||||||
import Avatar from 'boring-avatars';
|
import { ArrowRight, Settings } from 'lucide-react';
|
||||||
import { ArrowRight } from 'lucide-react';
|
|
||||||
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
import { Link, NextUiLinkClasses } from '@/src/i18n/routing';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
import { fetchMyProjects } from '@/utils/projectsControl';
|
import { fetchMyProjects } from '@/utils/projectsControl';
|
||||||
import { ProjectType } from '@/types/project';
|
import { ProjectType } from '@/types/project';
|
||||||
import PublicityChip from '@/components/PublicityChip';
|
import PublicityChip from '@/components/PublicityChip';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import { LocaleCodeType } from '@/types/locale';
|
import { LocaleCodeType } from '@/types/locale';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ type AccountPageMessages = {
|
|||||||
private: string;
|
private: string;
|
||||||
notOwnAnyProjects: string;
|
notOwnAnyProjects: string;
|
||||||
findProjects: string;
|
findProjects: string;
|
||||||
|
profileSettings: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
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="container mx-auto max-w-3xl pt-6 px-6 flex-grow">
|
||||||
<div className="w-full p-3 flex items-center justify-between">
|
<div className="w-full p-3 flex items-center justify-between">
|
||||||
<Card className="w-[600px]">
|
<Card className="w-[600px]">
|
||||||
<CardHeader className="flex gap-6">
|
<CardHeader className="flex gap-6 justify-between">
|
||||||
<Avatar
|
<div className="flex gap-6">
|
||||||
|
<UserAvatar
|
||||||
size={48}
|
size={48}
|
||||||
name={context.token?.user?.username}
|
username={context.token?.user?.username}
|
||||||
variant="beam"
|
avatarPath={context.token?.user?.avatarPath}
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-xl font-bold">{context.token?.user?.username}</p>
|
<p className="text-xl font-bold">{context.token?.user?.username}</p>
|
||||||
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
|
<p className="text-lg text-default-500">{context.token?.user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="/account/settings"
|
||||||
|
locale={locale}
|
||||||
|
variant="flat"
|
||||||
|
size="sm"
|
||||||
|
startContent={<Settings size={16} />}
|
||||||
|
>
|
||||||
|
{messages.profileSettings}
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function Page({ params }: PageType) {
|
|||||||
private: t('private'),
|
private: t('private'),
|
||||||
notOwnAnyProjects: t('not_own_any_projects'),
|
notOwnAnyProjects: t('not_own_any_projects'),
|
||||||
findProjects: t('find_projects'),
|
findProjects: t('find_projects'),
|
||||||
|
profileSettings: t('profile_settings'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AccountPage messages={messages} locale={params.locale as LocaleCodeType} />;
|
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,
|
DropdownItem,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import Avatar from 'boring-avatars';
|
|
||||||
import { UserType, AdminMessages } from '@/types/user';
|
import { UserType, AdminMessages } from '@/types/user';
|
||||||
import { roles } from '@/config/selection';
|
import { roles } from '@/config/selection';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
users: UserType[];
|
users: UserType[];
|
||||||
@@ -62,14 +62,7 @@ export default function UsersTable({ users, myself, onChangeRole, messages }: Pr
|
|||||||
|
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'avatar':
|
case 'avatar':
|
||||||
return (
|
return <UserAvatar size={24} username={user.username} avatarPath={user.avatarPath} />;
|
||||||
<Avatar
|
|
||||||
size={24}
|
|
||||||
name={user.username}
|
|
||||||
variant="beam"
|
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'id':
|
case 'id':
|
||||||
return <span>{cellValue}</span>;
|
return <span>{cellValue}</span>;
|
||||||
case 'email':
|
case 'email':
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
||||||
import Avatar from 'boring-avatars';
|
|
||||||
import { UserType } from '@/types/user';
|
import { UserType } from '@/types/user';
|
||||||
import { MembersMessages } from '@/types/member';
|
import { MembersMessages } from '@/types/member';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
candidates: UserType[];
|
candidates: UserType[];
|
||||||
@@ -24,14 +24,7 @@ export default function MembersTable({ candidates, onAddPress, messages }: Props
|
|||||||
|
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'avatar':
|
case 'avatar':
|
||||||
return (
|
return <UserAvatar size={24} username={candidate.username} avatarPath={candidate.avatarPath} />;
|
||||||
<Avatar
|
|
||||||
size={16}
|
|
||||||
name={candidate.username}
|
|
||||||
variant="beam"
|
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'email':
|
case 'email':
|
||||||
return cellValue;
|
return cellValue;
|
||||||
case 'username':
|
case 'username':
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import Avatar from 'boring-avatars';
|
|
||||||
import { MemberType, UserType } from '@/types/user';
|
import { MemberType, UserType } from '@/types/user';
|
||||||
import { memberRoles } from '@/config/selection';
|
import { memberRoles } from '@/config/selection';
|
||||||
import { MembersMessages } from '@/types/member';
|
import { MembersMessages } from '@/types/member';
|
||||||
|
import Config from '@/config/config';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
members: MemberType[];
|
members: MemberType[];
|
||||||
@@ -56,14 +57,7 @@ export default function MembersTable({ members, isDisabled, onChangeRole, onDele
|
|||||||
|
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case 'avatar':
|
case 'avatar':
|
||||||
return (
|
return <UserAvatar size={24} username={member.User.username} avatarPath={member.User.avatarPath} />;
|
||||||
<Avatar
|
|
||||||
size={24}
|
|
||||||
name={member.User.username}
|
|
||||||
variant="beam"
|
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'email':
|
case 'email':
|
||||||
return member.User.email;
|
return member.User.email;
|
||||||
case 'username':
|
case 'username':
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useContext } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
import { Button, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react';
|
||||||
import Avatar from 'boring-avatars';
|
|
||||||
import { Pencil, Trash } from 'lucide-react';
|
import { Pencil, Trash } from 'lucide-react';
|
||||||
import { SettingsMessages } from '@/types/settings';
|
import { SettingsMessages } from '@/types/settings';
|
||||||
import { TokenContext } from '@/utils/TokenProvider';
|
import { TokenContext } from '@/utils/TokenProvider';
|
||||||
@@ -13,6 +12,7 @@ import ProjectDialog from '@/components/ProjectDialog';
|
|||||||
import { UserType } from '@/types/user';
|
import { UserType } from '@/types/user';
|
||||||
import { findUser } from '@/utils/usersControl';
|
import { findUser } from '@/utils/usersControl';
|
||||||
import { logError } from '@/utils/errorHandler';
|
import { logError } from '@/utils/errorHandler';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -130,12 +130,7 @@ export default function SettingsPage({ projectId, messages, projectDialogMessage
|
|||||||
<TableCell>{messages.projectOwner}</TableCell>
|
<TableCell>{messages.projectOwner}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Avatar
|
<UserAvatar size={24} username={owner.username} avatarPath={owner.avatarPath} />
|
||||||
size={24}
|
|
||||||
name={owner.username}
|
|
||||||
variant="beam"
|
|
||||||
colors={['#0A0310', '#49007E', '#FF005B', '#FF7D10', '#FFB238']}
|
|
||||||
/>
|
|
||||||
<p className="">{owner.username}</p>
|
<p className="">{owner.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type TokenType = {
|
|||||||
export type TokenContextType = {
|
export type TokenContextType = {
|
||||||
token: {
|
token: {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
expires_at: number;
|
||||||
user: UserType | null;
|
user: UserType | null;
|
||||||
};
|
};
|
||||||
isSignedIn: () => boolean;
|
isSignedIn: () => boolean;
|
||||||
@@ -87,6 +88,7 @@ export type AdminMessages = {
|
|||||||
|
|
||||||
export type AccountDropDownMessages = {
|
export type AccountDropDownMessages = {
|
||||||
account: string;
|
account: string;
|
||||||
|
profileSettings: string;
|
||||||
signUp: string;
|
signUp: string;
|
||||||
signIn: string;
|
signIn: string;
|
||||||
signOut: string;
|
signOut: string;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function removeTokenFromLocalStorage() {
|
|||||||
const defaultContext = {
|
const defaultContext = {
|
||||||
token: {
|
token: {
|
||||||
access_token: '',
|
access_token: '',
|
||||||
|
expires_at: 0,
|
||||||
user: null,
|
user: null,
|
||||||
},
|
},
|
||||||
isSignedIn: () => false,
|
isSignedIn: () => false,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
|
|||||||
body: JSON.stringify(updateUserData),
|
body: JSON.stringify(updateUserData),
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${apiServer}/users/${userId}`;
|
const url = `${apiServer}/users/${userId}/role`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, fetchOptions);
|
const response = await fetch(url, fetchOptions);
|
||||||
@@ -76,4 +76,117 @@ async function updateUserRole(jwt: string, userId: number, newRole: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { findUser, searchUsers, updateUserRole };
|
async function updateUsername(jwt: string, username: string) {
|
||||||
|
const updateData = {
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/username`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating username:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(jwt: string, currentPassword: string, newPassword: string) {
|
||||||
|
const updateData = {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/password`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error updating password:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(jwt: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/avatar`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error uploading avatar:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAvatar(jwt: string) {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${apiServer}/users/avatar`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logError('Error deleting avatar:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { findUser, searchUsers, updateUserRole, updateUsername, updatePassword, uploadAvatar, deleteAvatar };
|
||||||
|
|||||||
Reference in New Issue
Block a user