Compare commits

..

9 Commits

8 changed files with 481 additions and 133 deletions

41
add_profile.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
require 'db.php';
require 'auth.php';
header('Content-Type: application/json');
require_login();
$user_id = current_user_id();
$data = json_decode(file_get_contents('php://input'), true);
$name = trim($data['name'] ?? '');
if ($name === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Profile name is required']);
exit;
}
// Prevent duplicate profile names per user
$stmt = $pdo->prepare("SELECT id FROM profiles WHERE user_id = ? AND name = ?");
$stmt->execute([$user_id, $name]);
if ($stmt->fetchColumn()) {
http_response_code(409);
echo json_encode(['success' => false, 'error' => 'Profile already exists']);
exit;
}
// Insert new profile
$stmt = $pdo->prepare("INSERT INTO profiles (user_id, name, is_default) VALUES (?, ?, 0)");
$stmt->execute([$user_id, $name]);
$newProfileId = $pdo->lastInsertId();
// Make it active immediately
$_SESSION['active_profile_id'] = (int)$newProfileId;
echo json_encode([
'success' => true,
'profile_id' => $newProfileId
]);

View File

@@ -6,6 +6,13 @@ header('Content-Type: application/json');
require_login(); require_login();
$user_id = current_user_id(); $user_id = current_user_id();
$profile_id = $_SESSION['active_profile_id'] ?? null;
if (!$profile_id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No active profile']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$name = trim($data['name'] ?? ''); $name = trim($data['name'] ?? '');
@@ -16,7 +23,7 @@ if ($name === '') {
exit; exit;
} }
$stmt = $pdo->prepare("INSERT INTO projects (user_id, name) VALUES (?, ?)"); $stmt = $pdo->prepare("INSERT INTO projects (user_id, profile_id, name) VALUES (?, ?, ?)");
$stmt->execute([$user_id, $name]); $stmt->execute([$user_id, $profile_id, $name]);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]); echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);

View File

@@ -6,9 +6,16 @@ header('Content-Type: application/json');
require_login(); require_login();
$user_id = current_user_id(); $user_id = current_user_id();
$profile_id = $_SESSION['active_profile_id'] ?? null;
$stmt = $pdo->prepare("SELECT * FROM projects WHERE user_id = ? ORDER BY sort_order ASC"); if (!$profile_id) {
$stmt->execute([$user_id]); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No active profile']);
exit;
}
$stmt = $pdo->prepare("SELECT * FROM projects WHERE user_id = ? AND profile_id = ? ORDER BY sort_order ASC");
$stmt->execute([$user_id, $profile_id]);
$projects = $stmt->fetchAll(); $projects = $stmt->fetchAll();
foreach ($projects as &$project) { foreach ($projects as &$project) {

18
get_profiles.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
require 'db.php';
require 'auth.php';
header('Content-Type: application/json');
require_login();
$user_id = current_user_id();
$stmt = $pdo->prepare("SELECT id, name, is_default FROM profiles WHERE user_id = ? ORDER BY is_default DESC, name ASC");
$stmt->execute([$user_id]);
$profiles = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'active_profile_id' => $_SESSION['active_profile_id'] ?? null,
'profiles' => $profiles
]);

190
index.php
View File

@@ -78,11 +78,6 @@
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.add-project-btn {
font-size: 1.5rem; /* increase to your desired size */
line-height: 1;
}
.settings-btn { .settings-btn {
font-size: 1.5rem; /* increase to your desired size */ font-size: 1.5rem; /* increase to your desired size */
line-height: 1; line-height: 1;
@@ -136,6 +131,52 @@
color: #d00; color: #d00;
} }
.user-menu-btn {
font-size: 1.4rem;
padding: 0.5rem 0.9rem;
}
.header-action-btn {
font-size: 1.4rem;
padding: 0.6rem 0.9rem;
display: flex;
align-items: center;
justify-content: center;
}
.profile-select {
height: 32px; /* match your header-action-btn buttons */
font-size: 1.5rem;
font-weight: 700; /* or 700 for stronger bold */
padding-right: 2.5rem; /* key: reserve space for the arrow */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.add-project-btn {
font-size: 1.5rem;
font-weight: 600; /* or 700 for stronger bold */
padding: 0.5rem 1rem;
}
.custom-dropdown {
min-width: 120px;
text-align: right;
}
.custom-dropdown .dropdown-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.custom-dropdown .dropdown-item,
.custom-dropdown .dropdown-item-text {
font-size: 1.5rem !important;
text-align: right;
}
.project-color-0 { background-color: #f9f9fc; } .project-color-0 { background-color: #f9f9fc; }
.project-color-1 { background-color: #eefaf5; } .project-color-1 { background-color: #eefaf5; }
.project-color-2 { background-color: #fef7e0; } .project-color-2 { background-color: #fef7e0; }
@@ -228,9 +269,37 @@
<i class="bi bi-gear-fill"></i> <i class="bi bi-gear-fill"></i>
</button> </button>
</span> </span>
<button class="btn btn-sm btn-success add-project-btn" data-bs-toggle="modal" data-bs-target="#addProjectModal" title="Add Project">
<i class="bi bi-plus"></i> <div class="d-flex align-items-center gap-3">
<select id="profileSelect" class="form-select btn-lg profile-select" style="width: 140px;"></select>
<!-- Add Project -->
<button class="btn btn-success btn-lg header-action-btn"
data-bs-toggle="modal"
data-bs-target="#addProjectModal">
<i class="bi bi-plus-lg"></i>
</button> </button>
<!-- User Dropdown -->
<div class="dropdown">
<button class="btn btn-outline-secondary btn-lg header-action-btn"
type="button"
id="menuButton"
data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end custom-dropdown" aria-labelledby="menuButton">
<li class="dropdown-item-text fw-semibold fs-5" id="dropdownUserEmail"></li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item fs-5" type="button" id="logoutBtn">
<i class="bi bi-box-arrow-right"></i>
<span>Logout</span>
</button>
</li>
</ul>
</div>
</div>
</h1> </h1>
@@ -371,8 +440,8 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email</label> <label class="form-label">Email or Username</label>
<input type="email" class="form-control" id="loginEmail" required> <input type="text" class="form-control" id="loginEmail" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>
@@ -401,6 +470,10 @@
<label class="form-label">Email</label> <label class="form-label">Email</label>
<input type="email" class="form-control" id="registerEmail" required> <input type="email" class="form-control" id="registerEmail" required>
</div> </div>
<div class="mb-3">
<label class="form-label">Username (optional)</label>
<input type="text" class="form-control" id="registerUsername">
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password (8+ chars)</label> <label class="form-label">Password (8+ chars)</label>
<input type="password" class="form-control" id="registerPassword" required minlength="8"> <input type="password" class="form-control" id="registerPassword" required minlength="8">
@@ -479,11 +552,29 @@
favicon.type = "image/svg+xml"; favicon.type = "image/svg+xml";
}); });
}); });
} }
function loadProfiles() {
fetch('get_profiles.php')
.then(r => r.json())
.then(data => {
if (!data.success) return;
const sel = document.getElementById('profileSelect');
sel.innerHTML = '';
data.profiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
sel.appendChild(opt);
});
if (data.active_profile_id) {
sel.value = String(data.active_profile_id);
}
});
}
function fetchProjects() { function fetchProjects() {
fetch('get_data.php') fetch('get_data.php')
@@ -559,14 +650,14 @@
}); });
// Create the drop indicator element // Create the drop indicator element
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'project-column'; placeholder.className = 'project-column';
placeholder.innerHTML = `<div class="project-placeholder"></div>`; placeholder.innerHTML = `<div class="project-placeholder"></div>`;
// Setup drag events for project reordering // Setup drag events for project reordering
document.querySelectorAll('.project-column').forEach(col => { document.querySelectorAll('.project-column').forEach(col => {
col.setAttribute('draggable', 'true'); col.setAttribute('draggable', 'true');
col.addEventListener('dragstart', (e) => { col.addEventListener('dragstart', (e) => {
@@ -603,7 +694,7 @@ document.querySelectorAll('.project-column').forEach(col => {
updateProjectOrder(); // This already exists in your code updateProjectOrder(); // This already exists in your code
} }
}); });
}); });
// Enable drag-and-drop sorting // Enable drag-and-drop sorting
@@ -855,10 +946,22 @@ document.querySelectorAll('.project-column').forEach(col => {
.then(data => { .then(data => {
bootstrap.Modal.getInstance(document.getElementById('loginModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('loginModal')).hide();
// Show/hide settings button according to permissions
if (!data.user.can_manage_settings) { if (!data.user.can_manage_settings) {
document.querySelectorAll('.settings-btn').forEach(b => b.style.display = 'none'); document.querySelectorAll('.settings-btn').forEach(b => b.style.display = 'none');
} else {
document.querySelectorAll('.settings-btn').forEach(b => b.style.display = '');
} }
// Update user email or username if exists in the dropdown and refresh profiles + projects
if (data.user) {
const userEl = document.getElementById('dropdownUserEmail');
if (userEl) userEl.textContent = (data.user.username && data.user.username.trim())
? data.user.username
: data.user.email;
}
loadProfiles();
fetchProjects(); fetchProjects();
}) })
.catch(ex => { .catch(ex => {
@@ -871,6 +974,7 @@ document.querySelectorAll('.project-column').forEach(col => {
document.getElementById('registerForm').addEventListener('submit', (e) => { document.getElementById('registerForm').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const email = document.getElementById('registerEmail').value.trim(); const email = document.getElementById('registerEmail').value.trim();
const username = document.getElementById('registerUsername').value.trim();
const password = document.getElementById('registerPassword').value; const password = document.getElementById('registerPassword').value;
const err = document.getElementById('registerError'); const err = document.getElementById('registerError');
@@ -881,7 +985,7 @@ document.querySelectorAll('.project-column').forEach(col => {
fetch('register.php', { fetch('register.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, username: username || null, password })
}) })
.then(async r => { .then(async r => {
const data = await r.json(); const data = await r.json();
@@ -898,6 +1002,22 @@ document.querySelectorAll('.project-column').forEach(col => {
}); });
}); });
document.getElementById('profileSelect').addEventListener('change', () => {
const profileId = parseInt(document.getElementById('profileSelect').value, 10);
fetch('set_profile.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
})
.then(r => r.json())
.then(data => {
if (!data.success) return;
fetchProjects();
});
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadSettings(); loadSettings();
@@ -910,15 +1030,49 @@ document.querySelectorAll('.project-column').forEach(col => {
return; return;
} }
// Add user name to user menu
if (me.logged_in) {
document.getElementById('dropdownUserEmail').textContent =
(me.user.username && me.user.username.trim()) ? me.user.username : me.user.email;
}
// Hide settings button if user can't manage settings // Hide settings button if user can't manage settings
if (!me.user.can_manage_settings) { if (!me.user.can_manage_settings) {
document.querySelectorAll('.settings-btn').forEach(b => b.style.display = 'none'); document.querySelectorAll('.settings-btn').forEach(b => b.style.display = 'none');
} }
loadProfiles();
fetchProjects(); fetchProjects();
}); });
}); });
document.addEventListener('DOMContentLoaded', () => {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
fetch('logout.php')
.then(res => res.json())
.then(data => {
if (!data.success) return;
// Clear UI
document.getElementById('projectGrid').innerHTML = '';
// Clear Profile List
document.getElementById('profileSelect').innerHTML = '';
// Clear User Email
document.getElementById('dropdownUserEmail').textContent = '';
// Show login modal
new bootstrap.Modal(document.getElementById('loginModal')).show();
})
.catch(err => console.error('Logout failed:', err));
});
}
});
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>

View File

@@ -5,19 +5,32 @@ require 'auth.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$email = strtolower(trim($data['email'] ?? ''));
// Keep front-end field name "email" for compatibility; it can now be email OR username
$identifierRaw = trim($data['email'] ?? '');
$identifier = strtolower($identifierRaw);
$password = strval($data['password'] ?? ''); $password = strval($data['password'] ?? '');
$stmt = $pdo->prepare(" if ($identifier === '') {
SELECT u.id, u.email, u.password_hash, http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Email or username is required']);
exit;
}
$isEmail = filter_var($identifier, FILTER_VALIDATE_EMAIL) !== false;
$sql = "
SELECT u.id, u.email, u.username, u.password_hash,
r.name AS role_name, r.name AS role_name,
r.can_manage_settings r.can_manage_settings
FROM users u FROM users u
JOIN roles r ON r.id = u.role_id JOIN roles r ON r.id = u.role_id
WHERE u.email = ? WHERE " . ($isEmail ? "u.email = ?" : "u.username = ?") . "
LIMIT 1 LIMIT 1
"); ";
$stmt->execute([$email]);
$stmt = $pdo->prepare($sql);
$stmt->execute([$identifier]);
$user = $stmt->fetch(PDO::FETCH_ASSOC); $user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['password_hash'])) { if (!$user || !password_verify($password, $user['password_hash'])) {
@@ -27,18 +40,40 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
} }
$_SESSION['user'] = [ $_SESSION['user'] = [
'id' => intval($user['id']), 'id' => (int)$user['id'],
'email' => $user['email'], 'email' => $user['email'],
'username' => $user['username'], // NEW
'role' => $user['role_name'], 'role' => $user['role_name'],
'can_manage_settings' => intval($user['can_manage_settings']), 'can_manage_settings' => (int)$user['can_manage_settings'],
]; ];
// Set active profile for this session (default profile if available)
$stmt = $pdo->prepare("SELECT id FROM profiles WHERE user_id = ? AND is_default = 1 LIMIT 1");
$stmt->execute([$_SESSION['user']['id']]);
$profileId = $stmt->fetchColumn();
if (!$profileId) {
$stmt = $pdo->prepare("SELECT id FROM profiles WHERE user_id = ? ORDER BY id ASC LIMIT 1");
$stmt->execute([$_SESSION['user']['id']]);
$profileId = $stmt->fetchColumn();
}
if (!$profileId) {
$stmt = $pdo->prepare("INSERT INTO profiles (user_id, name, is_default) VALUES (?, 'Default', 1)");
$stmt->execute([$_SESSION['user']['id']]);
$profileId = $pdo->lastInsertId();
}
$_SESSION['active_profile_id'] = (int)$profileId;
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'user' => [ 'user' => [
'id' => $_SESSION['user']['id'], 'id' => $_SESSION['user']['id'],
'email' => $_SESSION['user']['email'], 'email' => $_SESSION['user']['email'],
'username' => $_SESSION['user']['username'], // NEW
'role' => $_SESSION['user']['role'], 'role' => $_SESSION['user']['role'],
'can_manage_settings' => $_SESSION['user']['can_manage_settings'], 'can_manage_settings' => $_SESSION['user']['can_manage_settings'],
] ],
'active_profile_id' => $_SESSION['active_profile_id']
]); ]);

View File

@@ -6,6 +6,8 @@ header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$email = strtolower(trim($data['email'] ?? '')); $email = strtolower(trim($data['email'] ?? ''));
$usernameRaw = trim($data['username'] ?? '');
$username = $usernameRaw === '' ? null : strtolower($usernameRaw);
$password = strval($data['password'] ?? ''); $password = strval($data['password'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -19,12 +21,31 @@ if (strlen($password) < 8) {
exit; exit;
} }
// Optional username validation
if ($username !== null) {
if (strlen($username) < 3 || strlen($username) > 50) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Username must be between 3 and 50 characters']);
exit;
}
// letters/numbers + . _ - ; must start/end with letter/number
if (!preg_match('/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/', $username)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Username may contain letters, numbers, underscore, hyphen, dot, and must start/end with a letter or number'
]);
exit;
}
}
$hash = password_hash($password, PASSWORD_DEFAULT); $hash = password_hash($password, PASSWORD_DEFAULT);
// Standard role id from roles table // Standard role id from roles table
$stmt = $pdo->prepare("SELECT id FROM roles WHERE name = 'standard' LIMIT 1"); $stmt = $pdo->prepare("SELECT id FROM roles WHERE name = 'standard' LIMIT 1");
$stmt->execute(); $stmt->execute();
$role_id = intval($stmt->fetchColumn()); $role_id = (int)$stmt->fetchColumn();
if ($role_id <= 0) { if ($role_id <= 0) {
http_response_code(500); http_response_code(500);
@@ -33,10 +54,45 @@ if ($role_id <= 0) {
} }
try { try {
$stmt = $pdo->prepare("INSERT INTO users (email, password_hash, role_id) VALUES (?, ?, ?)"); $pdo->beginTransaction();
$stmt->execute([$email, $hash, $role_id]);
// Friendly conflict checks (DB unique constraints should still exist)
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = ? LIMIT 1');
$stmt->execute([$email]);
if ($stmt->fetchColumn()) {
$pdo->rollBack();
http_response_code(409);
echo json_encode(['success' => false, 'error' => 'Email already in use']);
exit;
}
if ($username !== null) {
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = ? LIMIT 1');
$stmt->execute([$username]);
if ($stmt->fetchColumn()) {
$pdo->rollBack();
http_response_code(409);
echo json_encode(['success' => false, 'error' => 'Username already in use']);
exit;
}
}
// Create user (username is nullable)
$stmt = $pdo->prepare("INSERT INTO users (email, username, password_hash, role_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$email, $username, $hash, $role_id]);
$userId = (int)$pdo->lastInsertId();
// Create default profile for this user
$stmt = $pdo->prepare("INSERT INTO profiles (user_id, name, is_default) VALUES (?, 'Default', 1)");
$stmt->execute([$userId]);
$pdo->commit();
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
http_response_code(409); http_response_code(409);
echo json_encode(['success' => false, 'error' => 'Account already exists']); echo json_encode(['success' => false, 'error' => 'Account already exists']);
} }

30
set_profile.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
require 'db.php';
require 'auth.php';
header('Content-Type: application/json');
require_login();
$user_id = current_user_id();
$input = json_decode(file_get_contents('php://input'), true);
$profile_id = (int)($input['profile_id'] ?? 0);
if ($profile_id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid profile_id']);
exit;
}
$stmt = $pdo->prepare("SELECT id FROM profiles WHERE id = ? AND user_id = ?");
$stmt->execute([$profile_id, $user_id]);
if (!$stmt->fetchColumn()) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Profile not allowed']);
exit;
}
$_SESSION['active_profile_id'] = $profile_id;
echo json_encode(['success' => true, 'active_profile_id' => $profile_id]);