Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ api.interceptors.request.use((config) => {
return config
})

export const updateUser = (userId: string, username: string, config?: AxiosRequestConfig) =>
api.put<{ username: string, token: string }>(`/users/${userId}`, { username }, config);

import { useToastStore } from "../stores/toast.store";

// Global Error Handling
Expand Down
17 changes: 16 additions & 1 deletion client/src/stores/user.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ export const useUserStore = defineStore("user", () => {
localStorage.setItem("authToken", authToken)
localStorage.setItem("userId", userId)
localStorage.setItem("isAdmin", String(admin))

}

async function updateProfile(newUsername: string) {
if (!id.value) return;
// Import dynamically to avoid circular dependency if any, or move import to top if safe
const api = await import('../services/api');

const response = await api.updateUser(id.value, newUsername);
const { username: name, token: newToken } = response.data;

username.value = name;
token.value = newToken;
localStorage.setItem("username", name);
localStorage.setItem("authToken", newToken);
}

function logout(): void {
Expand All @@ -31,5 +46,5 @@ export const useUserStore = defineStore("user", () => {

const isAuthenticated = computed(() => !!token.value)

return { username, token, id, isAdmin, isAuthenticated, login, logout }
return { username, token, id, isAdmin, isAuthenticated, login, logout, updateProfile }
})
99 changes: 98 additions & 1 deletion client/src/views/ProfileView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,24 @@

<div class="form-group">
<label>Username:</label>
<input type="text" :value="userStore.username" disabled class="input-field" />
<div class="username-edit-group">
<input
type="text"
v-model="editUsername"
:disabled="!isEditing"
class="input-field"
:class="{ 'editing': isEditing }"
/>
<div class="edit-actions">
<button v-if="!isEditing" @click="startEditing" class="edit-btn">Edit</button>
<div v-else class="action-buttons">
<button @click="saveUsername" class="save-btn" :disabled="isSaving">
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<button @click="cancelEditing" class="cancel-btn" :disabled="isSaving">Cancel</button>
</div>
</div>
</div>
</div>

<button class="logout-btn" @click="handleLogout">
Expand Down Expand Up @@ -64,6 +81,43 @@ const router = useRouter();
const userStore = useUserStore();
const notificationStore = useInAppNotificationStore();
const { isMuted } = storeToRefs(notificationStore);
import { useToastStore } from '../stores/toast.store';

const toastStore = useToastStore();
const isEditing = ref(false);
const isSaving = ref(false);
const editUsername = ref(userStore.username);

const startEditing = () => {
editUsername.value = userStore.username;
isEditing.value = true;
};

const cancelEditing = () => {
isEditing.value = false;
editUsername.value = userStore.username;
};

const saveUsername = async () => {
if (!editUsername.value.trim() || editUsername.value === userStore.username) {
isEditing.value = false;
return;
}

try {
isSaving.value = true;
await userStore.updateProfile(editUsername.value);
toastStore.add('Username updated successfully', 'success');
isEditing.value = false;
} catch (error: any) {
// Show specific error feedback
const errorMessage = error.response?.data?.error || "Failed to update username";
toastStore.add(errorMessage, 'error');
console.error("Failed to update username", error);
} finally {
isSaving.value = false;
}
};

const currentTheme = ref('light');

Expand Down Expand Up @@ -267,4 +321,47 @@ const toggleNotifications = () => {
padding: 1.5rem;
}
}

/* Edit Styles */
.username-edit-group {
display: flex;
gap: 1rem;
align-items: center;
}

.edit-actions {
display: flex;
align-items: center;
}

.action-buttons {
display: flex;
gap: 0.5rem;
}

.edit-btn, .cancel-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
cursor: pointer;
font-size: 0.85rem;
}

.save-btn {
padding: 0.5rem 1rem;
background: #10b981;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.85rem;
}

.save-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}

</style>
29 changes: 29 additions & 0 deletions server/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express"
import { UserService } from "../services/user.service.js"
import { AppError } from "../utils/AppError.js"

export class UserController {
static async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
Expand All @@ -11,4 +12,32 @@ export class UserController {
next(err)
}
}

static async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params
const { username } = req.body
const authUser = (req as any).user

if (!authUser || authUser.id !== id) {
throw new AppError("Unauthorized", 403)
}

if (!username || username.trim().length === 0) {
throw new AppError("Username is required", 400)
}

try {
const result = await UserService.updateUser(id, username)
res.json(result)
} catch (err: any) {
if (err.message === "Username already taken") {
throw new AppError("Username already taken", 409)
}
throw err
}
} catch (err) {
next(err)
}
}
}
1 change: 1 addition & 0 deletions server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ router.post("/login", LoginController.login)

// Users
router.get("/users", authenticateToken, UserController.getAll)
router.put("/users/:id", authenticateToken, UserController.update)

// Test Error

Expand Down
45 changes: 38 additions & 7 deletions server/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ interface LoginResponse {
isAdmin: boolean
}

interface UpdateUserResponse {
username: string
token: string
}

interface AllUsersResponse {
username: string
isAdmin: boolean
_id?: string
}

export class UserService {
/**
* Handle user login/registration and token generation
*/
static async login(username: string): Promise<LoginResponse> {
const JWT_SECRET = CONFIG.JWT.SECRET

Expand All @@ -31,7 +33,8 @@ export class UserService {
isNewUser = true
}

const token = jwt.sign({ id: user._id, username: user.username, isAdmin: user.isAdmin }, JWT_SECRET, { expiresIn: CONFIG.JWT.EXPIRES_IN as any })

const token = this.generateToken(user)

return {
username: user.username,
Expand All @@ -42,9 +45,6 @@ export class UserService {
}
}

/**
* Retrieve all users with limited fields
*/
static async getAllUsers(): Promise<AllUsersResponse[]> {
const users = await User.find({}, "username isAdmin")
return users.map((user) => ({
Expand All @@ -53,4 +53,35 @@ export class UserService {
_id: user._id?.toString(),
}))
}

static async updateUser(userId: string, newUsername: string): Promise<UpdateUserResponse> {
const JWT_SECRET = CONFIG.JWT.SECRET

const existing = await User.findOne({ username: newUsername })
if (existing && existing._id.toString() !== userId) {
throw new Error("Username already taken")
}

const user = await User.findByIdAndUpdate(userId, { username: newUsername }, { new: true })
if (!user) {
throw new Error("User not found")
}

const token = this.generateToken(user)

return {
username: user.username,
token
}
}

private static generateToken(user: any): string {
const JWT_SECRET = CONFIG.JWT.SECRET
return jwt.sign(
{ id: user._id, username: user.username, isAdmin: user.isAdmin },
JWT_SECRET,
{ expiresIn: CONFIG.JWT.EXPIRES_IN as any }
)
}
}