From c9a9f847f7e00aa553ac72ed81e3dd6c1e274766 Mon Sep 17 00:00:00 2001 From: lenafx Date: Thu, 11 Dec 2025 19:45:20 +0100 Subject: [PATCH] added optional usser password --- package-lock.json | 37 ++ package.json | 2 + src/api/user/user.controller.ts | 93 ++++- src/api/user/user.routes.ts | 1 + src/api/user/user.service.ts | 76 +++- src/scripts/users.js | 658 +++++++++++++++++++++++--------- views/css/users.css | 110 +++++- 7 files changed, 748 insertions(+), 229 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e0876c..c992483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fastify/static": "^8.3.0", "@ryuziii/discord-rpc": "^1.0.1-rc.1", + "bcrypt": "^6.0.0", "bindings": "^1.5.0", "dotenv": "^17.2.3", "fastify": "^5.6.2", @@ -20,6 +21,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", "electron": "^39.2.5", @@ -467,6 +469,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -765,6 +777,20 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2604,6 +2630,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", diff --git a/package.json b/package.json index 1aa0174..be573ab 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@fastify/static": "^8.3.0", "@ryuziii/discord-rpc": "^1.0.1-rc.1", + "bcrypt": "^6.0.0", "bindings": "^1.5.0", "dotenv": "^17.2.3", "fastify": "^5.6.2", @@ -24,6 +25,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", "electron": "^39.2.5", diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts index 976fbfc..3a89d69 100644 --- a/src/api/user/user.controller.ts +++ b/src/api/user/user.controller.ts @@ -4,8 +4,20 @@ import {queryOne} from '../../shared/database'; import jwt from "jsonwebtoken"; interface UserIdParams { id: string; } -interface CreateUserBody { username: string; profilePictureUrl?: string; } -interface UpdateUserBody { username?: string; profilePictureUrl?: string | null; password?: string; } +interface CreateUserBody { + username: string; + profilePictureUrl?: string; + password?: string; +} +interface UpdateUserBody { + username?: string; + profilePictureUrl?: string | null; + password?: string | null; +} +interface LoginBody { + userId: number; + password?: string; +} interface DBRunResult { changes: number; lastID: number; } @@ -19,7 +31,7 @@ export async function getMe(req: any, reply: any) { const user = await queryOne( `SELECT username, profile_picture_url FROM User WHERE id = ?`, [userId], - 'userdata' // ✅ DB correcta + 'userdata' ); if (!user) { @@ -33,16 +45,34 @@ export async function getMe(req: any, reply: any) { } export async function login(req: FastifyRequest, reply: FastifyReply) { - const { userId } = req.body as { userId: number }; + const { userId, password } = req.body as LoginBody; if (!userId || typeof userId !== "number" || userId <= 0) { return reply.code(400).send({ error: "Invalid userId provided" }); } - if (!await userService.userExists(userId)) { + const user = await userService.getUserById(userId); + + if (!user) { return reply.code(404).send({ error: "User not found in local database" }); } + // Si el usuario tiene contraseña, debe proporcionarla + if (user.has_password) { + if (!password) { + return reply.code(401).send({ + error: "Password required", + requiresPassword: true + }); + } + + const isValid = await userService.verifyPassword(userId, password); + + if (!isValid) { + return reply.code(401).send({ error: "Incorrect password" }); + } + } + const token = jwt.sign( { id: userId }, process.env.JWT_SECRET!, @@ -67,17 +97,20 @@ export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) { export async function createUser(req: FastifyRequest, reply: FastifyReply) { try { - const { username, profilePictureUrl } = req.body as CreateUserBody; + const { username, profilePictureUrl, password } = req.body as CreateUserBody; if (!username) { return reply.code(400).send({ error: "Missing required field: username" }); } - const result: any = await userService.createUser(username, profilePictureUrl); + const result: any = await userService.createUser(username, profilePictureUrl, password); - return reply.code(201).send({ success: true, userId: result.lastID, username }); + return reply.code(201).send({ + success: true, + userId: result.lastID, + username + }); } catch (err) { - if ((err as Error).message.includes('SQLITE_CONSTRAINT')) { return reply.code(409).send({ error: "Username already exists." }); } @@ -119,7 +152,6 @@ export async function updateUser(req: FastifyRequest, reply: FastifyReply) { if (result && result.changes > 0) { return { success: true, message: "User updated successfully" }; } else { - return reply.code(404).send({ error: "User not found or nothing to update" }); } } catch (err) { @@ -194,3 +226,44 @@ export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply return reply.code(500).send({ error: "Failed to disconnect AniList" }); } } + +export async function changePassword(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + const { currentPassword, newPassword } = req.body as { + currentPassword?: string; + newPassword: string | null; + }; + const userId = parseInt(id, 10); + + if (!userId || isNaN(userId)) { + return reply.code(400).send({ error: "Invalid user id" }); + } + + const user = await userService.getUserById(userId); + + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + // Si el usuario tiene contraseña actual, debe proporcionar la contraseña actual + if (user.has_password && currentPassword) { + const isValid = await userService.verifyPassword(userId, currentPassword); + + if (!isValid) { + return reply.code(401).send({ error: "Current password is incorrect" }); + } + } + + // Actualizar la contraseña (null para eliminarla, string para establecerla) + await userService.updateUser(userId, { password: newPassword }); + + return reply.send({ + success: true, + message: newPassword ? "Password updated successfully" : "Password removed successfully" + }); + } catch (err) { + console.error("Change Password Error:", err); + return reply.code(500).send({ error: "Failed to change password" }); + } +} \ No newline at end of file diff --git a/src/api/user/user.routes.ts b/src/api/user/user.routes.ts index 3fd2a06..4d769e4 100644 --- a/src/api/user/user.routes.ts +++ b/src/api/user/user.routes.ts @@ -11,6 +11,7 @@ async function userRoutes(fastify: FastifyInstance) { fastify.delete('/users/:id', controller.deleteUser); fastify.get('/users/:id/integration', controller.getIntegrationStatus); fastify.delete('/users/:id/integration', controller.disconnectAniList); + fastify.put('/users/:id/password', controller.changePassword); } export default userRoutes; \ No newline at end of file diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts index b11ac9e..fadb477 100644 --- a/src/api/user/user.service.ts +++ b/src/api/user/user.service.ts @@ -1,21 +1,34 @@ import {queryAll, queryOne, run} from '../../shared/database'; +import bcrypt from 'bcrypt'; const USER_DB_NAME = 'userdata'; +const SALT_ROUNDS = 10; -interface User { id: number; username: string; profile_picture_url: string | null; } +interface User { + id: number; + username: string; + profile_picture_url: string | null; + has_password: boolean; +} export async function userExists(id: number): Promise { const sql = 'SELECT 1 FROM User WHERE id = ?'; - const row = queryOne(sql, [id], USER_DB_NAME); + const row = await queryOne(sql, [id], USER_DB_NAME); return !!row; } -export async function createUser(username: string, profilePictureUrl?: string): Promise<{ lastID: number }> { +export async function createUser(username: string, profilePictureUrl?: string, password?: string): Promise<{ lastID: number }> { + let passwordHash = null; + + if (password && password.trim()) { + passwordHash = await bcrypt.hash(password.trim(), SALT_ROUNDS); + } + const sql = ` - INSERT INTO User (username, profile_picture_url) - VALUES (?, ?) + INSERT INTO User (username, profile_picture_url, password_hash) + VALUES (?, ?, ?) `; - const params = [username, profilePictureUrl || null]; + const params = [username, profilePictureUrl || null, passwordHash]; const result = await run(sql, params, USER_DB_NAME); @@ -30,13 +43,23 @@ export async function updateUser(userId: number, updates: any): Promise { fields.push('username = ?'); values.push(updates.username); } - if (updates.profilePictureUrl !== undefined) { + if (updates.profilePictureUrl !== undefined) { fields.push('profile_picture_url = ?'); values.push(updates.profilePictureUrl); } - if (updates.password !== undefined) { + if (updates.password !== undefined) { + if (updates.password === null || updates.password === '') { + // Eliminar contraseña + fields.push('password_hash = ?'); + values.push(null); + } else { + // Actualizar contraseña + const hash = await bcrypt.hash(updates.password.trim(), SALT_ROUNDS); + fields.push('password_hash = ?'); + values.push(hash); + } } if (fields.length === 0) { @@ -51,7 +74,6 @@ export async function updateUser(userId: number, updates: any): Promise { } export async function deleteUser(userId: number): Promise { - await run( `DELETE FROM ListEntry WHERE user_id = ?`, [userId], @@ -79,22 +101,34 @@ export async function deleteUser(userId: number): Promise { return result; } - export async function getAllUsers(): Promise { - const sql = 'SELECT id, username, profile_picture_url FROM User ORDER BY id'; + const sql = ` + SELECT + id, + username, + profile_picture_url, + CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password + FROM User + ORDER BY id + `; const users = await queryAll(sql, [], USER_DB_NAME); return users.map((user: any) => ({ id: user.id, username: user.username, - profile_picture_url: user.profile_picture_url || null + profile_picture_url: user.profile_picture_url || null, + has_password: !!user.has_password })) as User[]; } export async function getUserById(id: number): Promise { const sql = ` - SELECT id, username, profile_picture_url, email + SELECT + id, + username, + profile_picture_url, + CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password FROM User WHERE id = ? `; @@ -106,10 +140,22 @@ export async function getUserById(id: number): Promise { return { id: user.id, username: user.username, - profile_picture_url: user.profile_picture_url || null + profile_picture_url: user.profile_picture_url || null, + has_password: !!user.has_password }; } +export async function verifyPassword(userId: number, password: string): Promise { + const sql = 'SELECT password_hash FROM User WHERE id = ?'; + const user = await queryOne(sql, [userId], USER_DB_NAME); + + if (!user || !user.password_hash) { + return false; + } + + return await bcrypt.compare(password, user.password_hash); +} + export async function getAniListIntegration(userId: number) { const sql = ` SELECT anilist_user_id, expires_at @@ -137,4 +183,4 @@ export async function removeAniListIntegration(userId: number) { `; return run(sql, [userId, "AniList"], USER_DB_NAME); -} +} \ No newline at end of file diff --git a/src/scripts/users.js b/src/scripts/users.js index be21eca..8a268e6 100644 --- a/src/scripts/users.js +++ b/src/scripts/users.js @@ -11,10 +11,6 @@ const modalCreateUser = document.getElementById('modalCreateUser'); const closeCreateModal = document.getElementById('closeCreateModal'); const cancelCreate = document.getElementById('cancelCreate'); const createUserForm = document.getElementById('createUserForm'); -const usernameInput = document.getElementById('username'); -const avatarInput = document.getElementById('avatarInput'); -const avatarPreview = document.getElementById('avatarPreview'); -const avatarUploadArea = document.getElementById('avatarUploadArea'); const modalUserActions = document.getElementById('modalUserActions'); const closeActionsModal = document.getElementById('closeActionsModal'); @@ -24,10 +20,6 @@ const modalEditUser = document.getElementById('modalEditUser'); const closeEditModal = document.getElementById('closeEditModal'); const cancelEdit = document.getElementById('cancelEdit'); const editUserForm = document.getElementById('editUserForm'); -const editUsernameInput = document.getElementById('editUsername'); -const editAvatarPreview = document.getElementById('editAvatarPreview'); -const editAvatarUploadArea = document.getElementById('editAvatarUploadArea'); -const editAvatarInput = document.getElementById('editAvatarInput'); const modalAniList = document.getElementById('modalAniList'); const closeAniListModal = document.getElementById('closeAniListModal'); @@ -45,23 +37,12 @@ if (anilistStatus === "success") { if (anilistStatus === "error") { showUserToast("❌ Failed to connect AniList"); } + document.addEventListener('DOMContentLoaded', () => { - initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview'); - initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview'); loadUsers(); attachEventListeners(); }); -function authFetch(url, options = {}) { - const token = localStorage.getItem('token'); - const headers = { - ...(options.headers || {}), - }; - if (token) headers.Authorization = `Bearer ${token}`; - - return fetch(url, { ...options, headers }); -} - function showUserToast(message, type = 'info') { if (!toastContainer) return; @@ -162,6 +143,29 @@ function fileToBase64(file) { }); } +function togglePasswordVisibility(inputId, buttonElement) { + const input = document.getElementById(inputId); + if (!input) return; + + if (input.type === 'password') { + input.type = 'text'; + buttonElement.innerHTML = ` + + + + + `; + } else { + input.type = 'password'; + buttonElement.innerHTML = ` + + + + + `; + } +} + async function loadUsers() { try { const res = await fetch(`${API_BASE}/users`); @@ -175,63 +179,6 @@ async function loadUsers() { } } -async function createUser(username, profilePictureUrl) { - try { - const body = { username }; - if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl; - - const res = await fetch(`${API_BASE}/users`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Error creating user'); - } - - return await res.json(); - } catch (err) { - throw err; - } -} - -async function updateUser(userId, username, profilePictureUrl) { - try { - const updates = { username }; - if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl; - - const res = await fetch(`${API_BASE}/users/${userId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates) - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Error updating user'); - } - - return await res.json(); - } catch (err) { - throw err; - } -} - -async function deleteUser(userId) { - try { - const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' }); - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Error deleting user'); - } - return await res.json(); - } catch (err) { - throw err; - } -} - function renderUsers() { if (!usersGrid) return; @@ -251,10 +198,13 @@ function createUserCard(user) { const card = document.createElement('div'); card.className = 'user-card'; - card.addEventListener('click', (e) => { + if (user.has_password) { + card.classList.add('has-password'); + } + card.addEventListener('click', (e) => { if (!e.target.closest('.user-config-btn')) { - loginUser(user.id); + loginUser(user.id, user.has_password); } }); @@ -271,10 +221,6 @@ function createUserCard(user) {
${avatarContent}
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+
+ + + + +
+

Click to upload or drag and drop

+

PNG, JPG up to 5MB

+
+ +
+ + +
+ + `; + modalCreateUser.classList.add('active'); - if (usernameInput) usernameInput.focus(); + + initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview'); + + document.getElementById('createUserFormDynamic').addEventListener('submit', handleCreateUser); + document.getElementById('username').focus(); + selectedFile = null; - if (avatarPreview) { - avatarPreview.innerHTML = ` - - - - - `; - } } function closeModal() { @@ -328,23 +327,21 @@ function closeModal() { modalAniList.classList.remove('active'); modalUserActions.classList.remove('active'); modalEditUser.classList.remove('active'); - if (createUserForm) createUserForm.reset(); - if (editUserForm) editUserForm.reset(); selectedFile = null; - const modalHeader = modalAniList.querySelector('.modal-header h2'); - if (modalHeader) modalHeader.textContent = 'AniList Integration'; } async function handleCreateUser(e) { e.preventDefault(); const username = document.getElementById('username').value.trim(); + const password = document.getElementById('createPassword').value.trim(); + if (!username) { showUserToast('Please enter a username', 'error'); return; } - const submitBtn = createUserForm.querySelector('button[type="submit"]'); + const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = 'Creating...'; @@ -352,7 +349,20 @@ async function handleCreateUser(e) { let profilePictureUrl = null; if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); - await createUser(username, profilePictureUrl); + const body = { username }; + if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl; + if (password) body.password = password; + + const res = await fetch(`${API_BASE}/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error creating user'); + } closeModal(); await loadUsers(); @@ -382,15 +392,30 @@ function openUserActionsModal(userId) { content.innerHTML = `
+ +
+
+
+ + +
+ +
+ +
+
+ ${user.profile_picture_url + ? `Avatar` + : ` + + + ` + } +
+

Click to upload or drag and drop

+

PNG, JPG up to 5MB

+
+ +
- const avatarPlaceholder = ` - - - - + +
+ `; - if (user.profile_picture_url) { - editAvatarPreview.innerHTML = `Avatar preview`; - } else { - editAvatarPreview.innerHTML = avatarPlaceholder; - } - - if (editAvatarInput) editAvatarInput.value = ''; modalEditUser.classList.add('active'); + + initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview'); + + selectedFile = null; + + document.getElementById('editUserFormDynamic').addEventListener('submit', handleEditUser); }; async function handleEditUser(e) { @@ -434,13 +492,13 @@ async function handleEditUser(e) { const user = users.find(u => u.id === currentUserId); if (!user) return; - const username = editUsernameInput.value.trim(); + const username = document.getElementById('editUsername').value.trim(); if (!username) { showUserToast('Please enter a username', 'error'); return; } - const submitBtn = editUserForm.querySelector('.btn-primary'); + const submitBtn = e.target.querySelector('.btn-primary'); submitBtn.disabled = true; submitBtn.textContent = 'Saving...'; @@ -448,7 +506,19 @@ async function handleEditUser(e) { let profilePictureUrl; if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile); - await updateUser(currentUserId, username, profilePictureUrl); + const updates = { username }; + if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl; + + const res = await fetch(`${API_BASE}/users/${currentUserId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error updating user'); + } closeModal(); await loadUsers(); @@ -462,6 +532,162 @@ async function handleEditUser(e) { } } +window.openPasswordModal = function(userId) { + currentUserId = userId; + modalUserActions.classList.remove('active'); + const user = users.find(u => u.id === userId); + if (!user) return; + + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + + document.getElementById('passwordForm').addEventListener('submit', handlePasswordSubmit); +}; + +async function handlePasswordSubmit(e) { + e.preventDefault(); + + const user = users.find(u => u.id === currentUserId); + if (!user) return; + + const currentPassword = user.has_password ? document.getElementById('currentPassword').value : null; + const newPassword = document.getElementById('newPassword').value; + + if (!newPassword && !user.has_password) { + showUserToast('Please enter a password', 'error'); + return; + } + + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Updating...'; + + try { + const body = { newPassword: newPassword || null }; + if (currentPassword) body.currentPassword = currentPassword; + + const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error updating password'); + } + + closeModal(); + await loadUsers(); + showUserToast('Password updated successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error updating password', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = user.has_password ? 'Update Password' : 'Set Password'; + } +} + +window.handleRemovePassword = async function() { + if (!confirm('Are you sure you want to remove the password protection from this profile?')) return; + + try { + const currentPassword = document.getElementById('currentPassword').value; + + if (!currentPassword) { + showUserToast('Please enter your current password', 'error'); + return; + } + + const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword: null }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error removing password'); + } + + closeModal(); + await loadUsers(); + showUserToast('Password removed successfully!', 'success'); + } catch (err) { + console.error(err); + showUserToast(err.message || 'Error removing password', 'error'); + } +}; + window.handleDeleteConfirmation = function(userId) { const user = users.find(u => u.id === userId); if (!user) return; @@ -480,7 +706,13 @@ window.handleConfirmedDeleteUser = async function(userId) { showUserToast('Deleting user...', 'info'); try { - await deleteUser(userId); + const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Error deleting user'); + } + await loadUsers(); showUserToast('User deleted successfully!', 'success'); } catch (err) { @@ -492,27 +724,36 @@ window.handleConfirmedDeleteUser = async function(userId) { function showConfirmationModal(title, message, confirmAction) { closeModal(); - const modalHeader = modalAniList.querySelector('.modal-header h2'); - if (modalHeader) modalHeader.textContent = title; - - aniListContent.innerHTML = ` -
- - - - - -

- ${message} -

-
- -
+
+ + + + + +

+ ${message} +

+
+ + +
+
`; @@ -522,22 +763,41 @@ function showConfirmationModal(title, message, confirmAction) { function openAniListModal(userId) { currentUserId = userId; - aniListContent.innerHTML = `
Loading integration status...
`; modalUserActions.classList.remove('active'); modalEditUser.classList.remove('active'); + aniListContent.innerHTML = `
Loading integration status...
`; + + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + getIntegrationStatus(userId).then(integration => { - aniListContent.innerHTML = ` + const content = document.getElementById('aniListContent'); + + content.innerHTML = `
${integration.connected ? `
- AniList -
+ AniList +

Connected to AniList

User ID: ${integration.anilistUserId}

@@ -553,31 +813,27 @@ function openAniListModal(userId) {

Sync your anime list by logging in with AniList.

-
- -

You will be redirected and then returned here.

+

+ You will be redirected and then returned here. +

`}
`; - - modalAniList.classList.add('active'); - }).catch(err => { console.error(err); - aniListContent.innerHTML = `
Error loading integration status.
`; - modalAniList.classList.add('active'); + const content = document.getElementById('aniListContent'); + content.innerHTML = `
Error loading integration status.
`; }); } async function redirectToAniListLogin() { try { - const res = await fetch(`/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -590,10 +846,7 @@ async function redirectToAniListLogin() { localStorage.setItem('token', data.token); const clientId = 32898; - const redirectUri = encodeURIComponent( - window.location.origin + '/api/anilist' - ); - + const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist'); const state = encodeURIComponent(currentUserId); window.location.href = @@ -611,7 +864,6 @@ async function redirectToAniListLogin() { async function getIntegrationStatus(userId) { try { - const res = await fetch(`${API_BASE}/users/${userId}/integration`); if (!res.ok) { return { connected: false }; @@ -623,11 +875,15 @@ async function getIntegrationStatus(userId) { } } -async function disconnectAniList(userId) { - try { +window.handleDisconnectAniList = async function() { + if (!confirm('Are you sure you want to disconnect AniList?')) return; - const res = await authFetch(`${API_BASE}/users/${userId}/integration`, { - method: 'DELETE' + try { + const res = await fetch(`${API_BASE}/users/${currentUserId}/integration`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } }); if (!res.ok) { @@ -635,31 +891,71 @@ async function disconnectAniList(userId) { throw new Error(error.error || 'Error disconnecting AniList'); } - return await res.json(); - } catch (err) { - throw err; - } -} - -window.handleDisconnectAniList = async function() { - if (!confirm('Are you sure you want to disconnect AniList?')) return; - - try { - await disconnectAniList(currentUserId); showUserToast('AniList disconnected successfully', 'success'); - await openAniListModal(currentUserId); + openAniListModal(currentUserId); } catch (err) { console.error(err); showUserToast('Error disconnecting AniList', 'error'); } }; -async function loginUser(userId) { +async function loginUser(userId, hasPassword) { + if (hasPassword) { + // Mostrar modal de contraseña + modalAniList.innerHTML = ` + + + `; + + modalAniList.classList.add('active'); + + document.getElementById('loginPasswordForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const password = document.getElementById('loginPassword').value; + await performLogin(userId, password); + }); + } else { + await performLogin(userId); + } +} + +async function performLogin(userId, password = null) { try { + const body = { userId }; + if (password) body.password = password; + const res = await fetch(`${API_BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId }) + body: JSON.stringify(body) }); if (!res.ok) { @@ -673,13 +969,9 @@ async function loginUser(userId) { window.location.href = '/anime'; } catch (err) { console.error('Login error', err); - showUserToast('Login failed', 'error'); + showUserToast(err.message || 'Login failed', 'error'); } } window.openAniListModal = openAniListModal; -window.openEditModal = window.openEditModal; - -window.handleDeleteConfirmation = window.handleDeleteConfirmation; -window.handleConfirmedDeleteUser = window.handleConfirmedDeleteUser; window.redirectToAniListLogin = redirectToAniListLogin; \ No newline at end of file diff --git a/views/css/users.css b/views/css/users.css index 02d8625..ed90504 100644 --- a/views/css/users.css +++ b/views/css/users.css @@ -80,6 +80,20 @@ box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3); } +/* Badge de contraseña protegida */ +.user-card.has-password::after { + content: '🔒'; + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + padding: 0.4rem 0.7rem; + border-radius: var(--radius-md); + font-size: 0.9rem; + z-index: 10; + border: 1px solid rgba(255, 255, 255, 0.1); +} + .user-avatar { width: 100%; height: 100%; @@ -116,7 +130,7 @@ bottom: 0; left: 0; right: 0; - padding: 1rem 1.5rem; /* Reducido el padding */ + padding: 1rem 1.5rem; background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 50%, transparent 100%); transform: translateY(100%); transition: transform 0.3s ease; @@ -135,12 +149,10 @@ text-overflow: ellipsis; } -/* OCULTAR: El estado "Available" completo */ .user-status { display: none; } -/* BOTÓN DE CONFIGURACIÓN EN LA TARJETA (NUEVO) */ .user-config-btn { position: absolute; top: 10px; @@ -193,10 +205,6 @@ box-shadow: 0 10px 30px var(--color-primary-glow); } -.btn-manage { - display: none; /* OCULTADO */ -} - /* Modal Styles */ .modal { display: none; @@ -275,7 +283,8 @@ color: var(--color-text-secondary); } -.form-group input[type="text"] { +.form-group input[type="text"], +.form-group input[type="password"] { width: 100%; padding: 1rem; background: rgba(255, 255, 255, 0.05); @@ -287,13 +296,44 @@ transition: all 0.2s; } -.form-group input[type="text"]:focus { +.form-group input[type="text"]:focus, +.form-group input[type="password"]:focus { background: rgba(255, 255, 255, 0.08); border-color: var(--color-primary); box-shadow: 0 0 15px var(--color-primary-glow); outline: none; } +.password-toggle-wrapper { + position: relative; +} + +.password-toggle-btn { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; +} + +.password-toggle-btn:hover { + color: white; +} + +.optional-label { + font-size: 0.85rem; + color: var(--color-text-muted); + font-weight: 400; +} + .avatar-upload-area { border: 2px dashed rgba(255, 255, 255, 0.1); border-radius: var(--radius-md); @@ -398,6 +438,29 @@ input[type="file"] { color: white; } +/* Estilos para modal de password */ +.password-modal-content { + padding: 1.5rem; +} + +.password-info { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1.5rem; + color: #3b82f6; + font-size: 0.9rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.password-info svg { + flex-shrink: 0; + margin-top: 0.1rem; +} + /* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */ .manage-actions-modal { display: flex; @@ -430,6 +493,16 @@ input[type="file"] { background: rgba(59, 130, 246, 0.2); } +.btn-action.password { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; + border-color: rgba(245, 158, 11, 0.3); +} + +.btn-action.password:hover { + background: rgba(245, 158, 11, 0.2); +} + .btn-action.delete { background: rgba(239, 68, 68, 0.1); color: #ef4444; @@ -459,7 +532,6 @@ input[type="file"] { background: rgba(255, 255, 255, 0.1); color: white; } -/* FIN ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */ .anilist-status { padding: 1.5rem; @@ -557,9 +629,8 @@ input[type="file"] { margin-bottom: 2rem; } -/* ESTILOS DEL TOAST DE NOTIFICACIÓN (NUEVO) */ -#userToastContainer { /* <-- ASEGÚRATE DE USAR ESTE ID */ - position: fixed; /* <-- CRUCIAL: Mantiene el contenedor en su sitio al hacer scroll */ +#userToastContainer { + position: fixed; top: 20px; right: 20px; z-index: 2000; @@ -569,7 +640,7 @@ input[type="file"] { pointer-events: none; } - .wb-toast { +.wb-toast { padding: 1rem 1.5rem; border-radius: var(--radius-md); font-weight: 600; @@ -582,26 +653,25 @@ input[type="file"] { min-width: 250px; } - .wb-toast.show { +.wb-toast.show { opacity: 1; transform: translateX(0); } - .wb-toast.success { +.wb-toast.success { background: #22c55e; border: 1px solid rgba(34, 197, 94, 0.4); } - .wb-toast.error { +.wb-toast.error { background: #ef4444; border: 1px solid rgba(239, 68, 68, 0.4); } - .wb-toast.info { +.wb-toast.info { background: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.4); } -/* FIN ESTILOS TOAST */ /* Animations */ @keyframes fadeIn { @@ -642,7 +712,6 @@ input[type="file"] { } } -/* Loading State */ .skeleton { background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%); background-size: 200% 100%; @@ -662,7 +731,6 @@ input[type="file"] { } .users-grid { - /* Para móvil, volvemos a usar minmax para ocupar el espacio */ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1.5rem; }