nu
This commit is contained in:
parent
4c23ab840a
commit
e8864cc853
15 changed files with 4004 additions and 1593 deletions
|
|
@ -32,6 +32,7 @@ UserInfo AdminController::getUserFromRequest(const HttpRequestPtr &req) {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update getUsers in AdminController.cpp:
|
||||||
void AdminController::getUsers(const HttpRequestPtr &req,
|
void AdminController::getUsers(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
UserInfo user = getUserFromRequest(req);
|
UserInfo user = getUserFromRequest(req);
|
||||||
|
|
@ -41,7 +42,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, "
|
*dbClient << "SELECT u.id, u.username, u.is_admin, u.is_streamer, u.created_at, u.color_code, "
|
||||||
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count "
|
"(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count "
|
||||||
"FROM users u ORDER BY u.created_at DESC"
|
"FROM users u ORDER BY u.created_at DESC"
|
||||||
>> [callback](const Result& r) {
|
>> [callback](const Result& r) {
|
||||||
|
|
@ -56,6 +57,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
||||||
user["isAdmin"] = row["is_admin"].as<bool>();
|
user["isAdmin"] = row["is_admin"].as<bool>();
|
||||||
user["isStreamer"] = row["is_streamer"].as<bool>();
|
user["isStreamer"] = row["is_streamer"].as<bool>();
|
||||||
user["createdAt"] = row["created_at"].as<std::string>();
|
user["createdAt"] = row["created_at"].as<std::string>();
|
||||||
|
user["colorCode"] = row["color_code"].isNull() ? "#561D5E" : row["color_code"].as<std::string>();
|
||||||
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
|
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
|
||||||
users.append(user);
|
users.append(user);
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +70,6 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
||||||
callback(jsonError("Failed to get users"));
|
callback(jsonError("Failed to get users"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
UserInfo user = getUserFromRequest(req);
|
UserInfo user = getUserFromRequest(req);
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void RealmController::getRealm(const HttpRequestPtr &req,
|
void RealmController::getRealm(const HttpRequestPtr &, // Remove parameter name since it's unused
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &realmId) {
|
const std::string &realmId) {
|
||||||
// Remove authentication requirement for public viewing
|
// Remove authentication requirement for public viewing
|
||||||
|
|
@ -349,17 +349,43 @@ void RealmController::getRealm(const HttpRequestPtr &req,
|
||||||
void RealmController::updateRealm(const HttpRequestPtr &req,
|
void RealmController::updateRealm(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &realmId) {
|
const std::string &realmId) {
|
||||||
// Since we removed display_name and description, there's nothing to update
|
|
||||||
// We could just return success or remove this endpoint entirely
|
|
||||||
UserInfo user = getUserFromRequest(req);
|
UserInfo user = getUserFromRequest(req);
|
||||||
if (user.id == 0) {
|
if (user.id == 0) {
|
||||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Json::Value resp;
|
// Parse realm ID
|
||||||
resp["success"] = true;
|
int64_t id;
|
||||||
callback(jsonResp(resp));
|
try {
|
||||||
|
id = std::stoll(realmId);
|
||||||
|
} catch (...) {
|
||||||
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the realm exists and belongs to the user
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
*dbClient << "SELECT id FROM realms WHERE id = $1 AND user_id = $2"
|
||||||
|
<< id << user.id
|
||||||
|
>> [callback](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(jsonError("Realm not found or access denied", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently no fields to update since we removed display_name and description
|
||||||
|
// This endpoint is kept for potential future updates
|
||||||
|
// For now, just return success
|
||||||
|
Json::Value resp;
|
||||||
|
resp["success"] = true;
|
||||||
|
resp["message"] = "Realm updated successfully";
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(jsonError("Database error"));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void RealmController::deleteRealm(const HttpRequestPtr &req,
|
void RealmController::deleteRealm(const HttpRequestPtr &req,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,8 @@ public:
|
||||||
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
|
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
|
||||||
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
|
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
|
||||||
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
|
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
|
||||||
|
ADD_METHOD_TO(UserController::updateColor, "/api/user/color", Put);
|
||||||
|
ADD_METHOD_TO(UserController::getAvailableColors, "/api/colors/available", Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void register_(const HttpRequestPtr &req,
|
void register_(const HttpRequestPtr &req,
|
||||||
|
|
@ -62,6 +64,13 @@ public:
|
||||||
void getUserPgpKeys(const HttpRequestPtr &req,
|
void getUserPgpKeys(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &username);
|
const std::string &username);
|
||||||
|
|
||||||
|
void updateColor(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
|
void getAvailableColors(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||||
};
|
};
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
#include <drogon/utils/Utilities.h>
|
#include <drogon/utils/Utilities.h>
|
||||||
#include <regex>
|
#include <regex>
|
||||||
#include <random>
|
#include <random>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
using namespace drogon;
|
using namespace drogon;
|
||||||
using namespace drogon::orm;
|
using namespace drogon::orm;
|
||||||
|
|
@ -27,205 +29,306 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
|
||||||
|
try {
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
|
||||||
|
// Create a structure to hold the state for recursive attempts
|
||||||
|
struct ColorGenerator : public std::enable_shared_from_this<ColorGenerator> {
|
||||||
|
std::mt19937 gen;
|
||||||
|
std::uniform_int_distribution<> dis;
|
||||||
|
std::function<void(const std::string&)> callback;
|
||||||
|
DbClientPtr dbClient;
|
||||||
|
int attempts;
|
||||||
|
|
||||||
|
ColorGenerator(std::function<void(const std::string&)> cb, DbClientPtr db)
|
||||||
|
: gen(std::random_device{}()),
|
||||||
|
dis(0, 0xFFFFFF),
|
||||||
|
callback(cb),
|
||||||
|
dbClient(db),
|
||||||
|
attempts(0) {}
|
||||||
|
|
||||||
|
void tryGenerate() {
|
||||||
|
auto self = shared_from_this();
|
||||||
|
|
||||||
|
// Limit attempts to prevent infinite recursion
|
||||||
|
if (++attempts > 100) {
|
||||||
|
LOG_ERROR << "Failed to generate unique color after 100 attempts";
|
||||||
|
callback("#561D5E"); // Fallback to default
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random color
|
||||||
|
int colorValue = dis(gen);
|
||||||
|
char colorHex[8];
|
||||||
|
snprintf(colorHex, sizeof(colorHex), "#%06X", colorValue);
|
||||||
|
std::string color(colorHex);
|
||||||
|
|
||||||
|
// Check if color exists
|
||||||
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 LIMIT 1"
|
||||||
|
<< color
|
||||||
|
>> [self, color](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
// Color is unique, use it
|
||||||
|
self->callback(color);
|
||||||
|
} else {
|
||||||
|
// Color exists, try again
|
||||||
|
self->tryGenerate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [self](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error checking color: " << e.base().what();
|
||||||
|
// Fallback to a default color
|
||||||
|
self->callback("#561D5E");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto generator = std::make_shared<ColorGenerator>(callback, dbClient);
|
||||||
|
generator->tryGenerate();
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in generateUniqueColor: " << e.what();
|
||||||
|
callback("#561D5E"); // Fallback to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::updateUserColor(int64_t userId, const std::string& newColor,
|
||||||
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
||||||
|
try {
|
||||||
|
// Validate color format
|
||||||
|
if (newColor.length() != 7 || newColor[0] != '#') {
|
||||||
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if color is valid hex
|
||||||
|
for (size_t i = 1; i < 7; i++) {
|
||||||
|
if (!std::isxdigit(newColor[i])) {
|
||||||
|
callback(false, "Invalid color format. Use #RRGGBB", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
|
||||||
|
// Check if color is already taken
|
||||||
|
*dbClient << "SELECT id FROM users WHERE user_color = $1 AND id != $2 LIMIT 1"
|
||||||
|
<< newColor << userId
|
||||||
|
>> [dbClient, userId, newColor, callback](const Result& r) {
|
||||||
|
if (!r.empty()) {
|
||||||
|
callback(false, "This color is already taken", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the color
|
||||||
|
*dbClient << "UPDATE users SET user_color = $1 WHERE id = $2 RETURNING user_color"
|
||||||
|
<< newColor << userId
|
||||||
|
>> [callback, newColor](const Result& r2) {
|
||||||
|
if (!r2.empty()) {
|
||||||
|
callback(true, "", newColor);
|
||||||
|
} else {
|
||||||
|
callback(false, "Failed to update color", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to update color: " << e.base().what();
|
||||||
|
callback(false, "Database error", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, "Database error", "");
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in updateUserColor: " << e.what();
|
||||||
|
callback(false, "Internal server error", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void AuthService::registerUser(const std::string& username, const std::string& password,
|
void AuthService::registerUser(const std::string& username, const std::string& password,
|
||||||
const std::string& publicKey, const std::string& fingerprint,
|
const std::string& publicKey, const std::string& fingerprint,
|
||||||
std::function<void(bool, const std::string&, int64_t)> callback) {
|
std::function<void(bool, const std::string&, int64_t)> callback) {
|
||||||
|
try {
|
||||||
// Validate username
|
LOG_DEBUG << "Starting user registration for: " << username;
|
||||||
if (username.length() < 3 || username.length() > 30) {
|
|
||||||
callback(false, "Username must be between 3 and 30 characters", 0);
|
// Validate username
|
||||||
return;
|
if (username.length() < 3 || username.length() > 30) {
|
||||||
|
callback(false, "Username must be between 3 and 30 characters", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
||||||
|
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
std::string error;
|
||||||
|
if (!validatePassword(password, error)) {
|
||||||
|
callback(false, error, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG << "Validation passed, generating unique color";
|
||||||
|
|
||||||
|
// Generate unique color first
|
||||||
|
generateUniqueColor([this, username, password, publicKey, fingerprint, callback](const std::string& color) {
|
||||||
|
try {
|
||||||
|
LOG_DEBUG << "Got unique color: " << color << ", checking username availability";
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
if (!dbClient) {
|
||||||
|
LOG_ERROR << "Database client is null";
|
||||||
|
callback(false, "Database connection error", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username exists
|
||||||
|
*dbClient << "SELECT id FROM users WHERE username = $1 LIMIT 1"
|
||||||
|
<< username
|
||||||
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r) {
|
||||||
|
try {
|
||||||
|
if (!r.empty()) {
|
||||||
|
LOG_WARN << "Username already exists: " << username;
|
||||||
|
callback(false, "Username already exists", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG << "Username available, checking fingerprint";
|
||||||
|
|
||||||
|
// Check if fingerprint exists
|
||||||
|
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1 LIMIT 1"
|
||||||
|
<< fingerprint
|
||||||
|
>> [dbClient, username, password, publicKey, fingerprint, color, callback](const Result& r2) {
|
||||||
|
try {
|
||||||
|
if (!r2.empty()) {
|
||||||
|
LOG_WARN << "Fingerprint already exists";
|
||||||
|
callback(false, "This PGP key is already registered", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG << "Fingerprint available, hashing password";
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
std::string hash;
|
||||||
|
try {
|
||||||
|
hash = BCrypt::generateHash(password);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Failed to hash password: " << e.what();
|
||||||
|
callback(false, "Failed to process password", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG << "Password hashed, inserting user";
|
||||||
|
|
||||||
|
// Insert user first (without transaction for simplicity)
|
||||||
|
*dbClient << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only, user_color) "
|
||||||
|
"VALUES ($1, $2, false, false, false, $3) RETURNING id"
|
||||||
|
<< username << hash << color
|
||||||
|
>> [dbClient, publicKey, fingerprint, callback, username](const Result& r3) {
|
||||||
|
try {
|
||||||
|
if (r3.empty()) {
|
||||||
|
LOG_ERROR << "Failed to insert user";
|
||||||
|
callback(false, "Failed to create user", 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t userId = r3[0]["id"].as<int64_t>();
|
||||||
|
LOG_INFO << "User created with ID: " << userId;
|
||||||
|
|
||||||
|
// Insert PGP key
|
||||||
|
*dbClient << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
||||||
|
<< userId << publicKey << fingerprint
|
||||||
|
>> [callback, userId, username](const Result&) {
|
||||||
|
LOG_INFO << "User registration complete for: " << username << " (ID: " << userId << ")";
|
||||||
|
callback(true, "", userId);
|
||||||
|
}
|
||||||
|
>> [dbClient, userId, callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
|
||||||
|
// Try to clean up the user
|
||||||
|
*dbClient << "DELETE FROM users WHERE id = $1" << userId >> [](const Result&) {} >> [](const DrogonDbException&) {};
|
||||||
|
callback(false, "Failed to save PGP key", 0);
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception processing user insert result: " << e.what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to insert user: " << e.base().what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in fingerprint check callback: " << e.what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error checking fingerprint: " << e.base().what();
|
||||||
|
callback(false, "Database error", 0);
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in username check callback: " << e.what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error checking username: " << e.base().what();
|
||||||
|
callback(false, "Database error", 0);
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in color generation callback: " << e.what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in registerUser: " << e.what();
|
||||||
|
callback(false, "Registration failed", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
|
||||||
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate password
|
|
||||||
std::string error;
|
|
||||||
if (!validatePassword(password, error)) {
|
|
||||||
callback(false, error, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
|
||||||
|
|
||||||
// Check if username exists
|
|
||||||
*dbClient << "SELECT id FROM users WHERE username = $1"
|
|
||||||
<< username
|
|
||||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r) {
|
|
||||||
if (!r.empty()) {
|
|
||||||
callback(false, "Username already exists", 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if fingerprint exists
|
|
||||||
*dbClient << "SELECT id FROM pgp_keys WHERE fingerprint = $1"
|
|
||||||
<< fingerprint
|
|
||||||
>> [dbClient, username, password, publicKey, fingerprint, callback](const Result& r2) {
|
|
||||||
if (!r2.empty()) {
|
|
||||||
callback(false, "This PGP key is already registered", 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
std::string hash = BCrypt::generateHash(password);
|
|
||||||
|
|
||||||
// Begin transaction
|
|
||||||
auto trans = dbClient->newTransaction();
|
|
||||||
|
|
||||||
// Insert user with explicit false values for booleans
|
|
||||||
*trans << "INSERT INTO users (username, password_hash, is_admin, is_streamer, is_pgp_only) VALUES ($1, $2, false, false, false) RETURNING id"
|
|
||||||
<< username << hash
|
|
||||||
>> [trans, publicKey, fingerprint, callback](const Result& r3) {
|
|
||||||
if (r3.empty()) {
|
|
||||||
callback(false, "Failed to create user", 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int64_t userId = r3[0]["id"].as<int64_t>();
|
|
||||||
|
|
||||||
// Insert PGP key
|
|
||||||
*trans << "INSERT INTO pgp_keys (user_id, public_key, fingerprint) VALUES ($1, $2, $3)"
|
|
||||||
<< userId << publicKey << fingerprint
|
|
||||||
>> [trans, callback, userId](const Result&) {
|
|
||||||
// Transaction commits automatically
|
|
||||||
callback(true, "", userId);
|
|
||||||
}
|
|
||||||
>> [trans, callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Failed to insert PGP key: " << e.base().what();
|
|
||||||
callback(false, "Failed to save PGP key", 0);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
>> [trans, callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Failed to insert user: " << e.base().what();
|
|
||||||
callback(false, "Registration failed", 0);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
|
||||||
callback(false, "Database error", 0);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
|
||||||
callback(false, "Database error", 0);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AuthService::loginUser(const std::string& username, const std::string& password,
|
void AuthService::loginUser(const std::string& username, const std::string& password,
|
||||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||||
auto dbClient = app().getDbClient();
|
try {
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
if (!dbClient) {
|
||||||
"FROM users WHERE username = $1"
|
LOG_ERROR << "Database client is null";
|
||||||
<< username
|
callback(false, "", UserInfo{});
|
||||||
>> [password, callback, this](const Result& r) {
|
return;
|
||||||
if (r.empty()) {
|
}
|
||||||
callback(false, "", UserInfo{});
|
|
||||||
return;
|
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||||
}
|
"FROM users WHERE username = $1 LIMIT 1"
|
||||||
|
<< username
|
||||||
// Check if PGP-only is enabled BEFORE password validation
|
>> [password, callback, this](const Result& r) {
|
||||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
try {
|
||||||
|
|
||||||
if (isPgpOnly) {
|
|
||||||
// Return a specific error for PGP-only accounts
|
|
||||||
callback(false, "PGP-only login enabled for this account", UserInfo{});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
|
||||||
|
|
||||||
if (!BCrypt::validatePassword(password, hash)) {
|
|
||||||
callback(false, "", UserInfo{});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserInfo user;
|
|
||||||
user.id = r[0]["id"].as<int64_t>();
|
|
||||||
user.username = r[0]["username"].as<std::string>();
|
|
||||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
|
||||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
|
||||||
user.isPgpOnly = isPgpOnly;
|
|
||||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
|
||||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
|
||||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
|
||||||
|
|
||||||
std::string token = generateToken(user);
|
|
||||||
callback(true, token, user);
|
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
|
||||||
callback(false, "", UserInfo{});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void AuthService::initiatePgpLogin(const std::string& username,
|
|
||||||
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
|
||||||
auto dbClient = app().getDbClient();
|
|
||||||
|
|
||||||
// Generate random challenge
|
|
||||||
auto bytes = drogon::utils::genRandomString(32);
|
|
||||||
std::string challenge = drogon::utils::base64Encode(
|
|
||||||
reinterpret_cast<const unsigned char*>(bytes.data()), bytes.length()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store challenge in Redis with 5 minute TTL
|
|
||||||
RedisHelper::storeKeyAsync("pgp_challenge:" + username, challenge, 300,
|
|
||||||
[dbClient, username, challenge, callback](bool stored) {
|
|
||||||
if (!stored) {
|
|
||||||
callback(false, "", "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's latest public key
|
|
||||||
*dbClient << "SELECT pk.public_key FROM pgp_keys pk "
|
|
||||||
"JOIN users u ON pk.user_id = u.id "
|
|
||||||
"WHERE u.username = $1 "
|
|
||||||
"ORDER BY pk.created_at DESC LIMIT 1"
|
|
||||||
<< username
|
|
||||||
>> [callback, challenge](const Result& r) {
|
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
callback(false, "", "");
|
callback(false, "", UserInfo{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string publicKey = r[0]["public_key"].as<std::string>();
|
// Check if PGP-only is enabled BEFORE password validation
|
||||||
callback(true, challenge, publicKey);
|
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
if (isPgpOnly) {
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
// Return a specific error for PGP-only accounts
|
||||||
callback(false, "", "");
|
callback(false, "PGP-only login enabled for this account", UserInfo{});
|
||||||
};
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||||
|
|
||||||
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
|
bool valid = false;
|
||||||
const std::string& challenge,
|
try {
|
||||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
valid = BCrypt::validatePassword(password, hash);
|
||||||
// Get stored challenge from Redis
|
} catch (const std::exception& e) {
|
||||||
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
||||||
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
callback(false, "", UserInfo{});
|
||||||
if (storedChallenge.empty() || storedChallenge != challenge) {
|
return;
|
||||||
callback(false, "", UserInfo{});
|
}
|
||||||
return;
|
|
||||||
}
|
if (!valid) {
|
||||||
|
|
||||||
// Delete challenge after use
|
|
||||||
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
|
||||||
|
|
||||||
// In a real implementation, you would verify the signature here
|
|
||||||
// For now, we'll trust the client-side verification
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
|
||||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
|
||||||
"FROM users WHERE username = $1"
|
|
||||||
<< username
|
|
||||||
>> [callback, this](const Result& r) {
|
|
||||||
if (r.empty()) {
|
|
||||||
callback(false, "", UserInfo{});
|
callback(false, "", UserInfo{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -235,49 +338,185 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
||||||
user.username = r[0]["username"].as<std::string>();
|
user.username = r[0]["username"].as<std::string>();
|
||||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
user.isPgpOnly = isPgpOnly;
|
||||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||||
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
|
|
||||||
std::string token = generateToken(user);
|
std::string token = generateToken(user);
|
||||||
callback(true, token, user);
|
callback(true, token, user);
|
||||||
}
|
} catch (const std::exception& e) {
|
||||||
>> [callback](const DrogonDbException& e) {
|
LOG_ERROR << "Exception in login callback: " << e.what();
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
|
||||||
callback(false, "", UserInfo{});
|
callback(false, "", UserInfo{});
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in loginUser: " << e.what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::initiatePgpLogin(const std::string& username,
|
||||||
|
std::function<void(bool, const std::string&, const std::string&)> callback) {
|
||||||
|
try {
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
if (!dbClient) {
|
||||||
|
LOG_ERROR << "Database client is null";
|
||||||
|
callback(false, "", "");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// Generate random challenge
|
||||||
|
auto bytes = drogon::utils::genRandomString(32);
|
||||||
|
std::string challenge = drogon::utils::base64Encode(
|
||||||
|
reinterpret_cast<const unsigned char*>(bytes.data()), bytes.length()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store challenge in Redis with 5 minute TTL
|
||||||
|
RedisHelper::storeKeyAsync("pgp_challenge:" + username, challenge, 300,
|
||||||
|
[dbClient, username, challenge, callback](bool stored) {
|
||||||
|
if (!stored) {
|
||||||
|
callback(false, "", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's latest public key
|
||||||
|
*dbClient << "SELECT pk.public_key FROM pgp_keys pk "
|
||||||
|
"JOIN users u ON pk.user_id = u.id "
|
||||||
|
"WHERE u.username = $1 "
|
||||||
|
"ORDER BY pk.created_at DESC LIMIT 1"
|
||||||
|
<< username
|
||||||
|
>> [callback, challenge](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(false, "", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string publicKey = r[0]["public_key"].as<std::string>();
|
||||||
|
callback(true, challenge, publicKey);
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, "", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in initiatePgpLogin: " << e.what();
|
||||||
|
callback(false, "", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::verifyPgpLogin(const std::string& username, const std::string& signature,
|
||||||
|
const std::string& challenge,
|
||||||
|
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||||
|
try {
|
||||||
|
// Get stored challenge from Redis
|
||||||
|
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
||||||
|
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
||||||
|
try {
|
||||||
|
if (storedChallenge.empty() || storedChallenge != challenge) {
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete challenge after use
|
||||||
|
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
||||||
|
|
||||||
|
// In a real implementation, you would verify the signature here
|
||||||
|
// For now, we'll trust the client-side verification
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
if (!dbClient) {
|
||||||
|
LOG_ERROR << "Database client is null";
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||||
|
"FROM users WHERE username = $1 LIMIT 1"
|
||||||
|
<< username
|
||||||
|
>> [callback, this](const Result& r) {
|
||||||
|
try {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserInfo user;
|
||||||
|
user.id = r[0]["id"].as<int64_t>();
|
||||||
|
user.username = r[0]["username"].as<std::string>();
|
||||||
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||||
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||||
|
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||||
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||||
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||||
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||||
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
|
|
||||||
|
std::string token = generateToken(user);
|
||||||
|
callback(true, token, user);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in Redis callback: " << e.what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in verifyPgpLogin: " << e.what();
|
||||||
|
callback(false, "", UserInfo{});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string AuthService::generateToken(const UserInfo& user) {
|
std::string AuthService::generateToken(const UserInfo& user) {
|
||||||
if (jwtSecret_.empty()) {
|
try {
|
||||||
const char* envSecret = std::getenv("JWT_SECRET");
|
if (jwtSecret_.empty()) {
|
||||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
const char* envSecret = std::getenv("JWT_SECRET");
|
||||||
|
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto token = jwt::create()
|
||||||
|
.set_issuer("streaming-app")
|
||||||
|
.set_type("JWS")
|
||||||
|
.set_issued_at(std::chrono::system_clock::now())
|
||||||
|
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
||||||
|
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
||||||
|
.set_payload_claim("username", jwt::claim(user.username))
|
||||||
|
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
||||||
|
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
||||||
|
.set_payload_claim("color_code", jwt::claim(
|
||||||
|
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
||||||
|
)) // Ensure color is never empty
|
||||||
|
.sign(jwt::algorithm::hs256{jwtSecret_});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Failed to generate token: " << e.what();
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
auto token = jwt::create()
|
|
||||||
.set_issuer("streaming-app")
|
|
||||||
.set_type("JWS")
|
|
||||||
.set_issued_at(std::chrono::system_clock::now())
|
|
||||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
|
||||||
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
|
||||||
.set_payload_claim("username", jwt::claim(user.username))
|
|
||||||
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
|
||||||
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
|
||||||
.sign(jwt::algorithm::hs256{jwtSecret_});
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||||
if (jwtSecret_.empty()) {
|
|
||||||
const char* envSecret = std::getenv("JWT_SECRET");
|
|
||||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (jwtSecret_.empty()) {
|
||||||
|
const char* envSecret = std::getenv("JWT_SECRET");
|
||||||
|
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||||
|
}
|
||||||
|
|
||||||
auto decoded = jwt::decode(token);
|
auto decoded = jwt::decode(token);
|
||||||
|
|
||||||
auto verifier = jwt::verify()
|
auto verifier = jwt::verify()
|
||||||
|
|
@ -292,6 +531,14 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||||
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
||||||
|
|
||||||
|
// Get color from token if available, otherwise will need to fetch from DB
|
||||||
|
if (decoded.has_payload_claim("color_code")) {
|
||||||
|
userInfo.colorCode = decoded.get_payload_claim("color_code").as_string();
|
||||||
|
} else {
|
||||||
|
// For older tokens without color, default value
|
||||||
|
userInfo.colorCode = "#561D5E";
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
LOG_DEBUG << "Token validation failed: " << e.what();
|
LOG_DEBUG << "Token validation failed: " << e.what();
|
||||||
|
|
@ -302,46 +549,123 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||||
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
||||||
const std::string& newPassword,
|
const std::string& newPassword,
|
||||||
std::function<void(bool, const std::string&)> callback) {
|
std::function<void(bool, const std::string&)> callback) {
|
||||||
// Validate new password
|
try {
|
||||||
std::string error;
|
// Validate new password
|
||||||
if (!validatePassword(newPassword, error)) {
|
std::string error;
|
||||||
callback(false, error);
|
if (!validatePassword(newPassword, error)) {
|
||||||
return;
|
callback(false, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
if (!dbClient) {
|
||||||
|
LOG_ERROR << "Database client is null";
|
||||||
|
callback(false, "Database connection error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
*dbClient << "SELECT password_hash FROM users WHERE id = $1 LIMIT 1"
|
||||||
|
<< userId
|
||||||
|
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
||||||
|
try {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(false, "User not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||||
|
|
||||||
|
bool valid = false;
|
||||||
|
try {
|
||||||
|
valid = BCrypt::validatePassword(oldPassword, hash);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Failed to validate password: " << e.what();
|
||||||
|
callback(false, "Password validation error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
callback(false, "Incorrect password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
std::string newHash;
|
||||||
|
try {
|
||||||
|
newHash = BCrypt::generateHash(newPassword);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Failed to hash new password: " << e.what();
|
||||||
|
callback(false, "Failed to process new password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
||||||
|
<< newHash << userId
|
||||||
|
>> [callback](const Result&) {
|
||||||
|
callback(true, "");
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to update password: " << e.base().what();
|
||||||
|
callback(false, "Failed to update password");
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in password update callback: " << e.what();
|
||||||
|
callback(false, "Failed to update password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, "Database error");
|
||||||
|
};
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in updatePassword: " << e.what();
|
||||||
|
callback(false, "Failed to update password");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
auto dbClient = app().getDbClient();
|
|
||||||
|
void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback) {
|
||||||
// Verify old password
|
try {
|
||||||
*dbClient << "SELECT password_hash FROM users WHERE id = $1"
|
auto dbClient = app().getDbClient();
|
||||||
<< userId
|
if (!dbClient) {
|
||||||
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
LOG_ERROR << "Database client is null";
|
||||||
if (r.empty()) {
|
callback(false, UserInfo{});
|
||||||
callback(false, "User not found");
|
return;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||||
|
"FROM users WHERE id = $1 LIMIT 1"
|
||||||
|
<< userId
|
||||||
|
>> [callback](const Result& r) {
|
||||||
|
try {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(false, UserInfo{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserInfo user;
|
||||||
|
user.id = r[0]["id"].as<int64_t>();
|
||||||
|
user.username = r[0]["username"].as<std::string>();
|
||||||
|
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||||
|
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||||
|
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||||
|
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||||
|
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||||
|
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||||
|
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||||
|
|
||||||
|
callback(true, user);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception processing user data: " << e.what();
|
||||||
|
callback(false, UserInfo{});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
>> [callback](const DrogonDbException& e) {
|
||||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
LOG_ERROR << "Database error: " << e.base().what();
|
||||||
|
callback(false, UserInfo{});
|
||||||
if (!BCrypt::validatePassword(oldPassword, hash)) {
|
};
|
||||||
callback(false, "Incorrect password");
|
} catch (const std::exception& e) {
|
||||||
return;
|
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
|
||||||
}
|
callback(false, UserInfo{});
|
||||||
|
}
|
||||||
// Update password
|
|
||||||
std::string newHash = BCrypt::generateHash(newPassword);
|
|
||||||
|
|
||||||
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
|
||||||
<< newHash << userId
|
|
||||||
>> [callback](const Result&) {
|
|
||||||
callback(true, "");
|
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Failed to update password: " << e.base().what();
|
|
||||||
callback(false, "Failed to update password");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
>> [callback](const DrogonDbException& e) {
|
|
||||||
LOG_ERROR << "Database error: " << e.base().what();
|
|
||||||
callback(false, "Database error");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <drogon/drogon.h>
|
|
||||||
#include <bcrypt/BCrypt.hpp>
|
|
||||||
#include <jwt-cpp/jwt.h>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <jwt-cpp/jwt.h>
|
||||||
|
#include <bcrypt/BCrypt.hpp>
|
||||||
|
|
||||||
struct UserInfo {
|
struct UserInfo {
|
||||||
int64_t id;
|
int64_t id = 0;
|
||||||
std::string username;
|
std::string username;
|
||||||
bool isAdmin;
|
bool isAdmin = false;
|
||||||
bool isStreamer;
|
bool isStreamer = false;
|
||||||
bool isPgpOnly;
|
bool isPgpOnly = false;
|
||||||
std::string bio;
|
std::string bio;
|
||||||
std::string avatarUrl;
|
std::string avatarUrl;
|
||||||
std::string pgpOnlyEnabledAt;
|
std::string pgpOnlyEnabledAt;
|
||||||
|
std::string colorCode;
|
||||||
};
|
};
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
|
|
@ -23,41 +24,38 @@ public:
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User registration
|
|
||||||
void registerUser(const std::string& username, const std::string& password,
|
void registerUser(const std::string& username, const std::string& password,
|
||||||
const std::string& publicKey, const std::string& fingerprint,
|
const std::string& publicKey, const std::string& fingerprint,
|
||||||
std::function<void(bool success, const std::string& error, int64_t userId)> callback);
|
std::function<void(bool, const std::string&, int64_t)> callback);
|
||||||
|
|
||||||
// User login with password
|
|
||||||
void loginUser(const std::string& username, const std::string& password,
|
void loginUser(const std::string& username, const std::string& password,
|
||||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
std::function<void(bool, const std::string&, const UserInfo&)> callback);
|
||||||
|
|
||||||
// User login with PGP (returns challenge)
|
|
||||||
void initiatePgpLogin(const std::string& username,
|
void initiatePgpLogin(const std::string& username,
|
||||||
std::function<void(bool success, const std::string& challenge, const std::string& publicKey)> callback);
|
std::function<void(bool, const std::string&, const std::string&)> callback);
|
||||||
|
|
||||||
// Verify PGP signature
|
void verifyPgpLogin(const std::string& username, const std::string& signature,
|
||||||
void verifyPgpLogin(const std::string& username, const std::string& signature, const std::string& challenge,
|
const std::string& challenge,
|
||||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
std::function<void(bool, const std::string&, const UserInfo&)> callback);
|
||||||
|
|
||||||
// Validate JWT token
|
std::string generateToken(const UserInfo& user);
|
||||||
bool validateToken(const std::string& token, UserInfo& userInfo);
|
bool validateToken(const std::string& token, UserInfo& userInfo);
|
||||||
|
|
||||||
// Update password
|
// New method to fetch complete user info including color
|
||||||
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword,
|
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||||
std::function<void(bool success, const std::string& error)> callback);
|
|
||||||
|
|
||||||
// Check password requirements
|
void updatePassword(int64_t userId, const std::string& oldPassword,
|
||||||
bool validatePassword(const std::string& password, std::string& error);
|
const std::string& newPassword,
|
||||||
|
std::function<void(bool, const std::string&)> callback);
|
||||||
|
|
||||||
// Generate JWT token
|
void updateUserColor(int64_t userId, const std::string& newColor,
|
||||||
std::string generateToken(const UserInfo& user);
|
std::function<void(bool, const std::string&, const std::string&)> callback);
|
||||||
|
|
||||||
|
void generateUniqueColor(std::function<void(const std::string& color)> callback);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
AuthService() = default;
|
AuthService() = default;
|
||||||
~AuthService() = default;
|
|
||||||
AuthService(const AuthService&) = delete;
|
|
||||||
AuthService& operator=(const AuthService&) = delete;
|
|
||||||
|
|
||||||
std::string jwtSecret_;
|
std::string jwtSecret_;
|
||||||
|
|
||||||
|
bool validatePassword(const std::string& password, std::string& error);
|
||||||
};
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
pgp_only_enabled_at TIMESTAMP WITH TIME ZONE,
|
pgp_only_enabled_at TIMESTAMP WITH TIME ZONE,
|
||||||
bio TEXT DEFAULT '',
|
bio TEXT DEFAULT '',
|
||||||
avatar_url VARCHAR(255),
|
avatar_url VARCHAR(255),
|
||||||
|
user_color VARCHAR(7) UNIQUE NOT NULL, -- Unique hex color for each user
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
@ -49,6 +50,7 @@ CREATE TABLE IF NOT EXISTS stream_keys (
|
||||||
CREATE INDEX idx_users_username ON users(username);
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
CREATE INDEX idx_users_is_streamer ON users(is_streamer);
|
CREATE INDEX idx_users_is_streamer ON users(is_streamer);
|
||||||
CREATE INDEX idx_users_is_pgp_only ON users(is_pgp_only);
|
CREATE INDEX idx_users_is_pgp_only ON users(is_pgp_only);
|
||||||
|
CREATE INDEX idx_users_user_color ON users(user_color);
|
||||||
CREATE INDEX idx_pgp_keys_user_id ON pgp_keys(user_id);
|
CREATE INDEX idx_pgp_keys_user_id ON pgp_keys(user_id);
|
||||||
CREATE INDEX idx_pgp_keys_fingerprint ON pgp_keys(fingerprint);
|
CREATE INDEX idx_pgp_keys_fingerprint ON pgp_keys(fingerprint);
|
||||||
CREATE INDEX idx_realms_user_id ON realms(user_id);
|
CREATE INDEX idx_realms_user_id ON realms(user_id);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,48 @@ function createAuthStore() {
|
||||||
return { success: false, error: data.error || 'Registration failed' };
|
return { success: false, error: data.error || 'Registration failed' };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateColor(color) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const response = await fetch('/api/user/color', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ color })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
// IMPORTANT: Store the new token that includes the updated color
|
||||||
|
if (data.token) {
|
||||||
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
|
||||||
|
// Update the store with new token and user data
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
token: data.token,
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
userColor: data.color,
|
||||||
|
colorCode: data.color // Make sure both fields are updated
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Fallback if no new token (shouldn't happen with current backend)
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
user: { ...state.user, userColor: data.color, colorCode: data.color }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, color: data.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: data.error || 'Failed to update color' };
|
||||||
|
},
|
||||||
|
|
||||||
updateUser(userData) {
|
updateUser(userData) {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -125,4 +167,9 @@ export const isAdmin = derived(
|
||||||
export const isStreamer = derived(
|
export const isStreamer = derived(
|
||||||
auth,
|
auth,
|
||||||
$auth => $auth.user?.isStreamer || false
|
$auth => $auth.user?.isStreamer || false
|
||||||
|
);
|
||||||
|
|
||||||
|
export const userColor = derived(
|
||||||
|
auth,
|
||||||
|
$auth => $auth.user?.colorCode || '#561D5E'
|
||||||
);
|
);
|
||||||
93
frontend/src/lib/stores/user.js
Normal file
93
frontend/src/lib/stores/user.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
function createUserStore() {
|
||||||
|
// Initialize from localStorage if in browser
|
||||||
|
const initialUser = browser ? JSON.parse(localStorage.getItem('user') || 'null') : null;
|
||||||
|
|
||||||
|
const { subscribe, set, update } = writable(initialUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set: (user) => {
|
||||||
|
if (browser && user) {
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
} else if (browser) {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
set(user);
|
||||||
|
},
|
||||||
|
update: (fn) => {
|
||||||
|
update(currentUser => {
|
||||||
|
const newUser = fn(currentUser);
|
||||||
|
if (browser && newUser) {
|
||||||
|
localStorage.setItem('user', JSON.stringify(newUser));
|
||||||
|
}
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateColor: async (newColor) => {
|
||||||
|
const token = browser ? localStorage.getItem('token') : null;
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/color', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ color: newColor })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Update the store with new user data
|
||||||
|
if (data.user) {
|
||||||
|
// Full user data returned
|
||||||
|
set(data.user);
|
||||||
|
} else {
|
||||||
|
// Only color returned, update existing user
|
||||||
|
update(u => u ? { ...u, userColor: data.color } : null);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update color:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
const token = browser ? localStorage.getItem('token') : null;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.user) {
|
||||||
|
set(data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh user:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userStore = createUserStore();
|
||||||
|
|
||||||
|
// Derived store for just the color
|
||||||
|
export const userColor = derived(
|
||||||
|
userStore,
|
||||||
|
$user => $user?.userColor || '#561D5E'
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth';
|
import { auth, isAuthenticated, isAdmin, isStreamer, userColor } from '$lib/stores/auth';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
|
|
@ -75,33 +75,47 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-btn {
|
.user-avatar-btn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--gray);
|
background: var(--gray);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: border-color 0.2s;
|
transition: all 0.2s;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
position: relative;
|
||||||
|
|
||||||
.user-avatar-btn:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-btn img {
|
.user-avatar-btn.has-color {
|
||||||
width: 100%;
|
background: var(--user-color);
|
||||||
height: 100%;
|
}
|
||||||
object-fit: cover;
|
|
||||||
border: none;
|
.user-avatar-btn.has-color.with-image {
|
||||||
}
|
border-color: var(--user-color);
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-btn.has-color:hover {
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-btn img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -126,7 +140,8 @@
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +149,15 @@
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-color-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-role {
|
.dropdown-role {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
|
|
@ -169,7 +193,13 @@
|
||||||
{#if !$auth.loading}
|
{#if !$auth.loading}
|
||||||
{#if $isAuthenticated}
|
{#if $isAuthenticated}
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-avatar-btn" on:click={toggleDropdown}>
|
<button
|
||||||
|
class="user-avatar-btn"
|
||||||
|
class:has-color={$userColor}
|
||||||
|
class:with-image={$auth.user.avatarUrl}
|
||||||
|
style="--user-color: {$userColor}"
|
||||||
|
on:click={toggleDropdown}
|
||||||
|
>
|
||||||
{#if $auth.user.avatarUrl}
|
{#if $auth.user.avatarUrl}
|
||||||
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
|
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -181,6 +211,10 @@
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div class="dropdown-header">
|
<div class="dropdown-header">
|
||||||
<a href="/profile/{$auth.user.username}" class="dropdown-username">
|
<a href="/profile/{$auth.user.username}" class="dropdown-username">
|
||||||
|
<span
|
||||||
|
class="user-color-dot"
|
||||||
|
style="background: {$userColor}"
|
||||||
|
></span>
|
||||||
{$auth.user.username}
|
{$auth.user.username}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-role">
|
<div class="dropdown-role">
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,22 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-wrapper::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--user-color, var(--primary)) 0%,
|
||||||
|
var(--user-color, var(--primary)) 50%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-area {
|
.player-area {
|
||||||
|
|
@ -438,10 +454,24 @@
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-info-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--user-color, var(--primary));
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-header {
|
.stream-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-header h1 {
|
.stream-header h1 {
|
||||||
|
|
@ -462,11 +492,38 @@
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--gray);
|
background: var(--gray);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--white);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-avatar.has-color {
|
||||||
|
background: var(--user-color);
|
||||||
|
border-color: var(--user-color);
|
||||||
|
border-width: 3px;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-avatar.has-color.with-image {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.streamer-name {
|
.streamer-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-count {
|
.viewer-count {
|
||||||
|
|
@ -566,6 +623,39 @@
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color accent for chat/info sections */
|
||||||
|
.color-accent-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--user-color, var(--primary));
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for live indicator */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #ff0000;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -581,7 +671,7 @@
|
||||||
{:else if realm}
|
{:else if realm}
|
||||||
<div class="stream-container">
|
<div class="stream-container">
|
||||||
<div class="player-section">
|
<div class="player-section">
|
||||||
<div class="player-wrapper">
|
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||||
<div class="player-area">
|
<div class="player-area">
|
||||||
<div class="dummy-player"></div>
|
<div class="dummy-player"></div>
|
||||||
<div class="player-container">
|
<div class="player-container">
|
||||||
|
|
@ -590,15 +680,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stream-info-section">
|
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||||
<div class="stream-header">
|
<div class="stream-header">
|
||||||
<h1>{realm.name}</h1>
|
<h1>{realm.name}</h1>
|
||||||
<div class="streamer-info">
|
<div class="streamer-info">
|
||||||
{#if realm.avatarUrl}
|
<div
|
||||||
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
|
class="streamer-avatar"
|
||||||
{:else}
|
class:has-color={realm.colorCode}
|
||||||
<div class="streamer-avatar"></div>
|
class:with-image={realm.avatarUrl}
|
||||||
{/if}
|
style="--user-color: {realm.colorCode || '#561D5E'}"
|
||||||
|
>
|
||||||
|
{#if realm.avatarUrl}
|
||||||
|
<img src={realm.avatarUrl} alt={realm.username} />
|
||||||
|
{:else}
|
||||||
|
{realm.username?.charAt(0).toUpperCase() || '?'}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="streamer-name">{realm.username}</div>
|
<div class="streamer-name">{realm.username}</div>
|
||||||
<div class="viewer-count">
|
<div class="viewer-count">
|
||||||
|
|
@ -612,13 +709,14 @@
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
|
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
|
||||||
<h3>Stream Stats</h3>
|
<h3>Stream Stats</h3>
|
||||||
|
|
||||||
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
||||||
{#if stats.isLive}
|
{#if stats.isLive}
|
||||||
<span>●</span> Live
|
Live
|
||||||
{:else}
|
{:else}
|
||||||
<span>●</span> Offline
|
Offline
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -644,7 +742,7 @@
|
||||||
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if stats.codec}
|
{#if stats.codec && stats.codec !== 'N/A'}
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Codec</span>
|
<span class="stat-label">Codec</span>
|
||||||
<span class="stat-value">{stats.codec}</span>
|
<span class="stat-value">{stats.codec}</span>
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,24 @@ function formatBitrate(bitrate) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
.realm-owner-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realm-owner-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
.realm-card {
|
.realm-card {
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,19 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar.has-color {
|
||||||
|
background: var(--user-color);
|
||||||
|
border-color: var(--user-color);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar.has-color.with-image {
|
||||||
|
border-width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar img {
|
.profile-avatar img {
|
||||||
|
|
@ -129,6 +142,26 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.pgp-only-badge {
|
.pgp-only-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -176,6 +209,19 @@
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--user-color, var(--primary));
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bio-section h3 {
|
.bio-section h3 {
|
||||||
|
|
@ -327,7 +373,12 @@
|
||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{:else if profile}
|
{:else if profile}
|
||||||
<div class="profile-header">
|
<div class="profile-header">
|
||||||
<div class="profile-avatar">
|
<div
|
||||||
|
class="profile-avatar"
|
||||||
|
class:has-color={profile.colorCode}
|
||||||
|
class:with-image={profile.avatarUrl}
|
||||||
|
style="--user-color: {profile.colorCode || '#561D5E'}"
|
||||||
|
>
|
||||||
{#if profile.avatarUrl}
|
{#if profile.avatarUrl}
|
||||||
<img src={profile.avatarUrl} alt="{profile.username}" />
|
<img src={profile.avatarUrl} alt="{profile.username}" />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -340,9 +391,13 @@
|
||||||
<p class="member-since">
|
<p class="member-since">
|
||||||
Member since {new Date(profile.createdAt).toLocaleDateString()}
|
Member since {new Date(profile.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="color-badge">
|
||||||
|
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
|
||||||
|
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
|
||||||
|
</div>
|
||||||
{#if profile.isPgpOnly}
|
{#if profile.isPgpOnly}
|
||||||
<div class="pgp-only-badge">
|
<div class="pgp-only-badge">
|
||||||
<span>🔒</span>
|
<span>🔐</span>
|
||||||
<span>PGP-Only Authentication</span>
|
<span>PGP-Only Authentication</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -367,7 +422,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if activeTab === 'bio'}
|
{#if activeTab === 'bio'}
|
||||||
<div class="bio-section">
|
<div class="bio-section" style="--user-color: {profile.colorCode || '#561D5E'}">
|
||||||
<h3>About</h3>
|
<h3>About</h3>
|
||||||
{#if profile.bio}
|
{#if profile.bio}
|
||||||
<p>{profile.bio}</p>
|
<p>{profile.bio}</p>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,24 @@
|
||||||
let newPassword = '';
|
let newPassword = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
let userColor = '#561D5E';
|
||||||
|
let newColor = '';
|
||||||
|
let showColorPicker = false;
|
||||||
|
let colorLoading = false;
|
||||||
|
let colorError = '';
|
||||||
|
let colorMessage = '';
|
||||||
|
|
||||||
|
// Popular colors to choose from
|
||||||
|
const suggestedColors = [
|
||||||
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#FD79A8',
|
||||||
|
'#A29BFE', '#6C5CE7', '#FAB1A0', '#74B9FF', '#A8E6CF', '#FFD3B6',
|
||||||
|
'#FF8CC3', '#00B894', '#00CEC9', '#0984E3', '#6C5CE7', '#E17055',
|
||||||
|
'#FDCB6E', '#55A3FF', '#FD79A8', '#BADC58', '#F8B739', '#FA8231',
|
||||||
|
'#EB3B5A', '#FC5C65', '#45AAF2', '#4B7BEC', '#A55EEA', '#D63031',
|
||||||
|
'#74B9FF', '#A29BFE', '#FD79A8', '#E17055', '#00B894', '#00CEC9'
|
||||||
|
];
|
||||||
|
|
||||||
// PGP
|
// PGP
|
||||||
let pgpKeys = [];
|
let pgpKeys = [];
|
||||||
let pgpOnly = false;
|
let pgpOnly = false;
|
||||||
|
|
@ -67,6 +85,8 @@
|
||||||
pgpOnly = data.user.isPgpOnly === true;
|
pgpOnly = data.user.isPgpOnly === true;
|
||||||
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
|
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
|
||||||
avatarPreview = data.user.avatarUrl || '';
|
avatarPreview = data.user.avatarUrl || '';
|
||||||
|
userColor = data.user.colorCode || '#561D5E';
|
||||||
|
newColor = userColor;
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
|
|
||||||
// Update auth store with fresh data
|
// Update auth store with fresh data
|
||||||
|
|
@ -81,6 +101,8 @@
|
||||||
pgpOnly = $auth.user.isPgpOnly === true;
|
pgpOnly = $auth.user.isPgpOnly === true;
|
||||||
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
|
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
|
||||||
avatarPreview = $auth.user.avatarUrl || '';
|
avatarPreview = $auth.user.avatarUrl || '';
|
||||||
|
userColor = $auth.user.colorCode || '#561D5E';
|
||||||
|
newColor = userColor;
|
||||||
currentUser = $auth.user;
|
currentUser = $auth.user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +266,55 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateColor() {
|
||||||
|
if (!newColor || newColor === userColor) {
|
||||||
|
colorError = 'Please select a different color';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
colorLoading = true;
|
||||||
|
colorError = '';
|
||||||
|
colorMessage = '';
|
||||||
|
|
||||||
|
const result = await auth.updateColor(newColor);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
userColor = result.color;
|
||||||
|
colorMessage = 'Color updated successfully!';
|
||||||
|
showColorPicker = false;
|
||||||
|
|
||||||
|
// Refresh the current user data to ensure consistency
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear message after 3 seconds
|
||||||
|
setTimeout(() => { colorMessage = ''; }, 3000);
|
||||||
|
} else {
|
||||||
|
colorError = result.error || 'Failed to update color';
|
||||||
|
}
|
||||||
|
|
||||||
|
colorLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestedColor(color) {
|
||||||
|
newColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorInput(event) {
|
||||||
|
const value = event.target.value;
|
||||||
|
// Ensure it starts with # and is uppercase
|
||||||
|
if (value.length > 0 && value[0] !== '#') {
|
||||||
|
newColor = '#' + value;
|
||||||
|
} else {
|
||||||
|
newColor = value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidColor(color) {
|
||||||
|
return /^#[0-9A-F]{6}$/i.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
// Fixed: Handle checkbox click to show warning
|
// Fixed: Handle checkbox click to show warning
|
||||||
function handlePgpCheckboxClick(event) {
|
function handlePgpCheckboxClick(event) {
|
||||||
if (pgpOnly) {
|
if (pgpOnly) {
|
||||||
|
|
@ -777,6 +848,68 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker styles */
|
||||||
|
.color-picker-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected {
|
||||||
|
border-color: var(--white);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -790,6 +923,13 @@
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
class:active={activeTab === 'appearance'}
|
||||||
|
on:click={() => activeTab = 'appearance'}
|
||||||
|
>
|
||||||
|
Appearance
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab-button"
|
class="tab-button"
|
||||||
class:active={activeTab === 'password'}
|
class:active={activeTab === 'password'}
|
||||||
|
|
@ -884,6 +1024,131 @@
|
||||||
{loading ? 'Saving...' : 'Save Profile'}
|
{loading ? 'Saving...' : 'Save Profile'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'appearance'}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Appearance Settings</h2>
|
||||||
|
|
||||||
|
{#if colorMessage}
|
||||||
|
<div class="success" style="margin-bottom: 1rem;">{colorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if colorError}
|
||||||
|
<div class="error" style="margin-bottom: 1rem;">{colorError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Color</label>
|
||||||
|
<p style="color: var(--gray); font-size: 0.9rem; margin-bottom: 1rem;">
|
||||||
|
Your unique color appears next to your name across the platform
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="color-picker-container">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<div
|
||||||
|
class="color-preview"
|
||||||
|
style="background: {userColor};"
|
||||||
|
title="Current color"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-family: monospace; font-size: 1.1rem;">
|
||||||
|
{userColor}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--gray);">
|
||||||
|
Current color
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click={() => { showColorPicker = !showColorPicker; newColor = userColor; }}
|
||||||
|
>
|
||||||
|
{showColorPicker ? 'Cancel' : 'Change Color'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showColorPicker}
|
||||||
|
<div style="margin-top: 2rem; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Choose a new color</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="color-input">Custom Color</label>
|
||||||
|
<div class="color-input-wrapper">
|
||||||
|
<div
|
||||||
|
class="color-preview"
|
||||||
|
style="background: {isValidColor(newColor) ? newColor : '#000'};"
|
||||||
|
></div>
|
||||||
|
<input
|
||||||
|
id="color-input"
|
||||||
|
type="text"
|
||||||
|
class="color-input"
|
||||||
|
value={newColor}
|
||||||
|
on:input={handleColorInput}
|
||||||
|
placeholder="#000000"
|
||||||
|
maxlength="7"
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newColor}
|
||||||
|
on:input={(e) => newColor = e.target.value.toUpperCase()}
|
||||||
|
style="width: 50px; height: 40px; cursor: pointer; border: 1px solid var(--border); border-radius: 4px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small style="color: var(--gray);">
|
||||||
|
Enter a hex color code (e.g., #FF6B6B)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Suggested Colors</label>
|
||||||
|
<div class="color-grid">
|
||||||
|
{#each suggestedColors as color}
|
||||||
|
<button
|
||||||
|
class="color-option"
|
||||||
|
class:selected={newColor === color}
|
||||||
|
style="background: {color};"
|
||||||
|
on:click={() => selectSuggestedColor(color)}
|
||||||
|
title={color}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
|
||||||
|
<button
|
||||||
|
on:click={updateColor}
|
||||||
|
disabled={colorLoading || !isValidColor(newColor) || newColor === userColor}
|
||||||
|
>
|
||||||
|
{colorLoading ? 'Updating...' : 'Save Color'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click={() => { showColorPicker = false; newColor = userColor; }}
|
||||||
|
disabled={colorLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if newColor && newColor !== userColor}
|
||||||
|
<div style="margin-top: 1rem; padding: 1rem; background: rgba(0, 0, 0, 0.3); border-radius: 4px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<div
|
||||||
|
class="color-preview"
|
||||||
|
style="background: {newColor};"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--gray);">Preview</div>
|
||||||
|
<div style="font-family: monospace;">{newColor}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if activeTab === 'password'}
|
{:else if activeTab === 'password'}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Change Password</h2>
|
<h2>Change Password</h2>
|
||||||
|
|
@ -891,7 +1156,7 @@
|
||||||
{#if pgpOnly}
|
{#if pgpOnly}
|
||||||
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
|
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
|
||||||
<p>
|
<p>
|
||||||
<span class="check-icon">🔒</span>
|
<span class="check-icon">🔐</span>
|
||||||
<strong>PGP-only mode is enabled</strong>
|
<strong>PGP-only mode is enabled</strong>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue