GitGram — admin.php — GitGram
GitGram / main / v2.00 / admin.php40,361 B↓ Raw
<?php
// ─────────────────────────────────────────────────────────────────────────────
//  GitGram admin.php — front-end management panel
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/gitlib.php';

if (session_status() === PHP_SESSION_NONE) {
    session_name('gitgram');
    session_start();
}

// ── Auth guard ────────────────────────────────────────────────────────────────

function load_users(): array {
    return json_decode(@file_get_contents(DATA_PATH . '/users.json'), true) ?? [];
}
function save_users(array $u): void {
    file_put_contents(DATA_PATH . '/users.json', json_encode($u, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function load_settings(): array {
    $d = ['site_title' => SITE_TITLE, 'registration_open' => true, 'invite_only' => false];
    return array_merge($d, json_decode(@file_get_contents(DATA_PATH . '/settings.json'), true) ?? []);
}
function save_settings(array $s): void {
    file_put_contents(DATA_PATH . '/settings.json', json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function load_invites(): array {
    return json_decode(@file_get_contents(DATA_PATH . '/invites.json'), true) ?? [];
}
function save_invites(array $i): void {
    file_put_contents(DATA_PATH . '/invites.json', json_encode($i, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function load_repos_config(): array {
    return json_decode(@file_get_contents(DATA_PATH . '/repos.json'), true) ?? [];
}
function load_downloads(): array {
    return json_decode(@file_get_contents(DATA_PATH . '/downloads.json'), true) ?? [];
}
function save_repos_config(array $r): void {
    file_put_contents(DATA_PATH . '/repos.json', json_encode($r, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function repo_list_all(): array {
    $repos = [];
    foreach (glob(REPO_PATH . '/*.git', GLOB_ONLYDIR) as $dir) {
        $name        = basename($dir, '.git');
        $desc        = trim(@file_get_contents($dir . '/description') ?: '');
        if (str_starts_with($desc, 'Unnamed repository')) $desc = '';
        $repos[$name] = ['dir' => $dir, 'desc' => $desc];
    }
    return $repos;
}
function run_cmd_admin(string $cmd): array {
    if (function_exists('proc_open')) {
        $proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w']], $pipes);
        if (is_resource($proc)) {
            fclose($pipes[0]);
            $out = stream_get_contents($pipes[1]);
            fclose($pipes[1]);
            $rc  = proc_close($proc);
            return ['out' => trim($out), 'rc' => $rc];
        }
    }
    if (function_exists('exec')) {
        exec($cmd, $lines, $rc);
        return ['out' => trim(implode("\n", $lines)), 'rc' => $rc];
    }
    if (function_exists('shell_exec')) {
        return ['out' => trim(shell_exec($cmd) ?? ''), 'rc' => 0];
    }
    return ['out' => '', 'rc' => -1];
}

function git_run_admin(string $repo_dir, array $args): string {
    $cmd = array_map('escapeshellarg', array_merge([GIT_BIN, '--git-dir=' . $repo_dir], $args));
    $r   = run_cmd_admin(implode(' ', $cmd) . ' 2>/dev/null');
    return $r['out'];
}
function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function site_url(string $path = ''): string {
    if (SITE_URL !== '') return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
    $base   = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
    return $scheme . '://' . $host . $base . '/' . ltrim($path, '/');
}
function admin_url(string $tab = ''): string {
    return site_url('admin' . ($tab ? '?tab=' . $tab : ''));
}
function flash(string $type, string $msg): void {
    $_SESSION['flash'][] = [$type, $msg];
}
function get_flashes(): array {
    $f = $_SESSION['flash'] ?? [];
    unset($_SESSION['flash']);
    return $f;
}

// Require admin session — redirect to login if not authenticated
$cur_user = $_SESSION['gitgram_user'] ?? null;
if (!$cur_user) {
    header('Location: ' . site_url('login') . '?next=admin');
    exit;
}
$users = load_users();
if (($users[$cur_user]['role'] ?? '') !== 'admin') {
    http_response_code(403);
    echo '403 Forbidden — admin access required.';
    exit;
}

// ── Handle POST actions ───────────────────────────────────────────────────────

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action = $_POST['action'] ?? '';

    // ── Users ──
    if ($action === 'user_save') {
        $u        = load_users();
        $username = trim($_POST['username'] ?? '');
        $name     = trim($_POST['name']     ?? '');
        $email    = trim($_POST['email']    ?? '');
        $role     = in_array($_POST['role'] ?? '', ['admin','user']) ? $_POST['role'] : 'user';
        $groups   = array_values(array_filter(array_map('trim', explode(',', $_POST['groups'] ?? ''))));
        $password = $_POST['password'] ?? '';
        $is_new   = !isset($u[$username]);

        if (!preg_match('/^[a-z0-9_\-]{2,32}$/', $username)) {
            flash('err', 'Invalid username format.');
        } else {
            $entry = $u[$username] ?? [];
            $entry['name']   = $name;
            $entry['email']  = $email;
            $entry['role']   = $role;
            $entry['groups'] = $groups;
            if ($password !== '') {
                if (strlen($password) < 8) { flash('err', 'Password must be ≥8 characters.'); goto redirect; }
                $entry['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
            } elseif ($is_new) {
                flash('err', 'Password required for new users.');
                goto redirect;
            }
            $u[$username] = $entry;
            save_users($u);
            flash('ok', ($is_new ? 'User created: ' : 'User updated: ') . $username);
        }

    } elseif ($action === 'user_delete') {
        $u        = load_users();
        $username = trim($_POST['username'] ?? '');
        $admins   = array_filter($u, fn($x) => $x['role'] === 'admin');
        if ($username === $cur_user) {
            flash('err', 'Cannot delete your own account.');
        } elseif (count($admins) <= 1 && ($u[$username]['role'] ?? '') === 'admin') {
            flash('err', 'Cannot delete the last admin.');
        } elseif (!isset($u[$username])) {
            flash('err', 'User not found.');
        } else {
            unset($u[$username]);
            save_users($u);
            flash('ok', "User deleted: $username");
        }

    // ── Repos ──
    } elseif ($action === 'repo_create') {
        $name = trim($_POST['repo_name'] ?? '');
        $desc = trim($_POST['repo_desc'] ?? '');
        if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $name)) {
            flash('err', 'Invalid repo name. Use letters, numbers, hyphens, dots, underscores.');
        } else {
            $dir = REPO_PATH . '/' . $name . '.git';
            if (is_dir($dir)) {
                flash('err', "Repository '$name' already exists.");
            } else {
                // Use pure-PHP init when shell is unavailable
                $shell_ok = function_exists('proc_open') || function_exists('exec') || function_exists('shell_exec');
                if (!$shell_ok) {
                    $ok = gl_init_bare($dir);
                    $rc = $ok ? 0 : 1;
                    $err_msg = $ok ? '' : 'gl_init_bare() failed — check directory permissions.';
                } else {
                    $r  = run_cmd_admin(escapeshellarg(GIT_BIN) . ' init --bare ' . escapeshellarg($dir) . ' 2>&1');
                    $rc = $r['rc'];
                    $err_msg = $r['out'];
                }
                if ($rc !== 0) {
                    flash('err', 'git init failed: ' . $err_msg);
                } else {
                    if ($desc) file_put_contents($dir . '/description', $desc . "\n");
                    // Default: private (no access entry) — admin still has full access
                    flash('ok', "Repository created: $name");
                }
            }
        }

    } elseif ($action === 'repo_save') {
        $name    = trim($_POST['repo_name'] ?? '');
        $desc    = trim($_POST['repo_desc'] ?? '');
        $dir     = REPO_PATH . '/' . $name . '.git';
        if (is_dir($dir)) {
            file_put_contents($dir . '/description', $desc . "\n");
            flash('ok', "Description updated: $name");
        } else {
            flash('err', "Repository not found: $name");
        }

    } elseif ($action === 'repo_access_save') {
        $name  = trim($_POST['repo_name'] ?? '');
        $raw   = trim($_POST['access_json'] ?? '');
        $rc    = load_repos_config();
        $parsed = json_decode($raw, true);
        if ($parsed === null && $raw !== 'null' && $raw !== '') {
            flash('err', 'Invalid JSON in access rules.');
        } else {
            if ($parsed === null || $parsed === []) {
                unset($rc[$name]);
            } else {
                $rc[$name]['access'] = $parsed;
                $rc[$name]['description'] = trim($_POST['repo_desc'] ?? $rc[$name]['description'] ?? '');
            }
            save_repos_config($rc);
            flash('ok', "Access rules saved: $name");
        }

    } elseif ($action === 'repo_delete') {
        $name  = trim($_POST['repo_name'] ?? '');
        $confirm = trim($_POST['confirm_name'] ?? '');
        if ($name !== $confirm) {
            flash('err', 'Confirmation name did not match.');
        } else {
            $dir = REPO_PATH . '/' . $name . '.git';
            if (!is_dir($dir)) {
                flash('err', "Repository not found: $name");
            } else {
                // Recursive delete
                $it = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
                    RecursiveIteratorIterator::CHILD_FIRST
                );
                foreach ($it as $f) {
                    $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
                }
                rmdir($dir);
                // Remove from repos.json
                $rc = load_repos_config();
                unset($rc[$name]);
                save_repos_config($rc);
                flash('ok', "Repository deleted: $name");
            }
        }

    // ── Invites ──
    } elseif ($action === 'invite_create') {
        $n    = max(1, min(20, (int)($_POST['count'] ?? 1)));
        $exp  = trim($_POST['expires'] ?? '');
        $inv  = load_invites();
        for ($i = 0; $i < $n; $i++) {
            $code = strtoupper(bin2hex(random_bytes(4)));
            $inv[$code] = [
                'created_by' => $cur_user,
                'created_at' => date('Y-m-d H:i:s'),
                'expires'    => $exp ?: null,
                'used_by'    => null,
                'used_at'    => null,
            ];
        }
        save_invites($inv);
        flash('ok', "Generated $n invite code(s).");

    } elseif ($action === 'invite_revoke') {
        $code = trim($_POST['code'] ?? '');
        $inv  = load_invites();
        if (isset($inv[$code]) && $inv[$code]['used_by'] === null) {
            unset($inv[$code]);
            save_invites($inv);
            flash('ok', "Invite $code revoked.");
        } else {
            flash('err', 'Invite not found or already used.');
        }

    // ── Settings ──
    } elseif ($action === 'settings_save') {
        $s = load_settings();
        $s['site_title']        = trim($_POST['site_title'] ?? '') ?: SITE_TITLE;
        $s['registration_open'] = !empty($_POST['registration_open']);
        $s['invite_only']       = !empty($_POST['invite_only']);
        save_settings($s);
        flash('ok', 'Settings saved.');
    }

    redirect:
    $tab = $_POST['tab'] ?? 'users';
    header('Location: ' . admin_url($tab));
    exit;
}

// ── Render ────────────────────────────────────────────────────────────────────

$tab      = $_GET['tab'] ?? 'users';
$settings = load_settings();
$all_users  = load_users();
$all_repos  = repo_list_all();
$repos_cfg  = load_repos_config();
$invites    = load_invites();
$flashes    = get_flashes();

$site_title = h($settings['site_title'] ?? SITE_TITLE);
$end_url    = site_url('end-of-internet');
$home_url   = site_url();
$readme_url = site_url('readme');

?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin — <?= $site_title ?></title>
<style>
:root {
  --desktop: #008080; --win-bg: #c0c0c0; --title-bg: #000080; --title-txt: #fff;
  --hi: #fff; --sh: #808080; --dk: #404040;
  --accent: #000080;
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;}
body{background:var(--desktop);font-family:"Arial","Helvetica",sans-serif;font-size:13px;padding:10px;min-height:100vh;}
a{color:var(--accent);text-decoration:underline;}
a:hover{color:#0000cc;}

/* Window */
.window{max-width:1060px;margin:0 auto;background:var(--win-bg);
  border-top:2px solid var(--hi);border-left:2px solid var(--hi);
  border-right:2px solid var(--dk);border-bottom:2px solid var(--dk);
  display:flex;flex-direction:column;min-height:calc(100vh - 20px);}

/* Titlebar */
.titlebar{background:var(--title-bg);color:var(--title-txt);display:flex;
  align-items:center;height:22px;padding:0 3px;gap:2px;user-select:none;}
.tb-sysmenu{width:18px;height:14px;background:var(--win-bg);color:#000;
  border-top:1px solid var(--hi);border-left:1px solid var(--hi);
  border-right:1px solid var(--dk);border-bottom:1px solid var(--dk);
  display:flex;align-items:center;justify-content:center;font-size:9px;}
.tb-title{flex:1;text-align:center;font-size:12px;font-weight:bold;}
.tb-btn{width:16px;height:14px;background:var(--win-bg);color:#000;
  border-top:1px solid var(--hi);border-left:1px solid var(--hi);
  border-right:1px solid var(--dk);border-bottom:1px solid var(--dk);
  display:flex;align-items:center;justify-content:center;font-size:10px;
  font-weight:bold;cursor:pointer;text-decoration:none;}
.tb-btn:hover{background:#d4d4d4;}

/* Menubar */
.menubar{background:var(--win-bg);border-bottom:1px solid var(--sh);
  padding:2px 8px;display:flex;gap:16px;align-items:center;}
.menubar a{color:#000;text-decoration:none;font-size:12px;padding:1px 4px;}
.menubar a:hover{background:var(--accent);color:#fff;}
.menubar .right{margin-left:auto;display:flex;gap:14px;align-items:center;}
.menubar .right .who{font-size:11px;color:#555;}

/* Win body */
.win-body{flex:1;border-top:1px solid var(--sh);border-left:1px solid var(--sh);
  border-right:1px solid var(--hi);border-bottom:1px solid var(--hi);
  margin:4px;background:#fff;display:flex;flex-direction:column;overflow:hidden;}

/* Sidebar + main */
.layout{display:flex;flex:1;overflow:hidden;}
.sidebar{width:150px;background:#e8e8e8;border-right:1px solid #ccc;
  flex-shrink:0;padding:8px 0;}
.sidebar a{display:block;padding:5px 14px;font-size:12px;color:#000;text-decoration:none;
  border-left:3px solid transparent;}
.sidebar a:hover{background:#d8d8f8;}
.sidebar a.active{background:#fff;border-left:3px solid var(--accent);font-weight:bold;}
.sidebar .nav-section{padding:10px 14px 3px;font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.05em;}
.main{flex:1;overflow:auto;padding:16px;}

/* Flashes */
.flash{padding:7px 12px;margin-bottom:10px;font-size:12px;border-left:4px solid;}
.flash.ok  {background:#f0fff0;border-color:#008000;color:#040;}
.flash.err {background:#fff0f0;border-color:#cc0000;color:#800;}
.flash.warn{background:#fffbe6;border-color:#c0a000;color:#640;}

/* Section headings */
.section-hd{font-weight:bold;font-size:13px;border-bottom:1px solid #ccc;
  padding-bottom:4px;margin-bottom:12px;display:flex;align-items:center;gap:10px;}

/* Tables */
table{border-collapse:collapse;width:100%;}
th{text-align:left;padding:4px 8px;background:#e8e8e8;border-bottom:2px solid #bbb;font-size:12px;}
td{padding:4px 8px;border-bottom:1px solid #eee;vertical-align:middle;font-size:12px;}
tr:hover td{background:#f0f0ff;}
.mono{font-family:monospace;font-size:11px;}

/* Badges */
.badge{display:inline-block;font-size:10px;padding:1px 6px;border-radius:2px;font-weight:bold;}
.badge-admin{background:#000080;color:#fff;}
.badge-user {background:#666;color:#fff;}
.badge-used {background:#888;color:#fff;}
.badge-open {background:#008000;color:#fff;}
.badge-exp  {background:#cc0000;color:#fff;}

/* Forms */
label{display:block;font-weight:bold;font-size:12px;margin-top:8px;margin-bottom:3px;}
label small{font-weight:normal;color:#666;}
input[type=text],input[type=password],input[type=email],
input[type=date],input[type=number],select,textarea{
  width:100%;padding:4px 6px;font-size:13px;font-family:inherit;
  border-top:2px solid var(--sh);border-left:2px solid var(--sh);
  border-right:2px solid var(--hi);border-bottom:2px solid var(--hi);
  background:#fff;}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);}
textarea{font-family:monospace;font-size:12px;resize:vertical;}
.form-row{display:flex;gap:12px;}
.form-row>*{flex:1;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px;}

/* Buttons */
.btn{padding:4px 16px;font-size:12px;font-family:inherit;cursor:pointer;
  background:var(--win-bg);
  border-top:2px solid var(--hi);border-left:2px solid var(--hi);
  border-right:2px solid var(--dk);border-bottom:2px solid var(--dk);}
.btn:active{border-top:2px solid var(--dk);border-left:2px solid var(--dk);
  border-right:2px solid var(--hi);border-bottom:2px solid var(--hi);}
.btn.primary{background:#ccccff;font-weight:bold;}
.btn.danger {background:#ffcccc;}
.btn.sm{padding:2px 8px;font-size:11px;}

/* Cards */
.card{border:1px solid #ccc;padding:12px 14px;margin-bottom:14px;background:#fafafa;}
.card-title{font-weight:bold;font-size:12px;margin-bottom:8px;color:#000080;}

/* Access rules editor */
.access-help{font-size:11px;color:#555;margin-bottom:6px;line-height:1.6;}
.perm-chip{display:inline-block;background:#e0e8ff;border:1px solid #99b;
  padding:1px 6px;font-size:11px;font-family:monospace;margin:1px;}

/* Invite table */
.code-cell{font-family:monospace;font-size:13px;font-weight:bold;letter-spacing:.05em;}

/* Status bar */
.statusbar{background:var(--win-bg);border-top:1px solid var(--sh);
  padding:2px 8px;font-size:11px;color:#555;display:flex;gap:4px;flex-shrink:0;}
.sc{border-top:1px inset var(--sh);padding:1px 8px;}

/* Repo delete confirm */
.danger-zone{border:2px solid #cc0000;padding:12px;margin-top:16px;background:#fff8f8;}
.danger-zone .dz-title{font-weight:bold;color:#cc0000;margin-bottom:8px;}

/* Responsive collapse */
@media(max-width:600px){
  .sidebar{width:100%;border-right:none;border-bottom:1px solid #ccc;}
  .layout{flex-direction:column;}
  .form-row,.form-grid{flex-direction:column;display:block;}
}
</style>
</head>
<body id="body">
<div class="window" id="window">

<div class="titlebar">
  <div class="tb-sysmenu">▪</div>
  <div class="tb-title"><?= $site_title ?> — Admin Panel</div>
  <div class="tb-btn" id="btn-min" title="Minimize">▼</div>
  <div class="tb-btn" id="btn-max" title="Maximize">▲</div>
  <a   class="tb-btn" href="<?= h($end_url) ?>" title="Close">✕</a>
</div>

<div class="menubar">
  <a href="<?= h($home_url) ?>">← Site</a>
  <a href="<?= h($readme_url) ?>">Help</a>
  <div class="right">
    <span class="who"><?= h($cur_user) ?> [admin]</span>
    <a href="<?= h(site_url('logout')) ?>">Logout</a>
  </div>
</div>

<div class="win-body" id="win-body">
<div class="layout">

<!-- Sidebar nav -->
<div class="sidebar">
  <div class="nav-section">Manage</div>
  <a href="?tab=users"   class="<?= $tab==='users'   ? 'active' : '' ?>">Users</a>
  <a href="?tab=repos"      class="<?= $tab==='repos'      ? 'active' : '' ?>">Repositories</a>
  <a href="?tab=downloads"  class="<?= $tab==='downloads'  ? 'active' : '' ?>">Downloads</a>
  <a href="?tab=invites"    class="<?= $tab==='invites'    ? 'active' : '' ?>">Invites</a>
  <div class="nav-section">System</div>
  <a href="?tab=settings" class="<?= $tab==='settings' ? 'active' : '' ?>">Settings</a>
</div>

<!-- Main content -->
<div class="main">

<?php foreach ($flashes as [$type, $msg]): ?>
  <div class="flash <?= h($type) ?>"><?= $msg ?></div>
<?php endforeach; ?>

<?php /* ════════════════════════════════════════════════════════ USERS TAB */ ?>
<?php if ($tab === 'users'): ?>

<div class="section-hd">
  Users
  <button class="btn sm primary" onclick="togglePanel('panel-add-user')">+ Add User</button>
</div>

<!-- Add/Edit User form -->
<div class="card" id="panel-add-user" style="display:none">
  <div class="card-title">Add / Edit User</div>
  <form method="post">
    <input type="hidden" name="action" value="user_save">
    <input type="hidden" name="tab"    value="users">
    <div class="form-grid">
      <div>
        <label>Username <small>(a–z, 0–9, - _ , 2–32 chars)</small></label>
        <input type="text" name="username" autocomplete="off" pattern="[a-z0-9_\-]{2,32}" required
               list="existing-users">
        <datalist id="existing-users">
          <?php foreach ($all_users as $un => $_): ?>
            <option value="<?= h($un) ?>">
          <?php endforeach; ?>
        </datalist>
      </div>
      <div>
        <label>Display Name</label>
        <input type="text" name="name" required>
      </div>
      <div>
        <label>Email <small>(optional)</small></label>
        <input type="email" name="email">
      </div>
      <div>
        <label>Role</label>
        <select name="role">
          <option value="user">user</option>
          <option value="admin">admin</option>
        </select>
      </div>
      <div>
        <label>Password <small>(leave blank to keep existing)</small></label>
        <input type="password" name="password" autocomplete="new-password">
      </div>
      <div>
        <label>Groups <small>(comma-separated)</small></label>
        <input type="text" name="groups" placeholder="devs, leads">
      </div>
    </div>
    <button type="submit" class="btn primary" style="margin-top:10px">Save User</button>
  </form>
</div>

<!-- Users table -->
<table>
  <tr><th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Groups</th><th></th></tr>
  <?php foreach ($all_users as $un => $u): ?>
  <tr>
    <td class="mono"><strong><?= h($un) ?></strong></td>
    <td><?= h($u['name'] ?? '') ?></td>
    <td class="mono" style="font-size:11px;color:#555"><?= h($u['email'] ?? '') ?></td>
    <td><span class="badge badge-<?= h($u['role'] ?? 'user') ?>"><?= h($u['role'] ?? 'user') ?></span></td>
    <td style="font-size:11px;color:#666"><?= h(implode(', ', $u['groups'] ?? [])) ?></td>
    <td style="white-space:nowrap">
      <button class="btn sm" onclick="fillEditUser(<?= htmlspecialchars(json_encode([
        'username' => $un, 'name' => $u['name'] ?? '', 'email' => $u['email'] ?? '',
        'role' => $u['role'] ?? 'user', 'groups' => implode(', ', $u['groups'] ?? [])
      ])) ?>)">Edit</button>
      <?php if ($un !== $cur_user): ?>
      <form method="post" style="display:inline" onsubmit="return confirm('Delete <?= h($un) ?>?')">
        <input type="hidden" name="action"   value="user_delete">
        <input type="hidden" name="tab"      value="users">
        <input type="hidden" name="username" value="<?= h($un) ?>">
        <button class="btn sm danger">Del</button>
      </form>
      <?php endif; ?>
    </td>
  </tr>
  <?php endforeach; ?>
</table>

<?php /* ════════════════════════════════════════════════════════ REPOS TAB */ ?>
<?php elseif ($tab === 'repos'): ?>

<div class="section-hd">
  Repositories
  <button class="btn sm primary" onclick="togglePanel('panel-new-repo')">+ New Repo</button>
</div>

<!-- Create repo -->
<div class="card" id="panel-new-repo" style="display:none">
  <div class="card-title">Create Repository</div>
  <form method="post">
    <input type="hidden" name="action" value="repo_create">
    <input type="hidden" name="tab"    value="repos">
    <div class="form-row">
      <div>
        <label>Repository name</label>
        <input type="text" name="repo_name" pattern="[a-zA-Z0-9_\-\.]{1,64}"
               placeholder="my-project" required autocomplete="off">
      </div>
      <div>
        <label>Description <small>(optional)</small></label>
        <input type="text" name="repo_desc" placeholder="Short description">
      </div>
    </div>
    <button type="submit" class="btn primary" style="margin-top:10px">Create</button>
    <small style="margin-left:10px;color:#666">Initialises a bare repo in <code>repos/</code></small>
  </form>
</div>

<!-- Repos table -->
<?php if (empty($all_repos)): ?>
  <p style="color:#888">No repositories yet.</p>
<?php else: ?>
<table>
  <tr><th>Name</th><th>Description</th><th>Access rules</th><th></th></tr>
  <?php foreach ($all_repos as $rname => $r):
    $access = $repos_cfg[$rname]['access'] ?? null;
    $last   = git_run_admin($r['dir'], ['log','-1','--format=%ar']);
  ?>
  <tr>
    <td class="mono"><a href="<?= h(site_url($rname)) ?>"><?= h($rname) ?></a></td>
    <td style="font-size:12px;color:#555"><?= h($r['desc']) ?></td>
    <td>
      <?php if ($access): ?>
        <?php foreach ($access as $subj => $perm): ?>
          <span class="perm-chip"><?= h($subj) ?>:<?= h($perm) ?></span>
        <?php endforeach; ?>
      <?php else: ?>
        <span style="color:#999;font-size:11px">private (admin only)</span>
      <?php endif; ?>
    </td>
    <td style="white-space:nowrap">
      <button class="btn sm" onclick="openRepoEdit(<?= htmlspecialchars(json_encode([
        'name'   => $rname,
        'desc'   => $r['desc'],
        'access' => $access ? json_encode($access, JSON_PRETTY_PRINT) : "{\n    \"@all\": \"R\"\n}",
      ])) ?>)">Edit</button>
    </td>
  </tr>
  <?php endforeach; ?>
</table>
<?php endif; ?>

<!-- Edit repo panel -->
<div class="card" id="panel-repo-edit" style="display:none;margin-top:16px">
  <div class="card-title" id="repo-edit-title">Edit Repository</div>
  <form method="post" id="form-repo-access">
    <input type="hidden" name="action"    value="repo_access_save">
    <input type="hidden" name="tab"       value="repos">
    <input type="hidden" name="repo_name" id="edit-repo-name">
    <label>Description</label>
    <input type="text" name="repo_desc" id="edit-repo-desc">
    <label>Access Rules <small>(JSON — see <a href="<?= h(site_url('readme')) ?>" target="_blank">docs</a>)</small></label>
    <div class="access-help">
      Keys: <span class="perm-chip">@all</span>
            <span class="perm-chip">@groupname</span>
            <span class="perm-chip">username</span>
      &nbsp; Values: <span class="perm-chip">R</span>
                     <span class="perm-chip">RW</span>
                     <span class="perm-chip">RW+</span>
                     <span class="perm-chip">-</span>
    </div>
    <textarea name="access_json" id="edit-repo-access" rows="7"></textarea>
    <div style="margin-top:10px;display:flex;gap:10px">
      <button type="submit" class="btn primary">Save Access Rules</button>
      <button type="button" class="btn" onclick="document.getElementById('panel-repo-edit').style.display='none'">Cancel</button>
    </div>
  </form>

  <!-- Description-only save -->
  <form method="post" style="display:none" id="form-repo-desc">
    <input type="hidden" name="action"    value="repo_save">
    <input type="hidden" name="tab"       value="repos">
    <input type="hidden" name="repo_name" id="save-repo-name">
    <input type="hidden" name="repo_desc" id="save-repo-desc">
  </form>

  <div class="danger-zone">
    <div class="dz-title">⚠ Delete Repository</div>
    <p style="font-size:12px;margin-bottom:8px">This permanently destroys all commits and data. Type the repo name to confirm.</p>
    <form method="post" onsubmit="return confirm('PERMANENTLY delete this repo?')">
      <input type="hidden" name="action"    value="repo_delete">
      <input type="hidden" name="tab"       value="repos">
      <input type="hidden" name="repo_name" id="del-repo-name">
      <div class="form-row" style="align-items:flex-end">
        <div>
          <label>Type repo name to confirm</label>
          <input type="text" name="confirm_name" autocomplete="off">
        </div>
        <div style="flex:0">
          <button type="submit" class="btn danger" style="white-space:nowrap">Delete Repo</button>
        </div>
      </div>
    </form>
  </div>
</div>

<?php /* ═══════════════════════════════════════════════════════ INVITES TAB */ ?>
<?php elseif ($tab === 'invites'): ?>

<div class="section-hd">Invite Codes</div>

<?php $s = $settings; ?>
<?php if (!$s['registration_open']): ?>
  <div class="flash warn">Registration is currently closed. Invite codes will not work until you re-enable registration in Settings.</div>
<?php elseif (!$s['invite_only']): ?>
  <div class="flash warn">Invite-only mode is off. Anyone can register without a code. Enable it in Settings.</div>
<?php endif; ?>

<!-- Generate invites -->
<div class="card">
  <div class="card-title">Generate Invite Codes</div>
  <form method="post">
    <input type="hidden" name="action" value="invite_create">
    <input type="hidden" name="tab"    value="invites">
    <div class="form-row">
      <div>
        <label>How many codes</label>
        <input type="number" name="count" value="1" min="1" max="20" style="max-width:80px">
      </div>
      <div>
        <label>Expires <small>(optional — leave blank for no expiry)</small></label>
        <input type="date" name="expires">
      </div>
    </div>
    <button type="submit" class="btn primary" style="margin-top:10px">Generate</button>
  </form>
</div>

<!-- Unused codes -->
<?php
  $unused = array_filter($invites, fn($i) => $i['used_by'] === null);
  $used   = array_filter($invites, fn($i) => $i['used_by'] !== null);
?>
<div class="section-hd" style="margin-top:4px">Unused Codes (<?= count($unused) ?>)</div>
<?php if ($unused): ?>
<table>
  <tr><th>Code</th><th>Created by</th><th>Created</th><th>Expires</th><th></th></tr>
  <?php foreach ($unused as $code => $inv):
    $expired = $inv['expires'] && $inv['expires'] < date('Y-m-d');
  ?>
  <tr>
    <td class="code-cell"><?= h($code) ?></td>
    <td><?= h($inv['created_by']) ?></td>
    <td style="font-size:11px;color:#555"><?= h($inv['created_at']) ?></td>
    <td>
      <?php if ($inv['expires']): ?>
        <span class="badge <?= $expired ? 'badge-exp' : 'badge-open' ?>"><?= h($inv['expires']) ?></span>
      <?php else: echo '<span style="color:#888;font-size:11px">never</span>'; ?>
      <?php endif; ?>
    </td>
    <td>
      <form method="post" style="display:inline" onsubmit="return confirm('Revoke <?= h($code) ?>?')">
        <input type="hidden" name="action" value="invite_revoke">
        <input type="hidden" name="tab"    value="invites">
        <input type="hidden" name="code"   value="<?= h($code) ?>">
        <button class="btn sm danger">Revoke</button>
      </form>
    </td>
  </tr>
  <?php endforeach; ?>
</table>
<?php else: ?>
  <p style="color:#888;font-size:12px">No unused codes.</p>
<?php endif; ?>

<div class="section-hd" style="margin-top:16px">Used Codes (<?= count($used) ?>)</div>
<?php if ($used): ?>
<table>
  <tr><th>Code</th><th>Used by</th><th>Used at</th></tr>
  <?php foreach ($used as $code => $inv): ?>
  <tr>
    <td class="code-cell" style="color:#999"><?= h($code) ?></td>
    <td><?= h($inv['used_by']) ?></td>
    <td style="font-size:11px;color:#555"><?= h($inv['used_at'] ?? '') ?></td>
  </tr>
  <?php endforeach; ?>
</table>
<?php else: ?>
  <p style="color:#888;font-size:12px">No used codes yet.</p>
<?php endif; ?>

<?php /* ════════════════════════════════════════════════════ SETTINGS TAB */ ?>
<?php elseif ($tab === 'settings'): ?>

<div class="section-hd">Site Settings</div>

<form method="post" style="max-width:520px">
  <input type="hidden" name="action" value="settings_save">
  <input type="hidden" name="tab"    value="settings">

  <div class="card">
    <div class="card-title">General</div>
    <label>Site Title</label>
    <input type="text" name="site_title" value="<?= h($settings['site_title'] ?? SITE_TITLE) ?>">
  </div>

  <div class="card">
    <div class="card-title">Registration</div>
    <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal;margin-top:4px">
      <input type="checkbox" name="registration_open" value="1" <?= !empty($settings['registration_open']) ? 'checked' : '' ?>>
      <span><strong>Registration open</strong> — allow new accounts to be created via <code>/register</code></span>
    </label>
    <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal;margin-top:10px">
      <input type="checkbox" name="invite_only" value="1" <?= !empty($settings['invite_only']) ? 'checked' : '' ?>>
      <span><strong>Invite-only</strong> — require a valid invite code to register</span>
    </label>
    <p style="font-size:11px;color:#666;margin-top:8px">
      Invite codes are managed in the Invites tab.
    </p>
  </div>

  <button type="submit" class="btn primary">Save Settings</button>
</form>

<?php /* ════════════════════════════════════════════════ DOWNLOADS TAB */ ?>
<?php elseif ($tab === 'downloads'): ?>

<div class="section-hd">Downloads</div>

<?php
  $dl   = load_downloads();
  $rows = [];
  foreach ($all_repos as $rname => $r) {
      $zip   = (int)($dl[$rname]['zip'] ?? 0);
      $git   = (int)($dl[$rname]['git'] ?? 0);
      $rows[] = ['name' => $rname, 'zip' => $zip, 'git' => $git, 'total' => $zip + $git];
  }
?>
<?php if (empty($rows)): ?>
  <p style="color:#888">No repositories yet.</p>
<?php else: ?>
<p style="font-size:11px;color:#666;margin-bottom:10px">
  <strong>ZIP</strong> = browser archive downloads &nbsp;|&nbsp;
  <strong>Git</strong> = git clone / fetch operations.
  Click a column heading to sort.
</p>
<table id="dl-table">
  <tr>
    <th class="dl-sortable" data-col="0" style="cursor:pointer;user-select:none">Repository <span class="sort-ind"></span></th>
    <th class="dl-sortable" data-col="1" style="cursor:pointer;user-select:none">ZIP <span class="sort-ind"></span></th>
    <th class="dl-sortable" data-col="2" style="cursor:pointer;user-select:none">Git <span class="sort-ind"></span></th>
    <th class="dl-sortable" data-col="3" style="cursor:pointer;user-select:none">Total <span class="sort-ind"></span></th>
  </tr>
  <?php foreach ($rows as $row): ?>
  <tr>
    <td class="mono"><a href="<?= h(site_url($row['name'])) ?>"><?= h($row['name']) ?></a></td>
    <td><?= $row['zip'] ?></td>
    <td><?= $row['git'] ?></td>
    <td><?= $row['total'] ?></td>
  </tr>
  <?php endforeach; ?>
</table>
<?php endif; ?>

<?php endif; ?>

</div><!-- main -->
</div><!-- layout -->
</div><!-- win-body -->

<div class="statusbar">
  <div class="sc" id="status-text">Admin Panel</div>
  <div class="sc"><?= h($cur_user) ?></div>
  <div class="sc"><?= h($settings['site_title'] ?? SITE_TITLE) ?></div>
</div>

</div><!-- window -->

<script>
(function(){
  var body    = document.getElementById('body');
  var winBody = document.getElementById('win-body');
  var status  = document.getElementById('status-text');
  var minimized = false, maximized = false;

  document.getElementById('btn-min').addEventListener('click', function(){
    minimized = !minimized;
    winBody.style.display = minimized ? 'none' : '';
    status.textContent = minimized ? 'Minimized' : 'Admin Panel';
  });
  document.getElementById('btn-max').addEventListener('click', function(){
    maximized = !maximized;
    if (maximized) {
      body.style.padding = '0';
      document.querySelector('.window').style.cssText = 'max-width:100%;min-height:100vh;border:none;';
    } else {
      body.style.padding = '10px';
      document.querySelector('.window').style.cssText = '';
    }
    if (minimized){ minimized=false; winBody.style.display=''; }
    status.textContent = maximized ? 'Maximized' : 'Admin Panel';
  });

  document.querySelectorAll('a[href]').forEach(function(a){
    a.addEventListener('mouseenter',function(){ status.textContent = a.href; });
    a.addEventListener('mouseleave',function(){ status.textContent = 'Admin Panel'; });
  });
})();

function togglePanel(id){
  var p = document.getElementById(id);
  p.style.display = p.style.display === 'none' ? 'block' : 'none';
}

function fillEditUser(data){
  document.getElementById('panel-add-user').style.display = 'block';
  var f = document.getElementById('panel-add-user').querySelector('form');
  f.querySelector('[name=username]').value = data.username;
  f.querySelector('[name=name]').value     = data.name;
  f.querySelector('[name=email]').value    = data.email;
  f.querySelector('[name=role]').value     = data.role;
  f.querySelector('[name=groups]').value   = data.groups;
  f.querySelector('[name=password]').value = '';
  f.querySelector('[name=username]').scrollIntoView({behavior:'smooth',block:'center'});
}

// ── Downloads table sorting ───────────────────────────────────────────────────
(function(){
  var tbl = document.getElementById('dl-table');
  if (!tbl) return;
  var sortCol = -1, sortAsc = true;
  tbl.querySelectorAll('th.dl-sortable').forEach(function(th){
    th.addEventListener('click', function(){
      var col = parseInt(th.getAttribute('data-col'));
      if (sortCol === col) { sortAsc = !sortAsc; }
      else { sortCol = col; sortAsc = true; }
      var rows = Array.from(tbl.rows).slice(1);
      rows.sort(function(a, b){
        var av = a.cells[col].textContent.trim();
        var bv = b.cells[col].textContent.trim();
        var an = parseFloat(av), bn = parseFloat(bv);
        if (!isNaN(an) && !isNaN(bn)) { return sortAsc ? an - bn : bn - an; }
        return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
      });
      rows.forEach(function(r){ tbl.appendChild(r); });
      tbl.querySelectorAll('th.dl-sortable .sort-ind').forEach(function(s){ s.textContent = ''; });
      th.querySelector('.sort-ind').textContent = sortAsc ? ' ▲' : ' ▼';
    });
  });
})();

function openRepoEdit(data){
  var p = document.getElementById('panel-repo-edit');
  p.style.display = 'block';
  document.getElementById('repo-edit-title').textContent = 'Edit: ' + data.name;
  document.getElementById('edit-repo-name').value  = data.name;
  document.getElementById('edit-repo-desc').value  = data.desc;
  document.getElementById('edit-repo-access').value = data.access;
  document.getElementById('del-repo-name').value    = data.name;
  document.getElementById('save-repo-name').value   = data.name;
  p.scrollIntoView({behavior:'smooth', block:'start'});
}
</script>
</body>
</html>
Ready
GitGram