added optional usser password
This commit is contained in:
37
package-lock.json
generated
37
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"electron": "^39.2.5",
|
"electron": "^39.2.5",
|
||||||
@@ -467,6 +469,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/cacheable-request": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||||
@@ -765,6 +777,20 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bindings": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
@@ -2604,6 +2630,17 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"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": {
|
"node_modules/node-gyp/node_modules/chownr": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
"@ryuziii/discord-rpc": "^1.0.1-rc.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"electron": "^39.2.5",
|
"electron": "^39.2.5",
|
||||||
|
|||||||
@@ -4,8 +4,20 @@ import {queryOne} from '../../shared/database';
|
|||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
interface UserIdParams { id: string; }
|
interface UserIdParams { id: string; }
|
||||||
interface CreateUserBody { username: string; profilePictureUrl?: string; }
|
interface CreateUserBody {
|
||||||
interface UpdateUserBody { username?: string; profilePictureUrl?: string | null; password?: string; }
|
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; }
|
interface DBRunResult { changes: number; lastID: number; }
|
||||||
|
|
||||||
@@ -19,7 +31,7 @@ export async function getMe(req: any, reply: any) {
|
|||||||
const user = await queryOne(
|
const user = await queryOne(
|
||||||
`SELECT username, profile_picture_url FROM User WHERE id = ?`,
|
`SELECT username, profile_picture_url FROM User WHERE id = ?`,
|
||||||
[userId],
|
[userId],
|
||||||
'userdata' // ✅ DB correcta
|
'userdata'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -33,16 +45,34 @@ export async function getMe(req: any, reply: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(req: FastifyRequest, reply: FastifyReply) {
|
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) {
|
if (!userId || typeof userId !== "number" || userId <= 0) {
|
||||||
return reply.code(400).send({ error: "Invalid userId provided" });
|
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" });
|
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(
|
const token = jwt.sign(
|
||||||
{ id: userId },
|
{ id: userId },
|
||||||
process.env.JWT_SECRET!,
|
process.env.JWT_SECRET!,
|
||||||
@@ -67,17 +97,20 @@ export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
|
|
||||||
export async function createUser(req: FastifyRequest, reply: FastifyReply) {
|
export async function createUser(req: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { username, profilePictureUrl } = req.body as CreateUserBody;
|
const { username, profilePictureUrl, password } = req.body as CreateUserBody;
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return reply.code(400).send({ error: "Missing required field: 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) {
|
} catch (err) {
|
||||||
|
|
||||||
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
|
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
|
||||||
return reply.code(409).send({ error: "Username already exists." });
|
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) {
|
if (result && result.changes > 0) {
|
||||||
return { success: true, message: "User updated successfully" };
|
return { success: true, message: "User updated successfully" };
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
return reply.code(404).send({ error: "User not found or nothing to update" });
|
return reply.code(404).send({ error: "User not found or nothing to update" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -194,3 +226,44 @@ export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply
|
|||||||
return reply.code(500).send({ error: "Failed to disconnect AniList" });
|
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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ async function userRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.delete('/users/:id', controller.deleteUser);
|
fastify.delete('/users/:id', controller.deleteUser);
|
||||||
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
|
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
|
||||||
fastify.delete('/users/:id/integration', controller.disconnectAniList);
|
fastify.delete('/users/:id/integration', controller.disconnectAniList);
|
||||||
|
fastify.put('/users/:id/password', controller.changePassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default userRoutes;
|
export default userRoutes;
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
import {queryAll, queryOne, run} from '../../shared/database';
|
import {queryAll, queryOne, run} from '../../shared/database';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const USER_DB_NAME = 'userdata';
|
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<boolean> {
|
export async function userExists(id: number): Promise<boolean> {
|
||||||
const sql = 'SELECT 1 FROM User WHERE id = ?';
|
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;
|
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 = `
|
const sql = `
|
||||||
INSERT INTO User (username, profile_picture_url)
|
INSERT INTO User (username, profile_picture_url, password_hash)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?, ?)
|
||||||
`;
|
`;
|
||||||
const params = [username, profilePictureUrl || null];
|
const params = [username, profilePictureUrl || null, passwordHash];
|
||||||
|
|
||||||
const result = await run(sql, params, USER_DB_NAME);
|
const result = await run(sql, params, USER_DB_NAME);
|
||||||
|
|
||||||
@@ -30,13 +43,23 @@ export async function updateUser(userId: number, updates: any): Promise<any> {
|
|||||||
fields.push('username = ?');
|
fields.push('username = ?');
|
||||||
values.push(updates.username);
|
values.push(updates.username);
|
||||||
}
|
}
|
||||||
if (updates.profilePictureUrl !== undefined) {
|
|
||||||
|
|
||||||
|
if (updates.profilePictureUrl !== undefined) {
|
||||||
fields.push('profile_picture_url = ?');
|
fields.push('profile_picture_url = ?');
|
||||||
values.push(updates.profilePictureUrl);
|
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) {
|
if (fields.length === 0) {
|
||||||
@@ -51,7 +74,6 @@ export async function updateUser(userId: number, updates: any): Promise<any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: number): Promise<any> {
|
export async function deleteUser(userId: number): Promise<any> {
|
||||||
|
|
||||||
await run(
|
await run(
|
||||||
`DELETE FROM ListEntry WHERE user_id = ?`,
|
`DELETE FROM ListEntry WHERE user_id = ?`,
|
||||||
[userId],
|
[userId],
|
||||||
@@ -79,22 +101,34 @@ export async function deleteUser(userId: number): Promise<any> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getAllUsers(): Promise<User[]> {
|
export async function getAllUsers(): Promise<User[]> {
|
||||||
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);
|
const users = await queryAll(sql, [], USER_DB_NAME);
|
||||||
|
|
||||||
return users.map((user: any) => ({
|
return users.map((user: any) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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[];
|
})) as User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserById(id: number): Promise<User | null> {
|
export async function getUserById(id: number): Promise<User | null> {
|
||||||
const sql = `
|
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
|
FROM User
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`;
|
`;
|
||||||
@@ -106,10 +140,22 @@ export async function getUserById(id: number): Promise<User | null> {
|
|||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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<boolean> {
|
||||||
|
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) {
|
export async function getAniListIntegration(userId: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT anilist_user_id, expires_at
|
SELECT anilist_user_id, expires_at
|
||||||
@@ -137,4 +183,4 @@ export async function removeAniListIntegration(userId: number) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return run(sql, [userId, "AniList"], USER_DB_NAME);
|
return run(sql, [userId, "AniList"], USER_DB_NAME);
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,6 @@ const modalCreateUser = document.getElementById('modalCreateUser');
|
|||||||
const closeCreateModal = document.getElementById('closeCreateModal');
|
const closeCreateModal = document.getElementById('closeCreateModal');
|
||||||
const cancelCreate = document.getElementById('cancelCreate');
|
const cancelCreate = document.getElementById('cancelCreate');
|
||||||
const createUserForm = document.getElementById('createUserForm');
|
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 modalUserActions = document.getElementById('modalUserActions');
|
||||||
const closeActionsModal = document.getElementById('closeActionsModal');
|
const closeActionsModal = document.getElementById('closeActionsModal');
|
||||||
@@ -24,10 +20,6 @@ const modalEditUser = document.getElementById('modalEditUser');
|
|||||||
const closeEditModal = document.getElementById('closeEditModal');
|
const closeEditModal = document.getElementById('closeEditModal');
|
||||||
const cancelEdit = document.getElementById('cancelEdit');
|
const cancelEdit = document.getElementById('cancelEdit');
|
||||||
const editUserForm = document.getElementById('editUserForm');
|
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 modalAniList = document.getElementById('modalAniList');
|
||||||
const closeAniListModal = document.getElementById('closeAniListModal');
|
const closeAniListModal = document.getElementById('closeAniListModal');
|
||||||
@@ -45,23 +37,12 @@ if (anilistStatus === "success") {
|
|||||||
if (anilistStatus === "error") {
|
if (anilistStatus === "error") {
|
||||||
showUserToast("❌ Failed to connect AniList");
|
showUserToast("❌ Failed to connect AniList");
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview');
|
|
||||||
initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview');
|
|
||||||
loadUsers();
|
loadUsers();
|
||||||
attachEventListeners();
|
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') {
|
function showUserToast(message, type = 'info') {
|
||||||
if (!toastContainer) return;
|
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 = `
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
buttonElement.innerHTML = `
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/users`);
|
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() {
|
function renderUsers() {
|
||||||
if (!usersGrid) return;
|
if (!usersGrid) return;
|
||||||
|
|
||||||
@@ -251,10 +198,13 @@ function createUserCard(user) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'user-card';
|
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')) {
|
if (!e.target.closest('.user-config-btn')) {
|
||||||
loginUser(user.id);
|
loginUser(user.id, user.has_password);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,10 +221,6 @@ function createUserCard(user) {
|
|||||||
<div class="user-avatar">${avatarContent}</div>
|
<div class="user-avatar">${avatarContent}</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">${user.username}</div>
|
<div class="user-name">${user.username}</div>
|
||||||
<div class="user-status">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span>Available</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="user-config-btn" title="Manage User" data-user-id="${user.id}">
|
<button class="user-config-btn" title="Manage User" data-user-id="${user.id}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -310,17 +256,70 @@ function showEmptyState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
|
modalCreateUser.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Create New User</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="createUserFormDynamic">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required maxlength="20" placeholder="Enter your name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createPassword">
|
||||||
|
Password <span class="optional-label">(Optional)</span>
|
||||||
|
</label>
|
||||||
|
<div class="password-toggle-wrapper">
|
||||||
|
<input type="password" id="createPassword" name="password" placeholder="Leave empty for no password">
|
||||||
|
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('createPassword', this)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Picture</label>
|
||||||
|
<div class="avatar-upload-area" id="avatarUploadArea">
|
||||||
|
<div class="avatar-preview" id="avatarPreview">
|
||||||
|
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="avatar-upload-text">Click to upload or drag and drop</p>
|
||||||
|
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="avatarInput" accept="image/*">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Create User</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
modalCreateUser.classList.add('active');
|
modalCreateUser.classList.add('active');
|
||||||
if (usernameInput) usernameInput.focus();
|
|
||||||
|
initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview');
|
||||||
|
|
||||||
|
document.getElementById('createUserFormDynamic').addEventListener('submit', handleCreateUser);
|
||||||
|
document.getElementById('username').focus();
|
||||||
|
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
if (avatarPreview) {
|
|
||||||
avatarPreview.innerHTML = `
|
|
||||||
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -328,23 +327,21 @@ function closeModal() {
|
|||||||
modalAniList.classList.remove('active');
|
modalAniList.classList.remove('active');
|
||||||
modalUserActions.classList.remove('active');
|
modalUserActions.classList.remove('active');
|
||||||
modalEditUser.classList.remove('active');
|
modalEditUser.classList.remove('active');
|
||||||
if (createUserForm) createUserForm.reset();
|
|
||||||
if (editUserForm) editUserForm.reset();
|
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
const modalHeader = modalAniList.querySelector('.modal-header h2');
|
|
||||||
if (modalHeader) modalHeader.textContent = 'AniList Integration';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateUser(e) {
|
async function handleCreateUser(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const username = document.getElementById('username').value.trim();
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('createPassword').value.trim();
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
showUserToast('Please enter a username', 'error');
|
showUserToast('Please enter a username', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitBtn = createUserForm.querySelector('button[type="submit"]');
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Creating...';
|
submitBtn.textContent = 'Creating...';
|
||||||
|
|
||||||
@@ -352,7 +349,20 @@ async function handleCreateUser(e) {
|
|||||||
let profilePictureUrl = null;
|
let profilePictureUrl = null;
|
||||||
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
|
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();
|
closeModal();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
@@ -382,15 +392,30 @@ function openUserActionsModal(userId) {
|
|||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="manage-actions-modal">
|
<div class="manage-actions-modal">
|
||||||
<button class="btn-action edit" onclick="openEditModal(${user.id})">
|
<button class="btn-action edit" onclick="openEditModal(${user.id})">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||||
|
</svg>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-action password" onclick="openPasswordModal(${user.id})">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
${user.has_password ? 'Change Password' : 'Add Password'}
|
||||||
|
</button>
|
||||||
<button class="btn-action anilist" onclick="openAniListModal(${user.id})">
|
<button class="btn-action anilist" onclick="openAniListModal(${user.id})">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||||
|
</svg>
|
||||||
AniList Integration
|
AniList Integration
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-action delete" onclick="handleDeleteConfirmation(${user.id})">
|
<button class="btn-action delete" onclick="handleDeleteConfirmation(${user.id})">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="18" y1="9" x2="12" y2="15"></line><line x1="12" y1="9" x2="18" y2="15"></line></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path>
|
||||||
|
<line x1="18" y1="9" x2="12" y2="15"></line>
|
||||||
|
<line x1="12" y1="9" x2="18" y2="15"></line>
|
||||||
|
</svg>
|
||||||
Delete Profile
|
Delete Profile
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-action cancel" onclick="closeModal()">
|
<button class="btn-action cancel" onclick="closeModal()">
|
||||||
@@ -408,24 +433,57 @@ window.openEditModal = function(userId) {
|
|||||||
const user = users.find(u => u.id === userId);
|
const user = users.find(u => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
editUsernameInput.value = user.username || '';
|
modalEditUser.innerHTML = `
|
||||||
selectedFile = null;
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Edit Profile</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="editUserFormDynamic">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editUsername">Username</label>
|
||||||
|
<input type="text" id="editUsername" name="username" required maxlength="20" value="${user.username}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Picture</label>
|
||||||
|
<div class="avatar-upload-area" id="editAvatarUploadArea">
|
||||||
|
<div class="avatar-preview" id="editAvatarPreview">
|
||||||
|
${user.profile_picture_url
|
||||||
|
? `<img src="${user.profile_picture_url}" alt="Avatar">`
|
||||||
|
: `<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="avatar-upload-text">Click to upload or drag and drop</p>
|
||||||
|
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="editAvatarInput" accept="image/*">
|
||||||
|
</div>
|
||||||
|
|
||||||
const avatarPlaceholder = `
|
<div class="modal-actions">
|
||||||
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
</div>
|
||||||
</svg>
|
</form>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (user.profile_picture_url) {
|
|
||||||
editAvatarPreview.innerHTML = `<img src="${user.profile_picture_url}" alt="Avatar preview">`;
|
|
||||||
} else {
|
|
||||||
editAvatarPreview.innerHTML = avatarPlaceholder;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editAvatarInput) editAvatarInput.value = '';
|
|
||||||
modalEditUser.classList.add('active');
|
modalEditUser.classList.add('active');
|
||||||
|
|
||||||
|
initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview');
|
||||||
|
|
||||||
|
selectedFile = null;
|
||||||
|
|
||||||
|
document.getElementById('editUserFormDynamic').addEventListener('submit', handleEditUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleEditUser(e) {
|
async function handleEditUser(e) {
|
||||||
@@ -434,13 +492,13 @@ async function handleEditUser(e) {
|
|||||||
const user = users.find(u => u.id === currentUserId);
|
const user = users.find(u => u.id === currentUserId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const username = editUsernameInput.value.trim();
|
const username = document.getElementById('editUsername').value.trim();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
showUserToast('Please enter a username', 'error');
|
showUserToast('Please enter a username', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitBtn = editUserForm.querySelector('.btn-primary');
|
const submitBtn = e.target.querySelector('.btn-primary');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Saving...';
|
submitBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
@@ -448,7 +506,19 @@ async function handleEditUser(e) {
|
|||||||
let profilePictureUrl;
|
let profilePictureUrl;
|
||||||
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
|
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();
|
closeModal();
|
||||||
await loadUsers();
|
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 = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>${user.has_password ? 'Change Password' : 'Add Password'}</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="password-modal-content">
|
||||||
|
${user.has_password ? `
|
||||||
|
<div class="password-info">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
<span>This profile is currently password protected</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<form id="passwordForm">
|
||||||
|
${user.has_password ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="currentPassword">Current Password</label>
|
||||||
|
<div class="password-toggle-wrapper">
|
||||||
|
<input type="password" id="currentPassword" required placeholder="Enter current password">
|
||||||
|
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('currentPassword', this)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newPassword">New Password ${user.has_password ? '' : '<span class="optional-label">(Optional)</span>'}</label>
|
||||||
|
<div class="password-toggle-wrapper">
|
||||||
|
<input type="password" id="newPassword" ${user.has_password ? '' : ''} placeholder="Enter new password">
|
||||||
|
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('newPassword', this)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
${user.has_password ? `
|
||||||
|
<button type="button" class="btn-disconnect" onclick="handleRemovePassword()">
|
||||||
|
Remove Password
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
|
${user.has_password ? 'Update' : 'Set'} Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) {
|
window.handleDeleteConfirmation = function(userId) {
|
||||||
const user = users.find(u => u.id === userId);
|
const user = users.find(u => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -480,7 +706,13 @@ window.handleConfirmedDeleteUser = async function(userId) {
|
|||||||
showUserToast('Deleting user...', 'info');
|
showUserToast('Deleting user...', 'info');
|
||||||
|
|
||||||
try {
|
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();
|
await loadUsers();
|
||||||
showUserToast('User deleted successfully!', 'success');
|
showUserToast('User deleted successfully!', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -492,27 +724,36 @@ window.handleConfirmedDeleteUser = async function(userId) {
|
|||||||
function showConfirmationModal(title, message, confirmAction) {
|
function showConfirmationModal(title, message, confirmAction) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
const modalHeader = modalAniList.querySelector('.modal-header h2');
|
modalAniList.innerHTML = `
|
||||||
if (modalHeader) modalHeader.textContent = title;
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content" style="max-width: 400px;">
|
||||||
aniListContent.innerHTML = `
|
<div class="modal-header">
|
||||||
<div style="text-align: center; padding: 1rem;">
|
<h2>${title}</h2>
|
||||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="opacity: 0.8; margin: 0 auto 1rem; display: block;">
|
<button class="modal-close" onclick="closeModal()">
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<p style="color: var(--color-text-secondary); margin-bottom: 2rem; font-size: 1rem;">
|
|
||||||
${message}
|
|
||||||
</p>
|
|
||||||
<div style="display: flex; gap: 1rem;">
|
|
||||||
<button class="btn-secondary" style="flex: 1;" onclick="closeModal()">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button class="btn-disconnect" style="flex: 1; background: #ef4444; color: white;" onclick="window.${confirmAction}">
|
|
||||||
Confirm Delete
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="text-align: center; padding: 1rem;">
|
||||||
|
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="opacity: 0.8; margin: 0 auto 1rem; display: block;">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
<p style="color: var(--color-text-secondary); margin-bottom: 2rem; font-size: 1rem;">
|
||||||
|
${message}
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<button class="btn-secondary" style="flex: 1;" onclick="closeModal()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-disconnect" style="flex: 1; background: #ef4444; color: white;" onclick="window.${confirmAction}">
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -522,22 +763,41 @@ function showConfirmationModal(title, message, confirmAction) {
|
|||||||
function openAniListModal(userId) {
|
function openAniListModal(userId) {
|
||||||
currentUserId = userId;
|
currentUserId = userId;
|
||||||
|
|
||||||
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
|
|
||||||
modalUserActions.classList.remove('active');
|
modalUserActions.classList.remove('active');
|
||||||
modalEditUser.classList.remove('active');
|
modalEditUser.classList.remove('active');
|
||||||
|
|
||||||
|
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
|
||||||
|
|
||||||
|
modalAniList.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>AniList Integration</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="aniListContent">
|
||||||
|
<div style="text-align: center; padding: 2rem;">Loading integration status...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalAniList.classList.add('active');
|
||||||
|
|
||||||
getIntegrationStatus(userId).then(integration => {
|
getIntegrationStatus(userId).then(integration => {
|
||||||
aniListContent.innerHTML = `
|
const content = document.getElementById('aniListContent');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
<div class="anilist-status">
|
<div class="anilist-status">
|
||||||
${integration.connected ? `
|
${integration.connected ? `
|
||||||
<div class="anilist-connected">
|
<div class="anilist-connected">
|
||||||
<div class="anilist-icon">
|
<div class="anilist-icon">
|
||||||
<img
|
<img src="https://anilist.co/img/icons/icon.svg" alt="AniList" style="width:40px; height:40px;">
|
||||||
src="https://anilist.co/img/icons/icon.svg"
|
</div>
|
||||||
alt="AniList"
|
|
||||||
style="width:40px; height:40px;"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="anilist-info">
|
<div class="anilist-info">
|
||||||
<h3>Connected to AniList</h3>
|
<h3>Connected to AniList</h3>
|
||||||
<p>User ID: ${integration.anilistUserId}</p>
|
<p>User ID: ${integration.anilistUserId}</p>
|
||||||
@@ -553,31 +813,27 @@ function openAniListModal(userId) {
|
|||||||
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
|
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
|
||||||
Sync your anime list by logging in with AniList.
|
Sync your anime list by logging in with AniList.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display:flex; justify-content:center;">
|
<div style="display:flex; justify-content:center;">
|
||||||
<button class="btn-connect" onclick="redirectToAniListLogin()">
|
<button class="btn-connect" onclick="redirectToAniListLogin()">
|
||||||
Login with AniList
|
Login with AniList
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p style="font-size:0.85rem; margin-top:1rem; color:var(--color-text-secondary)">
|
||||||
<p style="font-size:0.85rem; margin-top:1rem; color:var(--color-text-secondary);">You will be redirected and then returned here.</p>
|
You will be redirected and then returned here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modalAniList.classList.add('active');
|
|
||||||
|
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
aniListContent.innerHTML = `<div style="text-align:center;padding:1rem;color:var(--color-text-secondary)">Error loading integration status.</div>`;
|
const content = document.getElementById('aniListContent');
|
||||||
modalAniList.classList.add('active');
|
content.innerHTML = `<div style="text-align:center;padding:1rem;color:var(--color-text-secondary)">Error loading integration status.</div>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function redirectToAniListLogin() {
|
async function redirectToAniListLogin() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const res = await fetch(`/api/login`, {
|
const res = await fetch(`/api/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -590,10 +846,7 @@ async function redirectToAniListLogin() {
|
|||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
|
|
||||||
const clientId = 32898;
|
const clientId = 32898;
|
||||||
const redirectUri = encodeURIComponent(
|
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
|
||||||
window.location.origin + '/api/anilist'
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = encodeURIComponent(currentUserId);
|
const state = encodeURIComponent(currentUserId);
|
||||||
|
|
||||||
window.location.href =
|
window.location.href =
|
||||||
@@ -611,7 +864,6 @@ async function redirectToAniListLogin() {
|
|||||||
|
|
||||||
async function getIntegrationStatus(userId) {
|
async function getIntegrationStatus(userId) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
|
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { connected: false };
|
return { connected: false };
|
||||||
@@ -623,11 +875,15 @@ async function getIntegrationStatus(userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectAniList(userId) {
|
window.handleDisconnectAniList = async function() {
|
||||||
try {
|
if (!confirm('Are you sure you want to disconnect AniList?')) return;
|
||||||
|
|
||||||
const res = await authFetch(`${API_BASE}/users/${userId}/integration`, {
|
try {
|
||||||
method: 'DELETE'
|
const res = await fetch(`${API_BASE}/users/${currentUserId}/integration`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -635,31 +891,71 @@ async function disconnectAniList(userId) {
|
|||||||
throw new Error(error.error || 'Error disconnecting AniList');
|
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');
|
showUserToast('AniList disconnected successfully', 'success');
|
||||||
await openAniListModal(currentUserId);
|
openAniListModal(currentUserId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showUserToast('Error disconnecting AniList', 'error');
|
showUserToast('Error disconnecting AniList', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loginUser(userId) {
|
async function loginUser(userId, hasPassword) {
|
||||||
|
if (hasPassword) {
|
||||||
|
// Mostrar modal de contraseña
|
||||||
|
modalAniList.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Enter Password</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="loginPasswordForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginPassword">Password</label>
|
||||||
|
<div class="password-toggle-wrapper">
|
||||||
|
<input type="password" id="loginPassword" required placeholder="Enter your password" autofocus>
|
||||||
|
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('loginPassword', this)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
|
const body = { userId };
|
||||||
|
if (password) body.password = password;
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/login`, {
|
const res = await fetch(`${API_BASE}/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ userId })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -673,13 +969,9 @@ async function loginUser(userId) {
|
|||||||
window.location.href = '/anime';
|
window.location.href = '/anime';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error', err);
|
console.error('Login error', err);
|
||||||
showUserToast('Login failed', 'error');
|
showUserToast(err.message || 'Login failed', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.openAniListModal = openAniListModal;
|
window.openAniListModal = openAniListModal;
|
||||||
window.openEditModal = window.openEditModal;
|
|
||||||
|
|
||||||
window.handleDeleteConfirmation = window.handleDeleteConfirmation;
|
|
||||||
window.handleConfirmedDeleteUser = window.handleConfirmedDeleteUser;
|
|
||||||
window.redirectToAniListLogin = redirectToAniListLogin;
|
window.redirectToAniListLogin = redirectToAniListLogin;
|
||||||
@@ -80,6 +80,20 @@
|
|||||||
box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3);
|
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 {
|
.user-avatar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -116,7 +130,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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%);
|
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 50%, transparent 100%);
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
@@ -135,12 +149,10 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OCULTAR: El estado "Available" completo */
|
|
||||||
.user-status {
|
.user-status {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* BOTÓN DE CONFIGURACIÓN EN LA TARJETA (NUEVO) */
|
|
||||||
.user-config-btn {
|
.user-config-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
@@ -193,10 +205,6 @@
|
|||||||
box-shadow: 0 10px 30px var(--color-primary-glow);
|
box-shadow: 0 10px 30px var(--color-primary-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-manage {
|
|
||||||
display: none; /* OCULTADO */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -275,7 +283,8 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type="text"] {
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
@@ -287,13 +296,44 @@
|
|||||||
transition: all 0.2s;
|
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);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 15px var(--color-primary-glow);
|
box-shadow: 0 0 15px var(--color-primary-glow);
|
||||||
outline: none;
|
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 {
|
.avatar-upload-area {
|
||||||
border: 2px dashed rgba(255, 255, 255, 0.1);
|
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -398,6 +438,29 @@ input[type="file"] {
|
|||||||
color: white;
|
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 */
|
/* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
|
||||||
.manage-actions-modal {
|
.manage-actions-modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -430,6 +493,16 @@ input[type="file"] {
|
|||||||
background: rgba(59, 130, 246, 0.2);
|
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 {
|
.btn-action.delete {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
@@ -459,7 +532,6 @@ input[type="file"] {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
/* FIN ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
|
|
||||||
|
|
||||||
.anilist-status {
|
.anilist-status {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -557,9 +629,8 @@ input[type="file"] {
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ESTILOS DEL TOAST DE NOTIFICACIÓN (NUEVO) */
|
#userToastContainer {
|
||||||
#userToastContainer { /* <-- ASEGÚRATE DE USAR ESTE ID */
|
position: fixed;
|
||||||
position: fixed; /* <-- CRUCIAL: Mantiene el contenedor en su sitio al hacer scroll */
|
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
@@ -569,7 +640,7 @@ input[type="file"] {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-toast {
|
.wb-toast {
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -582,26 +653,25 @@ input[type="file"] {
|
|||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-toast.show {
|
.wb-toast.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-toast.success {
|
.wb-toast.success {
|
||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
border: 1px solid rgba(34, 197, 94, 0.4);
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-toast.error {
|
.wb-toast.error {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-toast.info {
|
.wb-toast.info {
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||||
}
|
}
|
||||||
/* FIN ESTILOS TOAST */
|
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -642,7 +712,6 @@ input[type="file"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading State */
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
|
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
@@ -662,7 +731,6 @@ input[type="file"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.users-grid {
|
.users-grid {
|
||||||
/* Para móvil, volvemos a usar minmax para ocupar el espacio */
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user