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;
|
||||
}
|
||||
|
||||
// Update getUsers in AdminController.cpp:
|
||||
void AdminController::getUsers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
|
|
@ -41,7 +42,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
}
|
||||
|
||||
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 "
|
||||
"FROM users u ORDER BY u.created_at DESC"
|
||||
>> [callback](const Result& r) {
|
||||
|
|
@ -56,6 +57,7 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
user["isAdmin"] = row["is_admin"].as<bool>();
|
||||
user["isStreamer"] = row["is_streamer"].as<bool>();
|
||||
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>());
|
||||
users.append(user);
|
||||
}
|
||||
|
|
@ -68,7 +70,6 @@ void AdminController::getUsers(const HttpRequestPtr &req,
|
|||
callback(jsonError("Failed to get users"));
|
||||
};
|
||||
}
|
||||
|
||||
void AdminController::getActiveStreams(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
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,
|
||||
const std::string &realmId) {
|
||||
// Remove authentication requirement for public viewing
|
||||
|
|
@ -349,17 +349,43 @@ void RealmController::getRealm(const HttpRequestPtr &req,
|
|||
void RealmController::updateRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
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);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
callback(jsonResp(resp));
|
||||
// Parse realm ID
|
||||
int64_t id;
|
||||
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,
|
||||
|
|
|
|||
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::getProfile, "/api/users/{1}", 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
|
||||
|
||||
void register_(const HttpRequestPtr &req,
|
||||
|
|
@ -62,6 +64,13 @@ public:
|
|||
void getUserPgpKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
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:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
};
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
#include <drogon/utils/Utilities.h>
|
||||
#include <regex>
|
||||
#include <random>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -27,205 +29,306 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
|||
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,
|
||||
const std::string& publicKey, const std::string& fingerprint,
|
||||
std::function<void(bool, const std::string&, int64_t)> callback) {
|
||||
|
||||
// Validate username
|
||||
if (username.length() < 3 || username.length() > 30) {
|
||||
callback(false, "Username must be between 3 and 30 characters", 0);
|
||||
return;
|
||||
try {
|
||||
LOG_DEBUG << "Starting user registration for: " << username;
|
||||
|
||||
// Validate username
|
||||
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,
|
||||
std::function<void(bool, const std::string&, const UserInfo&)> callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at "
|
||||
"FROM users WHERE username = $1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PGP-only is enabled BEFORE password validation
|
||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
|
||||
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) {
|
||||
try {
|
||||
auto dbClient = app().getDbClient();
|
||||
if (!dbClient) {
|
||||
LOG_ERROR << "Database client is null";
|
||||
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
|
||||
>> [password, callback, this](const Result& r) {
|
||||
try {
|
||||
if (r.empty()) {
|
||||
callback(false, "", "");
|
||||
callback(false, "", UserInfo{});
|
||||
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, "", "");
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Get stored challenge from Redis
|
||||
RedisHelper::getKeyAsync("pgp_challenge:" + username,
|
||||
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
||||
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();
|
||||
*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()) {
|
||||
// Check if PGP-only is enabled BEFORE password validation
|
||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
|
||||
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>();
|
||||
|
||||
bool valid = false;
|
||||
try {
|
||||
valid = BCrypt::validatePassword(password, hash);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to validate password: " << e.what();
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
|
@ -235,49 +338,185 @@ void AuthService::verifyPgpLogin(const std::string& username, const 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.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.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);
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in login callback: " << 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 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) {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
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) {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
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 verifier = jwt::verify()
|
||||
|
|
@ -292,6 +531,14 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
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;
|
||||
} catch (const std::exception& e) {
|
||||
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,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
// Validate new password
|
||||
std::string error;
|
||||
if (!validatePassword(newPassword, error)) {
|
||||
callback(false, error);
|
||||
return;
|
||||
try {
|
||||
// Validate new password
|
||||
std::string error;
|
||||
if (!validatePassword(newPassword, error)) {
|
||||
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();
|
||||
|
||||
// Verify old password
|
||||
*dbClient << "SELECT password_hash FROM users WHERE id = $1"
|
||||
<< userId
|
||||
>> [oldPassword, newPassword, userId, dbClient, callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, "User not found");
|
||||
return;
|
||||
}
|
||||
|
||||
void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback) {
|
||||
try {
|
||||
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 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{});
|
||||
}
|
||||
}
|
||||
|
||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||
|
||||
if (!BCrypt::validatePassword(oldPassword, hash)) {
|
||||
callback(false, "Incorrect password");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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");
|
||||
};
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, UserInfo{});
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in fetchUserInfo: " << e.what();
|
||||
callback(false, UserInfo{});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
|
||||
struct UserInfo {
|
||||
int64_t id;
|
||||
int64_t id = 0;
|
||||
std::string username;
|
||||
bool isAdmin;
|
||||
bool isStreamer;
|
||||
bool isPgpOnly;
|
||||
bool isAdmin = false;
|
||||
bool isStreamer = false;
|
||||
bool isPgpOnly = false;
|
||||
std::string bio;
|
||||
std::string avatarUrl;
|
||||
std::string pgpOnlyEnabledAt;
|
||||
std::string colorCode;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
|
|
@ -23,41 +24,38 @@ public:
|
|||
return instance;
|
||||
}
|
||||
|
||||
// User registration
|
||||
void registerUser(const std::string& username, const std::string& password,
|
||||
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,
|
||||
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,
|
||||
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, const std::string& challenge,
|
||||
std::function<void(bool success, const std::string& token, const UserInfo& user)> callback);
|
||||
void verifyPgpLogin(const std::string& username, const std::string& signature,
|
||||
const std::string& challenge,
|
||||
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);
|
||||
|
||||
// Update password
|
||||
void updatePassword(int64_t userId, const std::string& oldPassword, const std::string& newPassword,
|
||||
std::function<void(bool success, const std::string& error)> callback);
|
||||
// New method to fetch complete user info including color
|
||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||
|
||||
// Check password requirements
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
void updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
// Generate JWT token
|
||||
std::string generateToken(const UserInfo& user);
|
||||
void updateUserColor(int64_t userId, const std::string& newColor,
|
||||
std::function<void(bool, const std::string&, const std::string&)> callback);
|
||||
|
||||
void generateUniqueColor(std::function<void(const std::string& color)> callback);
|
||||
|
||||
private:
|
||||
AuthService() = default;
|
||||
~AuthService() = default;
|
||||
AuthService(const AuthService&) = delete;
|
||||
AuthService& operator=(const AuthService&) = delete;
|
||||
|
||||
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,
|
||||
bio TEXT DEFAULT '',
|
||||
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,
|
||||
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_is_streamer ON users(is_streamer);
|
||||
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_fingerprint ON pgp_keys(fingerprint);
|
||||
CREATE INDEX idx_realms_user_id ON realms(user_id);
|
||||
|
|
|
|||
|
|
@ -95,6 +95,48 @@ function createAuthStore() {
|
|||
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) {
|
||||
update(state => ({
|
||||
...state,
|
||||
|
|
@ -125,4 +167,9 @@ export const isAdmin = derived(
|
|||
export const isStreamer = derived(
|
||||
auth,
|
||||
$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>
|
||||
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 '../app.css';
|
||||
|
||||
|
|
@ -75,33 +75,47 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.user-avatar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
transition: border-color 0.2s;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar-btn:hover {
|
||||
border-color: var(--primary);
|
||||
.user-avatar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-avatar-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
}
|
||||
.user-avatar-btn.has-color {
|
||||
background: var(--user-color);
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
|
|
@ -126,7 +140,8 @@
|
|||
margin-bottom: 0.25rem;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +149,15 @@
|
|||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray);
|
||||
|
|
@ -169,7 +193,13 @@
|
|||
{#if !$auth.loading}
|
||||
{#if $isAuthenticated}
|
||||
<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}
|
||||
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
|
||||
{:else}
|
||||
|
|
@ -181,6 +211,10 @@
|
|||
<div class="dropdown">
|
||||
<div class="dropdown-header">
|
||||
<a href="/profile/{$auth.user.username}" class="dropdown-username">
|
||||
<span
|
||||
class="user-color-dot"
|
||||
style="background: {$userColor}"
|
||||
></span>
|
||||
{$auth.user.username}
|
||||
</a>
|
||||
<div class="dropdown-role">
|
||||
|
|
|
|||
|
|
@ -407,6 +407,22 @@
|
|||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
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 {
|
||||
|
|
@ -438,10 +454,24 @@
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.stream-header h1 {
|
||||
|
|
@ -462,11 +492,38 @@
|
|||
height: 48px;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
|
|
@ -566,6 +623,39 @@
|
|||
padding: 4rem 2rem;
|
||||
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>
|
||||
|
||||
{#if loading}
|
||||
|
|
@ -581,7 +671,7 @@
|
|||
{:else if realm}
|
||||
<div class="stream-container">
|
||||
<div class="player-section">
|
||||
<div class="player-wrapper">
|
||||
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||
<div class="player-area">
|
||||
<div class="dummy-player"></div>
|
||||
<div class="player-container">
|
||||
|
|
@ -590,15 +680,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stream-info-section">
|
||||
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
|
||||
<div class="stream-header">
|
||||
<h1>{realm.name}</h1>
|
||||
<div class="streamer-info">
|
||||
{#if realm.avatarUrl}
|
||||
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
|
||||
{:else}
|
||||
<div class="streamer-avatar"></div>
|
||||
{/if}
|
||||
<div
|
||||
class="streamer-avatar"
|
||||
class:has-color={realm.colorCode}
|
||||
class:with-image={realm.avatarUrl}
|
||||
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 class="streamer-name">{realm.username}</div>
|
||||
<div class="viewer-count">
|
||||
|
|
@ -612,13 +709,14 @@
|
|||
|
||||
<div class="sidebar">
|
||||
<div class="stats-section">
|
||||
<div class="color-accent-bar" style="--user-color: {realm.colorCode || '#561D5E'}"></div>
|
||||
<h3>Stream Stats</h3>
|
||||
|
||||
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
||||
{#if stats.isLive}
|
||||
<span>●</span> Live
|
||||
Live
|
||||
{:else}
|
||||
<span>●</span> Offline
|
||||
Offline
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -644,7 +742,7 @@
|
|||
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.codec}
|
||||
{#if stats.codec && stats.codec !== 'N/A'}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Codec</span>
|
||||
<span class="stat-value">{stats.codec}</span>
|
||||
|
|
|
|||
|
|
@ -227,7 +227,24 @@ function formatBitrate(bitrate) {
|
|||
display: grid;
|
||||
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 {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@
|
|||
font-weight: 600;
|
||||
color: var(--white);
|
||||
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 {
|
||||
|
|
@ -129,6 +142,26 @@
|
|||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -176,6 +209,19 @@
|
|||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
|
|
@ -327,7 +373,12 @@
|
|||
<div class="error">{error}</div>
|
||||
{:else if profile}
|
||||
<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}
|
||||
<img src={profile.avatarUrl} alt="{profile.username}" />
|
||||
{:else}
|
||||
|
|
@ -340,9 +391,13 @@
|
|||
<p class="member-since">
|
||||
Member since {new Date(profile.createdAt).toLocaleDateString()}
|
||||
</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}
|
||||
<div class="pgp-only-badge">
|
||||
<span>🔒</span>
|
||||
<span>🔐</span>
|
||||
<span>PGP-Only Authentication</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -367,7 +422,7 @@
|
|||
</div>
|
||||
|
||||
{#if activeTab === 'bio'}
|
||||
<div class="bio-section">
|
||||
<div class="bio-section" style="--user-color: {profile.colorCode || '#561D5E'}">
|
||||
<h3>About</h3>
|
||||
{#if profile.bio}
|
||||
<p>{profile.bio}</p>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@
|
|||
let newPassword = '';
|
||||
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
|
||||
let pgpKeys = [];
|
||||
let pgpOnly = false;
|
||||
|
|
@ -67,6 +85,8 @@
|
|||
pgpOnly = data.user.isPgpOnly === true;
|
||||
pgpOnlyEnabledAt = data.user.pgpOnlyEnabledAt || '';
|
||||
avatarPreview = data.user.avatarUrl || '';
|
||||
userColor = data.user.colorCode || '#561D5E';
|
||||
newColor = userColor;
|
||||
currentUser = data.user;
|
||||
|
||||
// Update auth store with fresh data
|
||||
|
|
@ -81,6 +101,8 @@
|
|||
pgpOnly = $auth.user.isPgpOnly === true;
|
||||
pgpOnlyEnabledAt = $auth.user.pgpOnlyEnabledAt || '';
|
||||
avatarPreview = $auth.user.avatarUrl || '';
|
||||
userColor = $auth.user.colorCode || '#561D5E';
|
||||
newColor = userColor;
|
||||
currentUser = $auth.user;
|
||||
}
|
||||
}
|
||||
|
|
@ -244,6 +266,55 @@
|
|||
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
|
||||
function handlePgpCheckboxClick(event) {
|
||||
if (pgpOnly) {
|
||||
|
|
@ -777,6 +848,68 @@
|
|||
align-items: center;
|
||||
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>
|
||||
|
||||
<div class="container">
|
||||
|
|
@ -790,6 +923,13 @@
|
|||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'appearance'}
|
||||
on:click={() => activeTab = 'appearance'}
|
||||
>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'password'}
|
||||
|
|
@ -884,6 +1024,131 @@
|
|||
{loading ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</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'}
|
||||
<div class="card">
|
||||
<h2>Change Password</h2>
|
||||
|
|
@ -891,7 +1156,7 @@
|
|||
{#if pgpOnly}
|
||||
<div class="pgp-enabled-info" style="margin-bottom: 2rem;">
|
||||
<p>
|
||||
<span class="check-icon">🔒</span>
|
||||
<span class="check-icon">🔐</span>
|
||||
<strong>PGP-only mode is enabled</strong>
|
||||
</p>
|
||||
<p style="font-size: 0.9rem; margin-bottom: 0;">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue