release(v2.5.2): new user management hub & relocated shared upload limits

This commit is contained in:
Ryan
2025-12-10 01:59:23 -05:00
committed by GitHub
parent 8088cebfe1
commit d68e726396
9 changed files with 1207 additions and 320 deletions

View File

@@ -1,5 +1,67 @@
# Changelog
## Changes 12/10/2025 (v2.5.2)
release(v2.5.2): new user management hub & relocated shared upload limits
**Admin panel User Management**
- Added a new **“Manage users” hub** in the Users section, replacing the old separate “Add user / Remove user / User permissions” buttons.
- Introduced an inline **Add user dropdown card** anchored directly under the “Add user” button:
- Opens as a small card right under the button.
- Creates the user via `/api/addUser.php` and auto-selects them in the dropdown on success.
- Closes the card after a successful create.
- Added an inline **User Permissions (flags) row** for the selected user:
- Toggles `readOnly`, `disableUpload`, `canShare`, and `bypassOwnership`.
- Changes are saved immediately via `updateUserPermissions.php` and cached in `__userFlagsCacheHub`.
- Admin users are detected and treated as full-access (toggles disabled with a note).
**User creation & password resets**
- Improved `/api/addUser.php` responses:
- Returns proper HTTP status codes (e.g. **422** for validation failures).
- Normalized JSON shape to `{ ok: false, error: "…" }` for errors and `{ ok: true, data: … }` for success.
- Enforces **minimum 6-character passwords** for new users; invalid usernames and short passwords surface as clear error messages.
- Updated the admin “Add user” form in the hub to:
- Use `fetch` directly so 4xx responses (like 422) are correctly parsed.
- Show a toast (or `alert` fallback) on both success and failure, including backend validation messages.
- Added `UserModel::adminResetPassword()` to reset a users password from the admin hub without the old password, preserving TOTP/extra fields in the users file.
- Added new endpoint `public/api/admin/changeUserPassword.php`:
- Admin-only, with CSRF header check.
- Resets a users password via `adminResetPassword()` and returns consistent JSON.
**Shared links & upload limits**
- Reworked **shared upload size limit** UI:
- Removed the “Shared upload limits” block from the **Upload** section.
- Moved the **Shared Max Upload Size (bytes)** input into the **Shared links** section, above the links table.
- Renamed the section label to **“Manage Shared Links & Upload Size Limit”** and added a new `manage_shared_links_size` i18n key.
- Updated the Upload section label to **“Antivirus”** / “Antivirus upload scanning” since that section now focuses purely on AV configuration.
**Admin UI & Pro integrations**
- Updated the **Users** tab toolbar:
- Replaced old “Add user / Remove user / User permissions” buttons with:
- **Manage users** (opens the new hub).
- **Folder Access** (per-folder ACLs).
- **User Groups** (Pro).
- **Client Portals** (Pro).
- Wired **Client Portals** to open the new hub for user management:
- “Manage users…” button now triggers the global `adminOpenUserHub` instead of the old Add-User modal.
- “Folder access…” and “User groups…” buttons now link to `adminOpenFolderAccess` and the Pro groups modal respectively.
- Increased `#adminPanelModal` base width slightly (60% → 64%) for a bit more breathing room in the new layouts.
- Slight visual polish:
- Thicker `admin-divider` and OIDC debug snapshot border.
- **Admin subsection titles** bumped to 1.15rem.
- Toggle “ON” state uses `--filr-accent-400` for a slightly softer accent.
**Miscellaneous**
- Bumped the **upload card/modal z-index** so the upload UI always sits cleanly above other overlays.
- Added styling for `#adminUserHubModal .modal-content` in both light and dark mode so it matches existing admin panel modals.
---
## Changes 12/9/2025 (v2.5.1)
release(v2.5.1): upgrade vendor libs and enhance OIDC + admin UX

View File

@@ -0,0 +1,10 @@
<?php
// public/api/admin/changeUserPassword.php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
$controller = new UserController();
$controller->adminChangeUserPassword();

View File

@@ -6,6 +6,7 @@ img.logo{width:50px; height:50px; display:block;}
#userPanelModal .modal-content,
#adminPanelModal .modal-content,
#userPermissionsModal .modal-content,
#adminUserHubModal .modal-content,
#userFlagsModal .modal-content,
#userGroupsModal .modal-content,
#groupAclModal .modal-content,
@@ -2066,7 +2067,7 @@ body.dark-mode #deleteSelectedBtn,body.dark-mode #deleteAllBtn,body.dark-mode #d
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header,#clientPortalsModal .modal-content{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header,#clientPortalsModal .modal-content{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode .user-dropdown .user-menu,body.dark-mode #createMenu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu{background-clip:padding-box}
body:not(.dark-mode){background:var(--fr-bg-light)!important;color:#111!important;background-image:none!important}
body:not(.dark-mode) #fileListContainer,body:not(.dark-mode) #uploadCard,body:not(.dark-mode) #folderManagementCard,body:not(.dark-mode) .card,body:not(.dark-mode) .admin-panel-content{background:var(--fr-surface-light)!important;border-color:var(--fr-border-light)!important;box-shadow:0 3px 8px rgba(0,0,0,.04)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
@@ -2084,7 +2085,7 @@ body:not(.dark-mode) #deleteSelectedBtn,body:not(.dark-mode) #deleteAllBtn,body:
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile{background:#f1f1f1!important;border:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #customToast{background:#212121!important;color:#fff!important;border:1px solid #000!important;box-shadow:0 8px 18px rgba(0,0,0,.45)!important}
body:not(.dark-mode) #fileSummary{color:var(--fr-muted-light)!important}
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header,body:not(.dark-mode) #clientPortalsModal .modal-content{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header,body:not(.dark-mode) #clientPortalsModal .modal-content{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
#searchIcon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:36px;padding:0;border-radius:999px 0 0 999px;border:1px solid rgba(0,0,0,.18);border-right:none;background:#fff;cursor:pointer;box-shadow:none;transform:none}
#searchIcon .material-icons{font-size:20px;line-height:1;color:#555}
#searchIcon:hover{background:#f5f5f5}
@@ -2251,7 +2252,7 @@ body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
/* Modal sizing */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 60% !important;
width: 64% !important;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
@@ -2964,8 +2965,9 @@ body.dark-mode .oidc-debug-snapshot {
/* admin section separators */
.admin-divider {
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin: 10px 0;
border-top: 2px solid rgba(0, 0, 0, 0.08);
margin-top: 20px;
margin-bottom: 20px;
}
.dark-mode .admin-divider {
@@ -2975,7 +2977,7 @@ body.dark-mode .oidc-debug-snapshot {
/* OIDC debug JSON snapshot */
#oidcContent .oidc-debug-snapshot {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 4px;
}
@@ -3028,8 +3030,8 @@ body.dark-mode .oidc-debug-snapshot {
/* ON state */
#adminPanelModal .form-check.fr-toggle .fr-toggle-input:checked {
background-color: var(--filr-accent-500, #0d6efd);
border-color: var(--filr-accent-500, #0d6efd);
background-color: var(--filr-accent-400, #0d6efd);
border-color: var(--filr-accent-400, #0d6efd);
}
#adminPanelModal .form-check.fr-toggle .fr-toggle-input:checked::before {
@@ -3062,7 +3064,7 @@ body.dark-mode .oidc-debug-snapshot {
}
#adminPanelModal .admin-subsection-title {
font-weight: 600;
font-size: 0.95rem;
font-size: 1.15rem;
margin-top: 4px;
margin-bottom: 6px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -291,15 +291,15 @@ export async function openClientPortalsModal() {
<button type="button"
id="clientPortalsQuickAddUser"
class="btn btn-sm btn-outline-primary ms-1">
<i class="material-icons" style="font-size:16px; vertical-align:middle;">person_add</i>
<span style="margin-left:4px;">Add user…</span>
class="btn btn-sm btn-primary ms-1">
<i class="material-icons" style="font-size:16px; vertical-align:middle;">people</i>
<span style="margin-left:4px;">Manage users…</span>
</button>
<button
type="button"
id="clientPortalsOpenUserPerms"
class="btn btn-sm btn-outline-secondary ms-1">
class="btn btn-sm btn-secondary ms-1">
<i class="material-icons" style="font-size:16px; vertical-align:middle;">folder_shared</i>
<span style="margin-left:4px;">Folder access…</span>
</button>
@@ -307,7 +307,7 @@ export async function openClientPortalsModal() {
<button
type="button"
id="clientPortalsOpenUserGroups"
class="btn btn-sm btn-outline-secondary ms-1">
class="btn btn-sm btn-secondary ms-1">
<i class="material-icons" style="font-size:16px; vertical-align:middle;">groups</i>
<span style="margin-left:4px;">User groups…</span>
</button>
@@ -335,7 +335,7 @@ export async function openClientPortalsModal() {
if (quickAddUserBtn) {
quickAddUserBtn.onclick = () => {
// Reuse existing admin add-user button / modal
const globalBtn = document.getElementById('adminOpenAddUser');
const globalBtn = document.getElementById('adminOpenUserHub');
if (globalBtn) {
globalBtn.click();
} else {
@@ -346,7 +346,7 @@ export async function openClientPortalsModal() {
const openPermsBtn = document.getElementById('clientPortalsOpenUserPerms');
if (openPermsBtn) {
openPermsBtn.onclick = () => {
const btn = document.getElementById('adminOpenUserPermissions');
const btn = document.getElementById('adminOpenFolderAccess');
if (btn) {
btn.click();
} else {

View File

@@ -192,6 +192,7 @@ const translations = {
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
"manage_shared_links": "Manage Shared Links",
"manage_shared_links_size": "Manage Shared Links & Upload Size Limit",
"folder_shares": "Folder Shares",
"file_shares": "File Shares",
"loading": "Loading…",

View File

@@ -53,7 +53,7 @@ function showVirusScanNotice() {
transform: 'translate(-50%, -50%)',
maxWidth: '420px',
width: 'calc(100% - 32px)', // nice on mobile too
zIndex: '1080',
zIndex: '11080',
padding: '16px 18px',
borderRadius: '10px',
boxShadow: '0 4px 24px rgba(0,0,0,0.35)',

View File

@@ -144,15 +144,17 @@ class UserController
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Setup mode detection (first-run bootstrap)
$usersFile = USERS_DIR . USERS_FILE;
$isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1');
$setupMode = false;
if (
$isSetup && (!file_exists($usersFile)
|| filesize($usersFile) === 0
|| trim(@file_get_contents($usersFile)) === ''
$isSetup && (
!file_exists($usersFile) ||
filesize($usersFile) === 0 ||
trim(@file_get_contents($usersFile)) === ''
)
) {
$setupMode = true;
@@ -160,48 +162,110 @@ class UserController
// Not setup: enforce CSRF + admin auth
$h = self::headersLower();
$receivedToken = trim($h['x-csrf-token'] ?? '');
// Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX)
// Soft-fail CSRF: on mismatch, regenerate and return new token
if ($receivedToken !== $_SESSION['csrf_token']) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
'csrf_token' => $_SESSION['csrf_token'],
]);
exit;
}
self::requireAdmin();
}
$data = self::readJson();
$newUsername = trim($data['username'] ?? '');
$newPassword = trim($data['password'] ?? '');
$isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0');
if ($newUsername === '' || $newPassword === '') {
echo json_encode(["error" => "Username and password required"]);
exit;
}
if (!preg_match(REGEX_USER, $newUsername)) {
// Helper for validation errors (new + legacy shape)
$fail = function (string $message, int $status = 422) {
http_response_code($status);
echo json_encode([
"error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed."
'ok' => false,
'error' => $message,
// legacy-friendly fields:
'success' => false,
'message' => $message,
]);
exit;
};
if ($newUsername === '' || $newPassword === '') {
$fail('Username and password required');
}
// Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars
if (!preg_match(REGEX_USER, $newUsername)) {
$fail('Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed.');
}
// Enforce at least 6 chars (for new accounts)
if (strlen($newPassword) < 6) {
echo json_encode(["error" => "Password must be at least 6 characters."]);
exit;
$fail('Password must be at least 6 characters.');
}
$result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode);
echo json_encode($result);
// If the model returns an error, also go through the same fail() helper
if (isset($result['error']) && $result['error'] !== '') {
$fail($result['error']);
}
// Normalize success shape: new + legacy fields
$msg = $result['success'] ?? 'User added successfully';
http_response_code(200);
echo json_encode([
'ok' => true,
'data' => $result, // keeps your new structure
// legacy-friendly:
'success' => true,
'message' => $msg,
]);
exit;
}
public function adminChangeUserPassword()
{
self::jsonHeaders();
self::requireMethod(['POST']);
self::requireAdmin();
self::requireCsrf();
$data = self::readJson();
$username = trim((string)($data['username'] ?? ''));
$newPassword = (string)($data['newPassword'] ?? '');
if ($username === '' || $newPassword === '') {
http_response_code(400);
echo json_encode(['error' => 'Username and newPassword are required.']);
exit;
}
// Optional: enforce a minimum length, like addUser
if (strlen($newPassword) < 6) {
http_response_code(422);
echo json_encode(['error' => 'Password must be at least 6 characters.']);
exit;
}
$result = UserModel::adminResetPassword($username, $newPassword);
if (!empty($result['error'])) {
http_response_code(400);
} else {
http_response_code(200);
}
echo json_encode($result);
exit;
}
public function removeUser()
{
self::jsonHeaders();

View File

@@ -329,6 +329,54 @@ class userModel
return ["success" => "Password updated successfully."];
}
/**
* Admin-only password reset (no old password required).
*/
public static function adminResetPassword($targetUsername, $newPassword)
{
if (!preg_match(REGEX_USER, $targetUsername)) {
return ["error" => "Invalid username"];
}
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return ["error" => "Users file not found"];
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$userFound = false;
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
$storedUser = $parts[0];
if ($storedUser === $targetUsername) {
$userFound = true;
// index 1 is the hash; preserve TOTP/extra fields in the rest of the line
$parts[1] = password_hash($newPassword, PASSWORD_BCRYPT);
$newLines[] = implode(':', $parts);
} else {
$newLines[] = $line;
}
}
if (!$userFound) {
return ["error" => "User not found."];
}
$payload = implode(PHP_EOL, $newLines) . PHP_EOL;
if (file_put_contents($usersFile, $payload, LOCK_EX) === false) {
return ["error" => "Could not update password."];
}
return ["success" => "Password updated successfully."];
}
/**
* Update panel: if TOTP disabled, clear secret.
*/