foxygit / foxygit Log in
A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commit 4bbb1e0df2198946e1d4134a6f0510a70744ea4e
Author:     mrfox <jens.kristoffersson.se@gmail.com>
AuthorDate: Sat Aug 22 12:14:04 2026 +0200
Commit:     mrfox <jens.kristoffersson.se@gmail.com>
CommitDate: Sat Aug 22 12:14:04 2026 +0200

    Add accounts, API keys, and a web admin panel

    Until now the only way to create or delete a repo was over SSH via
    server/git-shell-commands, and HTTPS was read-only. This adds a login
    layer on top, without taking on a database: accounts and API-key hashes
    live in one JSON file (AUTH_STORE), read and written under an flock.

      - inc/auth.php: accounts, sessions, CSRF, and API keys. Split so the
        data half has no web-only dependencies and can be called from CLI --
        server/verify-api-key.php reuses verify_api_key() directly, so the
        HTTPS-push check and the web app share one definition of a valid key.
      - ?a=setup creates the first (admin) account, then disappears. Sessions
        are only started for the auth routes or for visitors who already have
        one, so plain repo browsing still sets no cookie beyond the theme one.
      - ?a=account: create and revoke your own API keys. ?a=admin: create and
        delete repos (retyping the name to confirm, as the SSH delete command
        already required) and manage accounts.
      - server/setup-http-push.sh installs git-http-backend-auth, a CGI
        wrapper that leaves clone/fetch anonymous but requires a valid API key
        as the Basic-auth password on push. Repos are created with
        http.receivepack=true, over SSH and from the admin panel alike.

    Bcrypt cost is pinned rather than left to PASSWORD_DEFAULT: PHP 8.4 raised
    the default from 10 to 12, and the dummy hash that equalises timing for
    unknown usernames has to match the real one's work factor or it verifies
    several times faster and leaks exactly what it exists to hide.

    Static asset URLs now carry the file mtime as ?v=, since this site sits
    behind a CDN that caches CSS -- without it an edited stylesheet appears
    not to deploy at all.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 README.md                        |  50 ++++++--
 assets/base.css                  | 242 +++++++++++++++++++++++++++++++++++
 inc/auth.php                     | 269 +++++++++++++++++++++++++++++++++++++++
 inc/config.example.php           |   1 +
 inc/functions.php                |  53 +++++++-
 index.php                        | 163 +++++++++++++++++++++++-
 server/git-http-backend-auth     |  51 ++++++++
 server/git-shell-commands/create |   1 +
 server/setup-http-push.sh        | 126 ++++++++++++++++++
 server/verify-api-key.php        |  25 ++++
 views/account.php                |  69 ++++++++++
 views/admin.php                  | 118 +++++++++++++++++
 views/login.php                  |  31 +++++
 views/partials/foot.php          |   2 +-
 views/partials/head.php          |  28 +++-
 views/setup.php                  |  37 ++++++
 16 files changed, 1247 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md
index 6d139ef..6bae32e 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
 # foxygit

-A tiny read-only git web frontend, in the spirit of [stagit](https://codemadness.org/stagit.html)
+A mostly-read-only git web frontend, in the spirit of [stagit](https://codemadness.org/stagit.html)
 and [cgit](https://git.zx2c4.com/cgit/about/) — but a single PHP application instead of a static-site
 generator or a C CGI program. No database, no framework, no build step. It shells out to the `git`
-binary and renders what comes back.
+binary and renders what comes back; accounts and API keys live in one flat JSON file, not a database.

-Push/pull is **not** handled here — that stays git-over-SSH against your bare repos (see
-[`server/`](server/)). This is only the read-only "shop window."
+git push/pull itself is **not** handled here — that's SSH or authenticated HTTPS against your bare
+repos (see [`server/`](server/)). The web app is the "shop window" (browse repos), plus login,
+API key management, and a repo admin panel.

 ## Features

@@ -15,8 +16,11 @@ Push/pull is **not** handled here — that stays git-over-SSH against your bare
   markdown → HTML converter — headings, lists, code, links, bold/italic).
 - Atom feeds for commits and tags.
 - Raw file download with correct `Content-Type` sniffing.
-- Anonymous, unauthenticated `git clone` over HTTPS, alongside SSH push for people with a key —
-  the same split GitHub/GitLab use for public repos (see `server/setup-anon-clone.sh`).
+- Anonymous, unauthenticated `git clone` over HTTPS (see `server/setup-anon-clone.sh`); push works
+  over SSH for people with a key, or over HTTPS with an API key (see Accounts below) — the same
+  split GitHub/GitLab use for public repos.
+- Log in, create/revoke your own API keys, and (as an admin) create/delete repos and add other
+  accounts — all from the browser, no shell access needed.
 - Themeable: colors live entirely in small CSS custom-property files under `themes/`; the
   structural CSS in `assets/base.css` never hardcodes a color. Ships with a dark/light pair in
   its own style plus a GitHub Primer–inspired dark/light pair. A sun/moon toggle switches within
@@ -25,6 +29,24 @@ Push/pull is **not** handled here — that stays git-over-SSH against your bare
   that could be read as flags, path traversal, unicode filenames, binary blobs, oversized diffs,
   and command/argument injection in general.

+## Accounts, API keys, and admin
+
+Visiting the site with no accounts yet shows a one-time "set up an admin account" screen. Once
+logged in:
+
+- **Account** (`?a=account`) — anyone can create and revoke their own API keys. An API key is
+  a personal-access-token-style credential: use it as the password when `git push` asks for one
+  over HTTPS (any username works, same convention as GitHub). Any valid key can push to any repo
+  — the same trust level SSH keyholders already have; there's no per-repo ACL.
+- **Admin** (`?a=admin`, admin accounts only) — create and delete repos from the browser (delete
+  requires retyping the repo name to confirm, same guard as the SSH `delete` command), and create
+  additional accounts (with or without admin rights) — the HTTPS-login equivalent of `add-key.sh`.
+
+Enabling the HTTPS-push side of this (beyond anonymous clone) needs one more server-side setup
+step: `sudo bash server/setup-http-push.sh` — see that script and `server/git-http-backend-auth`
+for how it works (a small CGI wrapper in front of `git-http-backend` that checks push requests'
+Basic Auth password against the stored API keys; reads stay anonymous, unchanged).
+
 ## Architecture

 ```
@@ -32,15 +54,18 @@ index.php          router only — resolves the request, asks inc/ for data, han
 inc/
   config.php         site-specific values (gitignored — see config.example.php)
   functions.php       all git/data logic; never prints HTML
+  auth.php            accounts, API keys, sessions — one JSON file (AUTH_STORE), no database
   render.php            the one bridge between index.php and views/
 views/
-  partials/              header, footer, tab bar, subnav, theme switcher
-  *.php                    one file per page (tree, log, commit, refs, blob, atom, ...)
+  partials/              header (incl. the login/account/admin nav), footer, tabs, subnav
+  *.php                    one file per page (tree, log, commit, ..., login, account, admin)
 assets/               structural CSS (base.css) + the one bit of JS (clone-to-clipboard)
 themes/               *.css files, each just a set of CSS custom properties
 server/               git hosting over SSH: user setup, key management, self-serve
-                      `create`/`describe`/`delete`, and the anonymous-HTTPS-clone route —
-                      independent of the web frontend
+                      `create`/`describe`/`delete`; the anonymous-HTTPS-clone route
+                      (setup-anon-clone.sh); and the authenticated-HTTPS-push route
+                      (setup-http-push.sh, git-http-backend-auth, verify-api-key.php) —
+                      independent of the web frontend except for sharing inc/auth.php
 ```

 `inc/functions.php` never emits HTML; `views/*.php` never talks to git. `index.php` is the only
@@ -55,7 +80,10 @@ place that knows both sides exist.
    halves are idempotent and can be run standalone too — `server/setup-server.sh` for just the
    git side, `deploy.sh` for just the web frontend.
 3. Optional: `sudo bash server/setup-anon-clone.sh` for unauthenticated HTTPS clone.
-4. Add collaborators' SSH keys with `sudo bash server/add-key.sh`.
+4. Optional: `sudo bash server/setup-http-push.sh` (needs step 3 first) so people can push over
+   HTTPS with an API key instead of only SSH — see "Accounts, API keys, and admin" above.
+5. Add collaborators' SSH keys with `sudo bash server/add-key.sh`, or have them log in on the site
+   and create their own API key instead (once an admin has created their account).

 Everything assumes Debian + Caddy + php-fpm; adjust the paths in `inc/config.php` and the
 `server/` scripts for a different setup.
diff --git a/assets/base.css b/assets/base.css
index d5dfe91..a2c3272 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -42,13 +42,18 @@ header .path { color: var(--dim); display: inline-flex; align-items: center; gap
 header .path a { color: var(--fg); font-weight: 600; }
 header .spacer { flex: 1; }

+/* also used on a <button> (the header's log-out form), so it has to unset the
+   background the button rule further down would otherwise give it — every
+   icon button in the header sits flush until hovered. */
 .icon-btn {
   color: var(--dim);
+  background: none;
   border: 1px solid transparent;
   border-radius: 6px;
   padding: 5px;
   display: inline-flex;
   line-height: 0;
+  cursor: pointer;
 }
 .icon-btn:hover { color: var(--fg); background: var(--bg); border-color: var(--line); text-decoration: none; }

@@ -313,7 +318,244 @@ footer .inner { display: flex; align-items: center; justify-content: space-betwe
   font-size: 12px;
 }

+/* ------------------------------------------------------------ auth nav:
+   the account / admin / log-out controls. flex-basis:100% inside the
+   header's wrapping .inner puts them on their OWN row under the brand and
+   icon buttons, rather than competing for the corner — squeezed in beside
+   the theme and help icons they wrapped raggedly, with the log-out button
+   dropping under the username. nowrap keeps the three together on that row.
+   Colours are set explicitly (not via --pre-bg, whose job is code insets)
+   so the chips read the same in every theme. */
+
+.nav-auth {
+  flex-basis: 100%;
+  display: flex;
+  flex-wrap: nowrap;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid var(--line);
+}
+.nav-auth form { display: inline-flex; margin: 0; }
+
+/* one shared look for all three, with a fixed height so they line up exactly */
+.nav-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  height: 30px;
+  padding: 0 12px;
+  background: var(--surface);
+  color: var(--fg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  font: inherit;
+  font-size: 13px;
+  line-height: 1;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: border-color .12s, color .12s;
+}
+.nav-btn:hover { border-color: var(--acc); color: var(--acc); text-decoration: none; }
+.nav-btn:focus-visible {
+  outline: none;
+  border-color: var(--acc);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 15%, transparent);
+}
+.nav-btn .icon { color: var(--dim); }
+.nav-btn:hover .icon { color: inherit; }
+
+.nav-sep { width: 1px; height: 18px; background: var(--line); flex: none; }
+
+/* ----------------------------------------------------------- page titles:
+   setup/login/account/admin are the only pages with a bare <h1> — every
+   other view relies on the header/tab bar for context instead. Kept light
+   (not a browser-default giant serif heading) so it sits at home next to
+   the rest of the 14px UI; can hold a leading .icon (h1's flex+gap centre
+   it against the text the same way box-header's icon does). */
+h1 {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 20px;
+  font-weight: 600;
+  color: var(--fg);
+  margin: 4px 0 20px;
+}
+
+/* --------------------------------------------------------------- forms:
+   login/setup/account/admin — the only pages in foxygit with real forms. */
+
+.form { max-width: 420px; }
+.form-row { margin-bottom: 14px; }
+.form-row:last-of-type { margin-bottom: 18px; }
+.form-row label,
+.form > label { display: block; margin-bottom: 5px; color: var(--dim); font-size: 12px; }
+
+/* Capped rather than 100%: these forms sit inside full-width cards, and a
+   text box stretched across the whole 1100px column looks broken. The auth
+   pages (narrow, centred) opt back into full width further down. */
+input[type="text"], input[type="password"] {
+  width: 100%;
+  max-width: 320px;
+  background: var(--pre-bg);            /* inset, like .pill and .clone-box */
+  color: var(--fg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 7px 10px;
+  font: inherit;
+  font-size: 13px;
+}
+input[type="text"]::placeholder, input[type="password"]::placeholder { color: var(--dim); }
+input[type="text"]:focus, input[type="password"]:focus {
+  outline: none;
+  border-color: var(--acc);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 15%, transparent);
+}
+
+/* input + its submit button side by side — the single-field "create" forms */
+.field-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.field-row button { flex: none; }
+
+/* one-line note under a field, explaining the rule the input enforces */
+.field-hint { margin: 10px 0 0; font-size: 12px; }
+.field-hint code { background: var(--pre-bg); border-radius: 4px; padding: 1px 5px; }
+
+.check-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--fg); }
+.check-row label { margin: 0; color: var(--fg); font-size: 13px; display: inline-flex; align-items: center; gap: 8px; }
+input[type="checkbox"] { accent-color: var(--acc); width: 15px; height: 15px; margin: 0; flex: none; }
+
+/* Buttons stay in the site's quiet register: thin border, inset fill, accent
+   carried as *colour* rather than a saturated slab. --acc is a pale mint, so
+   a solid fill of it reads as a glaring lozenge on a near-black page; a
+   tinted ghost keeps the primary action obvious without shouting.
+   (The plain background: is the fallback for anything without color-mix.) */
+button, .btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  background: var(--surface);
+  color: var(--fg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 7px 14px;
+  font: inherit;
+  font-size: 13px;
+  line-height: 1.4;
+  cursor: pointer;
+  transition: border-color .12s, background .12s, color .12s;
+}
+button:hover, .btn:hover { border-color: var(--acc); color: var(--acc); text-decoration: none; }
+button:focus-visible, .btn:focus-visible {
+  outline: none;
+  border-color: var(--acc);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--acc) 15%, transparent);
+}
+
+button.primary, .btn.primary {
+  color: var(--acc);
+  font-weight: 600;
+  background: var(--surface);
+  background: color-mix(in srgb, var(--acc) 13%, var(--surface));
+  border-color: var(--line);
+  border-color: color-mix(in srgb, var(--acc) 45%, var(--line));
+}
+button.primary:hover, .btn.primary:hover {
+  background: color-mix(in srgb, var(--acc) 22%, var(--surface));
+  border-color: var(--acc);
+  color: var(--acc);
+}
+
+button.danger, .btn.danger { color: var(--del); }
+button.danger:hover, .btn.danger:hover {
+  border-color: var(--del);
+  background: color-mix(in srgb, var(--del) 14%, var(--surface));
+  color: var(--del);
+}
+
+.inline-form { display: inline-flex; gap: 6px; align-items: center; }
+
+.msg-error, .msg-ok {
+  display: flex;
+  gap: 10px;
+  border-radius: 6px;
+  padding: 12px 14px;
+  margin-bottom: 16px;
+  border: 1px solid;
+}
+.msg-error .icon, .msg-ok .icon { flex: none; margin-top: 1px; }
+.msg-error { color: var(--del); background: var(--pre-bg); border-color: var(--del); }
+.msg-ok { color: var(--add); background: var(--pre-bg); border-color: var(--add); }
+.msg-error > div, .msg-ok > div { color: var(--fg); }
+.msg-ok p:first-child { margin-top: 0; }
+.msg-ok p:last-child { margin-bottom: 0; }
+
+.key-reveal { margin: 10px 0; }
+.key-reveal code {
+  display: block;
+  background: var(--pre-bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 8px 10px;
+  word-break: break-all;
+}
+
+/* The "create" forms get their OWN card (box-header + form), rather than
+   being tacked onto the bottom of the list they add to — so the box-header's
+   own bottom rule is the only divider needed above them. */
+.box .form {
+  max-width: none;
+  padding: 16px;
+  margin: 0;
+}
+.box .form-row:last-of-type { margin-bottom: 14px; }
+.box .form .field-row:last-child,
+.box .form > label + .field-row { margin-bottom: 0; }
+
+/* ------------------------------------------------------------ row-actions:
+   the trash-icon disclosure used for delete-repo/delete-user — a plain
+   <details>/<summary>, no JS, so the confirm field only appears once you've
+   deliberately opened it instead of cluttering every row by default. */
+.row-actions summary {
+  list-style: none;
+  display: inline-flex;
+  color: var(--dim);
+  border: 1px solid transparent;
+  border-radius: 6px;
+  padding: 5px;
+  cursor: pointer;
+}
+.row-actions summary::-webkit-details-marker { display: none; }
+.row-actions summary:hover { color: var(--del); background: var(--bg); border-color: var(--line); }
+.row-actions[open] summary { color: var(--del); background: var(--bg); border-color: var(--line); }
+.row-actions .confirm-delete {
+  display: flex;
+  gap: 6px;
+  justify-content: flex-end;
+  margin-top: 8px;
+}
+.row-actions .confirm-delete input[type="text"] { width: 210px; max-width: 210px; }
+
+/* --------------------------------------------------------- auth pages:
+   setup/login — a single centred card, the one place in foxygit that
+   looks like a dedicated screen rather than a page under the header. */
+.auth-page { max-width: 360px; margin: 56px auto 0; }
+.auth-page h1 { justify-content: center; font-size: 22px; margin-bottom: 6px; }
+.auth-page .lede { text-align: center; margin: 0 0 24px; }
+.auth-page .box { padding: 26px 26px 4px; background: var(--surface); }
+.auth-page .form { max-width: none; padding: 0; background: none; border-top: 0; }
+/* narrow, centred column — here full-width fields are the right call */
+.auth-page input[type="text"], .auth-page input[type="password"] { max-width: none; }
+.auth-page button.primary { width: 100%; padding: 9px 14px; }
+
 @media (max-width: 720px) {
   .inner { padding: 0 16px; }
   .feeds { margin-left: 0; }
+  .nav-auth { justify-content: flex-start; gap: 8px; }
+  .nav-btn { padding: 0 10px; }
+  input[type="text"], input[type="password"] { max-width: none; }
+  .row-actions .confirm-delete { flex-wrap: wrap; }
 }
diff --git a/inc/auth.php b/inc/auth.php
new file mode 100644
index 0000000..88db69b
--- /dev/null
+++ b/inc/auth.php
@@ -0,0 +1,269 @@
+<?php
+declare(strict_types=1);
+
+/*
+ * Accounts, API keys, and sessions — the one part of foxygit that isn't
+ * read-only. Everything lives in one JSON file (AUTH_STORE, see
+ * inc/config.php), read-modify-written under an flock() so concurrent
+ * requests can't clobber each other.
+ *
+ * Split in two halves on purpose:
+ *   - the data layer (load/save/create_user/verify_login/api keys) has no
+ *     dependency on $_SESSION or any web-only PHP feature, so
+ *     server/verify-api-key.php can require() this file and call
+ *     verify_api_key() from plain CLI — the HTTPS-push auth check reuses
+ *     the exact same hashing/lookup code the web app uses.
+ *   - the session/CSRF half is web-only and only ever touched by the new
+ *     login/account/admin routes in index.php, never by plain repo
+ *     browsing — so browsing a repo still sets no cookie beyond the
+ *     existing foxygit_theme one.
+ */
+
+/** Open AUTH_STORE, lock it, hand the decoded array to $mutator by
+ *  reference, then write back whatever $mutator left in it. $mutator's
+ *  return value is passed through — this is the only way callers read or
+ *  write the store, so every operation is atomic against concurrent requests. */
+function with_auth_store(callable $mutator) {
+    $dir = dirname(AUTH_STORE);
+    if (!is_dir($dir)) @mkdir($dir, 0700, true);
+
+    $fh = fopen(AUTH_STORE, 'c+');
+    if ($fh === false) {
+        throw new RuntimeException('cannot open auth store: ' . AUTH_STORE);
+    }
+    flock($fh, LOCK_EX);
+
+    $raw  = stream_get_contents($fh);
+    $data = $raw !== false && $raw !== '' ? json_decode($raw, true) : null;
+    if (!is_array($data)) $data = [];
+    if (!isset($data['users']) || !is_array($data['users'])) $data['users'] = [];
+    if (!isset($data['keys'])  || !is_array($data['keys']))  $data['keys']  = [];
+
+    $result = $mutator($data);
+
+    ftruncate($fh, 0);
+    rewind($fh);
+    fwrite($fh, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+    fflush($fh);
+    flock($fh, LOCK_UN);
+    fclose($fh);
+    @chmod(AUTH_STORE, 0600);
+
+    return $result;
+}
+
+function has_any_users(): bool {
+    return with_auth_store(function (array &$data): bool {
+        return $data['users'] !== [];
+    });
+}
+
+function valid_username(string $name): bool {
+    return (bool) preg_match('~^[A-Za-z0-9_-]{2,32}$~', $name);
+}
+
+/** @return true|string true on success, an error message otherwise. */
+function create_user(string $username, string $password, bool $isAdmin) {
+    if (!valid_username($username)) {
+        return 'Username must be 2-32 characters: letters, digits, "_" or "-".';
+    }
+    if (strlen($password) < 8) {
+        return 'Password must be at least 8 characters.';
+    }
+    return with_auth_store(function (array &$data) use ($username, $password, $isAdmin) {
+        if (isset($data['users'][$username])) {
+            return 'That username is already taken.';
+        }
+        $data['users'][$username] = [
+            'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => PASSWORD_COST]),
+            'is_admin'      => $isAdmin,
+            'created'       => time(),
+        ];
+        return true;
+    });
+}
+
+/* Bcrypt cost, pinned rather than left to PASSWORD_DEFAULT so that the work
+ * factor of a real verify and of the dummy verify below can't drift apart —
+ * PHP raised the bcrypt default from 10 to 12 in 8.4, and a dummy hash left
+ * at the older cost verifies ~4x faster, which is exactly the timing signal
+ * it exists to suppress. Raise this to make hashing slower; if you do,
+ * regenerate DUMMY_PASSWORD_HASH at the same cost:
+ *     php -r 'echo password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT, ["cost" => 12]);'
+ * Existing stored hashes keep working either way — password_verify() reads
+ * the cost out of the hash itself. */
+const PASSWORD_COST = 12;
+
+// A valid bcrypt hash (of a random string) that nothing will ever match — run
+// through password_verify() when a username doesn't exist, so a login attempt
+// against an unknown user costs the same as one against a real user with a
+// wrong password, and timing doesn't reveal which usernames exist.
+const DUMMY_PASSWORD_HASH = '$2y$12$JjwvL8rDRDdE9lf2XI548eGjcRu9scv6tNsXmzjcExnQztbg3zGIm';
+
+function verify_login(string $username, string $password): bool {
+    return with_auth_store(function (array &$data) use ($username, $password): bool {
+        $user = $data['users'][$username] ?? null;
+        $hash = $user['password_hash'] ?? DUMMY_PASSWORD_HASH;
+        $ok   = password_verify($password, $hash);
+        return $user !== null && $ok;
+    });
+}
+
+function list_users(): array {
+    return with_auth_store(function (array &$data): array {
+        $out = $data['users'];
+        uasort($out, fn(array $a, array $b): int => $a['created'] <=> $b['created']);
+        return $out;
+    });
+}
+
+/** @return true|string true on success, an error message otherwise. */
+function delete_user(string $username, string $requester) {
+    if ($username === $requester) {
+        return "You can't delete your own account while logged in as it.";
+    }
+    return with_auth_store(function (array &$data) use ($username): bool {
+        if (!isset($data['users'][$username])) return false;
+        unset($data['users'][$username]);
+        foreach ($data['keys'] as $hash => $meta) {
+            if ($meta['user'] === $username) unset($data['keys'][$hash]);
+        }
+        return true;
+    }) ? true : 'No such user.';
+}
+
+/** Returns the raw key — shown to the user exactly once. Only its SHA-256
+ *  hash is ever stored, the same way GitHub/GitLab handle personal access
+ *  tokens (a random high-entropy token is looked up by exact hash, unlike a
+ *  user-chosen low-entropy password which needs bcrypt's per-hash salt). */
+function generate_api_key(string $username, string $label): string {
+    $raw  = 'fxg_' . bin2hex(random_bytes(24));
+    $hash = hash('sha256', $raw);
+    $label = $label !== '' ? $label : 'unnamed key';
+    with_auth_store(function (array &$data) use ($username, $label, $hash): void {
+        $data['keys'][$hash] = [
+            'user'      => $username,
+            'label'     => $label,
+            'created'   => time(),
+            'last_used' => null,
+        ];
+    });
+    return $raw;
+}
+
+function list_api_keys(string $username): array {
+    return with_auth_store(function (array &$data) use ($username): array {
+        $out = array_filter($data['keys'], fn(array $k): bool => $k['user'] === $username);
+        uasort($out, fn(array $a, array $b): int => $b['created'] <=> $a['created']);
+        return $out;
+    });
+}
+
+function revoke_api_key(string $hash, string $requester, bool $requesterIsAdmin): bool {
+    return with_auth_store(function (array &$data) use ($hash, $requester, $requesterIsAdmin): bool {
+        $key = $data['keys'][$hash] ?? null;
+        if ($key === null) return false;
+        if (!$requesterIsAdmin && $key['user'] !== $requester) return false;
+        unset($data['keys'][$hash]);
+        return true;
+    });
+}
+
+/** Validates a raw API key (as sent as an HTTP Basic auth password) and
+ *  bumps its last_used timestamp. Used by the web app AND by
+ *  server/verify-api-key.php from plain CLI — the one shared source of
+ *  truth for "is this key valid". @return the owning username, or null. */
+function verify_api_key(string $rawKey): ?string {
+    $rawKey = trim($rawKey);
+    if ($rawKey === '') return null;
+    $hash = hash('sha256', $rawKey);
+    return with_auth_store(function (array &$data) use ($hash): ?string {
+        $key = $data['keys'][$hash] ?? null;
+        if ($key === null) return null;
+        $data['keys'][$hash]['last_used'] = time();
+        return $key['user'];
+    });
+}
+
+/* ------------------------------------------------------------- sessions */
+/* Everything below is web-only and assumes a normal PHP request/response
+ * lifecycle ($_SESSION, headers). Only called from the login/account/admin
+ * routes in index.php — never from plain repo browsing. */
+
+const SESSION_COOKIE_NAME = 'foxygit_sess';
+
+/** Called once per request (index.php) with $force = false: resumes a
+ *  session if the visitor already has one (so the nav can show "logged in
+ *  as ..." everywhere), but never creates one for a plain anonymous
+ *  browsing visitor — that's the whole "no cookies beyond the theme one"
+ *  guarantee. The login/setup/logout/account/admin routes call it again
+ *  with $force = true, which guarantees a session exists (creating one on
+ *  a visitor's very first hit to e.g. ?a=login). */
+function auth_session_start(bool $force = false): void {
+    if (session_status() === PHP_SESSION_ACTIVE) return;
+    if (!$force && !isset($_COOKIE[SESSION_COOKIE_NAME])) return;
+    session_name(SESSION_COOKIE_NAME);
+    session_set_cookie_params([
+        'lifetime' => 0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
+    ]);
+    session_start();
+}
+
+function current_username(): ?string {
+    if (session_status() !== PHP_SESSION_ACTIVE) return null;
+    return $_SESSION['username'] ?? null;
+}
+
+function is_logged_in(): bool {
+    return current_username() !== null;
+}
+
+function is_admin(): bool {
+    if (session_status() !== PHP_SESSION_ACTIVE) return false;
+    return (bool) ($_SESSION['is_admin'] ?? false);
+}
+
+function log_in_session(string $username, bool $isAdmin): void {
+    session_regenerate_id(true);
+    $_SESSION['username'] = $username;
+    $_SESSION['is_admin']  = $isAdmin;
+}
+
+function log_out_session(): void {
+    $_SESSION = [];
+    if (ini_get('session.use_cookies')) {
+        $params = session_get_cookie_params();
+        setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
+    }
+    session_destroy();
+}
+
+function require_login(): void {
+    if (!is_logged_in()) {
+        header('Location: ?a=login');
+        exit;
+    }
+}
+
+function require_admin(): void {
+    require_login();
+    if (!is_admin()) {
+        http_response_code(403);
+        exit('Forbidden — admin only.');
+    }
+}
+
+function csrf_token(): string {
+    if (empty($_SESSION['csrf'])) {
+        $_SESSION['csrf'] = bin2hex(random_bytes(32));
+    }
+    return $_SESSION['csrf'];
+}
+
+function check_csrf(): void {
+    $token = $_POST['csrf'] ?? '';
+    if (!is_string($token) || $token === '' || !hash_equals($_SESSION['csrf'] ?? '', $token)) {
+        http_response_code(400);
+        exit('Form expired or invalid (CSRF check failed) — go back and try again.');
+    }
+}
diff --git a/inc/config.example.php b/inc/config.example.php
index a282d6a..cbb9ba1 100644
--- a/inc/config.example.php
+++ b/inc/config.example.php
@@ -15,3 +15,4 @@ const MAX_DIFF_BYTES = 2_000_000;           // commit diffs bigger than this get
 const ATOM_COUNT     = 100;                 // commits included in the atom feed
 const THEME_DEFAULT  = 'foxygit-dark';      // theme used when nothing else is requested
 const THEME_DIR      = __DIR__ . '/../themes'; // one CSS custom-property file per theme; drop a new one in to add it
+const AUTH_STORE     = '/var/git/foxygit-auth.json'; // user accounts + API key hashes (JSON, git:git-owned); shared with server/verify-api-key.php
diff --git a/inc/functions.php b/inc/functions.php
index a24e294..e030dee 100644
--- a/inc/functions.php
+++ b/inc/functions.php
@@ -90,9 +90,18 @@ function git_bytes(string $repo, array $args): string {
     return $data === false ? '' : $data;
 }

+/** Cache-busting URL for a static asset — appends the file's mtime as ?v=.
+ *  Without it an edited stylesheet keeps being served from the visitor's
+ *  browser cache (and, for this site, from Cloudflare's edge cache, which
+ *  holds on to .css for a long time), so CSS changes appear not to deploy. */
+function asset_url(string $rel): string {
+    $mtime = @filemtime(__DIR__ . '/../' . $rel);
+    return $mtime !== false ? $rel . '?v=' . $mtime : $rel;
+}
+
 /** Validate + resolve a repo name to an absolute path INSIDE REPO_BASE. */
 function resolve_repo(?string $name): ?string {
-    if ($name === null || !preg_match('~^[A-Za-z0-9._-]+$~', $name)) {
+    if ($name === null || !valid_repo_name($name)) {
         return null;                              // reject junk / traversal chars
     }
     $base = realpath(REPO_BASE);
@@ -104,6 +113,48 @@ function resolve_repo(?string $name): ?string {
     return $path;
 }

+/** Same name rule the SSH-side git-shell-commands/{create,delete} enforce:
+ *  no path separators, no leading dot (hidden dirs) or dash (looks like a
+ *  flag), plain ASCII identifier characters only. */
+function valid_repo_name(string $name): bool {
+    return $name !== '' && preg_match('~^[A-Za-z0-9._-]+$~', $name) === 1
+        && $name[0] !== '.' && $name[0] !== '-';
+}
+
+/** Create a new bare repo under REPO_BASE, pre-configured so it accepts
+ *  push over authenticated HTTPS (see server/git-http-backend-auth) the
+ *  same way server/git-shell-commands/create does for SSH.
+ *  @return true|string true on success, an error message otherwise. */
+function create_bare_repo(string $name) {
+    if (!valid_repo_name($name)) return 'Invalid repo name.';
+    $dest = REPO_BASE . '/' . $name . '.git';
+    if (file_exists($dest)) return "A repo named \"$name\" already exists.";
+
+    $cmd = 'git init --bare -- ' . escapeshellarg($dest) . ' 2>&1';
+    exec($cmd, $out, $status);
+    if ($status !== 0) return 'git init failed: ' . implode(' ', $out);
+
+    exec('git -C ' . escapeshellarg($dest) . ' config http.receivepack true 2>&1', $out2, $status2);
+    if ($status2 !== 0) return 'repo created, but enabling HTTPS push failed: ' . implode(' ', $out2);
+
+    return true;
+}
+
+/** Permanently deletes a bare repo under REPO_BASE.
+ *  @return true|string true on success, an error message otherwise. */
+function delete_bare_repo(string $name) {
+    if (!valid_repo_name($name)) return 'Invalid repo name.';
+    $dest = REPO_BASE . '/' . $name . '.git';
+    $real = realpath($dest);
+    $base = realpath(REPO_BASE);
+    if ($real === false || $base === false || strncmp($real, $base . '/', strlen($base) + 1) !== 0) {
+        return 'No such repo.';
+    }
+    exec('rm -rf -- ' . escapeshellarg($real) . ' 2>&1', $out, $status);
+    if ($status !== 0) return 'delete failed: ' . implode(' ', $out);
+    return true;
+}
+
 /** Accept only a plausible ref/path so we never feed dangerous junk to git.
  *  Blocks: empty, overlong, a leading '-' (which git would read as a flag
  *  rather than a revision/path — the actual injection risk), '..' (path
diff --git a/index.php b/index.php
index 8f69640..0eecb8e 100644
--- a/index.php
+++ b/index.php
@@ -2,11 +2,14 @@
 declare(strict_types=1);

 /*
- * foxygit — a tiny read-only git web frontend. No database, no deps.
+ * foxygit — a mostly-read-only git web frontend. No database, no deps.
  *
- * Reads bare repos by shelling out to the `git` binary. Push/pull is NOT
- * handled here — that stays git-over-SSH on your bare repos (see server/).
- * This is only the read-only "shop window", like stagit or cgit.
+ * Reads bare repos by shelling out to the `git` binary. git push/pull itself
+ * is NOT handled here — that's SSH (see server/) or authenticated HTTPS via
+ * git-http-backend (see server/git-http-backend-auth). This app is the
+ * "shop window" (browse repos, log in, manage API keys, admin repo
+ * create/delete) plus the one shared source of truth (inc/auth.php) that
+ * the HTTPS-push auth check also reads from.
  *
  * This file is the router only: it resolves input, asks inc/functions.php
  * to talk to git, and hands plain-array results to views/*.php to render.
@@ -19,6 +22,7 @@ declare(strict_types=1);
 require __DIR__ . '/inc/config.php';
 require __DIR__ . '/inc/functions.php';
 require __DIR__ . '/inc/render.php';
+require __DIR__ . '/inc/auth.php';

 /* ---------------------------------------------------------------- routing */

@@ -27,6 +31,15 @@ $repo     = $repoName !== null ? resolve_repo($repoName) : null;
 // A repo with no explicit view lands on Files (tree), the way GitHub opens on Code.
 $action   = $_GET['a'] ?? ($repoName !== null ? 'tree' : 'index');

+// Resume a session if the visitor already has one (so the nav can show who's
+// logged in on any page), but never create one just for anonymous browsing —
+// that stays exactly as cookie-light as it's always been. The auth routes
+// below force a session into existence (needed for CSRF tokens + login itself).
+auth_session_start();
+if (in_array($action, ['setup', 'login', 'logout', 'account', 'admin'], true)) {
+    auth_session_start(force: true);
+}
+
 if ($repoName !== null && $repo === null) {
     http_response_code(404);
     $action = 'notfound';
@@ -56,6 +69,148 @@ if ($action === 'notfound') {
     exit;
 }

+/* -------------------------------------------------------- accounts/admin */
+
+if ($action === 'setup') {                              // one-time: create the first (admin) account
+    if (has_any_users()) { header('Location: ?a=login'); exit; }
+
+    $error = null;
+    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+        check_csrf();
+        $username = trim((string) ($_POST['username'] ?? ''));
+        $password = (string) ($_POST['password'] ?? '');
+        $confirm  = (string) ($_POST['confirm'] ?? '');
+        if ($password !== $confirm) {
+            $error = 'Passwords do not match.';
+        } else {
+            $result = create_user($username, $password, true);
+            if ($result === true) {
+                log_in_session($username, true);
+                header('Location: ?a=account');
+                exit;
+            }
+            $error = $result;
+        }
+    }
+
+    render('partials/head', ['title' => SITE_NAME . ' · set up', 'theme' => $theme, 'repoName' => null]);
+    render('setup', ['error' => $error, 'csrf' => csrf_token()]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($action === 'login') {
+    if (!has_any_users()) { header('Location: ?a=setup'); exit; }
+
+    $error = null;
+    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+        check_csrf();
+        $username = trim((string) ($_POST['username'] ?? ''));
+        $password = (string) ($_POST['password'] ?? '');
+        if (verify_login($username, $password)) {
+            $users   = list_users();
+            $isAdmin = (bool) ($users[$username]['is_admin'] ?? false);
+            log_in_session($username, $isAdmin);
+            // only ever redirect to a same-page query string (must start with "?") --
+            // anything else (an absolute/protocol-relative URL) is rejected so a
+            // crafted ?next= can't turn a real login into an open redirect.
+            $next = $_POST['next'] ?? '';
+            $target = is_string($next) && str_starts_with($next, '?') ? $next : '?';
+            header('Location: ' . $target);
+            exit;
+        }
+        // deliberately vague — doesn't reveal whether the username exists
+        $error = 'Invalid username or password.';
+    }
+
+    render('partials/head', ['title' => SITE_NAME . ' · log in', 'theme' => $theme, 'repoName' => null]);
+    render('login', ['error' => $error, 'csrf' => csrf_token(), 'next' => (string) ($_GET['next'] ?? '')]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($action === 'logout') {
+    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+        check_csrf();
+        log_out_session();
+    }
+    header('Location: ?');
+    exit;
+}
+
+if ($action === 'account') {
+    require_login();
+    $username = (string) current_username();
+
+    $newKey = null;
+    $error  = null;
+    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+        check_csrf();
+        $sub = $_POST['sub'] ?? '';
+        if ($sub === 'create-key') {
+            $label  = trim((string) ($_POST['label'] ?? ''));
+            $newKey = generate_api_key($username, $label);
+        } elseif ($sub === 'revoke-key') {
+            $hash = (string) ($_POST['hash'] ?? '');
+            if (!revoke_api_key($hash, $username, is_admin())) {
+                $error = 'Could not revoke that key.';
+            }
+        }
+    }
+
+    render('partials/head', ['title' => SITE_NAME . ' · account', 'theme' => $theme, 'repoName' => null]);
+    render('account', [
+        'username' => $username, 'isAdmin' => is_admin(), 'keys' => list_api_keys($username),
+        'newKey' => $newKey, 'error' => $error, 'csrf' => csrf_token(),
+    ]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($action === 'admin') {
+    require_admin();
+    $username = (string) current_username();
+
+    $error   = null;
+    $success = null;
+    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+        check_csrf();
+        $sub = $_POST['sub'] ?? '';
+        if ($sub === 'create-repo') {
+            $name   = trim((string) ($_POST['name'] ?? ''));
+            $result = create_bare_repo($name);
+            if ($result === true) { $success = "Created \"$name\"."; } else { $error = $result; }
+        } elseif ($sub === 'delete-repo') {
+            $name    = trim((string) ($_POST['name'] ?? ''));
+            $confirm = trim((string) ($_POST['confirm'] ?? ''));
+            if ($name === '' || $name !== $confirm) {
+                $error = 'Repo name did not match its confirmation — nothing deleted.';
+            } else {
+                $result = delete_bare_repo($name);
+                if ($result === true) { $success = "Deleted \"$name\"."; } else { $error = $result; }
+            }
+        } elseif ($sub === 'add-user') {
+            $newUsername = trim((string) ($_POST['username'] ?? ''));
+            $newPassword = (string) ($_POST['password'] ?? '');
+            $newIsAdmin  = isset($_POST['is_admin']);
+            $result = create_user($newUsername, $newPassword, $newIsAdmin);
+            if ($result === true) { $success = "Created account \"$newUsername\"."; } else { $error = $result; }
+        } elseif ($sub === 'delete-user') {
+            $targetUsername = trim((string) ($_POST['username'] ?? ''));
+            $result = delete_user($targetUsername, $username);
+            if ($result === true) { $success = "Deleted account \"$targetUsername\"."; } else { $error = $result; }
+        }
+    }
+
+    render('partials/head', ['title' => SITE_NAME . ' · admin', 'theme' => $theme, 'repoName' => null]);
+    render('admin', [
+        'repos' => list_repos(), 'users' => list_users(), 'currentUsername' => $username,
+        'error' => $error, 'success' => $success, 'csrf' => csrf_token(),
+    ]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
 if ($repo === null) {                                   // repo index
     render('partials/head', [
         'title' => SITE_NAME, 'theme' => $theme, 'repoName' => null, 'description' => SITE_DESCRIPTION,
diff --git a/server/git-http-backend-auth b/server/git-http-backend-auth
new file mode 100755
index 0000000..cb6fe62
--- /dev/null
+++ b/server/git-http-backend-auth
@@ -0,0 +1,51 @@
+#!/bin/sh
+# CGI wrapper around git-http-backend, installed by setup-http-push.sh in
+# place of calling git-http-backend directly (see the /repos/* block in the
+# git.kristoffersson.info Caddyfile). Invoked by fcgiwrap-git, as the `git`
+# user, once per HTTP request to /repos/*.
+#
+# Clone/fetch (anonymous, read-only) is untouched -- it execs straight into
+# git-http-backend exactly like before. A push (git-receive-pack) additionally
+# requires HTTP Basic Auth whose password is a valid foxygit API key (created
+# at https://<host>/?a=account); the username is not checked, same as GitHub's
+# "any username, a PAT as the password" convention -- the key alone identifies
+# the account. Wrong/missing credentials get a 401 instead of ever reaching
+# git-http-backend.
+#
+# Installed to /usr/local/lib/foxygit/git-http-backend-auth, root:root 0755 --
+# root-owned so a bug in the (git-user-owned) web app can't rewrite its own
+# auth gate. verify-api-key.php lives alongside it.
+
+set -eu
+
+GIT_HTTP_BACKEND="/usr/lib/git-core/git-http-backend"
+VERIFY_SCRIPT="$(dirname "$0")/verify-api-key.php"
+
+is_push=0
+case "${PATH_INFO:-}" in
+	*/git-receive-pack) is_push=1 ;;
+esac
+case "${QUERY_STRING:-}" in
+	*service=git-receive-pack*) is_push=1 ;;
+esac
+
+if [ "$is_push" -eq 1 ]; then
+	# Caddy's fastcgi transport forwards request headers as HTTP_* env vars;
+	# some setups instead deliver it as REDIRECT_HTTP_AUTHORIZATION.
+	auth="${HTTP_AUTHORIZATION:-${REDIRECT_HTTP_AUTHORIZATION:-}}"
+	cred=""
+	case "$auth" in
+		"Basic "*)
+			b64="${auth#Basic }"
+			cred=$(printf '%s' "$b64" | base64 -d 2>/dev/null || true)
+			;;
+	esac
+	pass="${cred#*:}"
+
+	if [ -z "$auth" ] || [ "$cred" = "$pass" ] || ! printf '%s' "$pass" | php "$VERIFY_SCRIPT"; then
+		printf 'Status: 401 Unauthorized\r\nWWW-Authenticate: Basic realm="foxygit push"\r\nContent-Type: text/plain\r\n\r\nAuthentication required to push. Create an API key at https://%s/?a=account and use it as the password (any username works).\n' "${HTTP_HOST:-this host}"
+		exit 0
+	fi
+fi
+
+exec "$GIT_HTTP_BACKEND"
diff --git a/server/git-shell-commands/create b/server/git-shell-commands/create
index fd7ce20..f7d5b81 100755
--- a/server/git-shell-commands/create
+++ b/server/git-shell-commands/create
@@ -32,6 +32,7 @@ if [ -e "$dest" ]; then
 fi

 git init --bare --template="$templatedir" -- "$dest" >/dev/null
+git -C "$dest" config http.receivepack true	# allow push over authenticated HTTPS too, see server/setup-http-push.sh

 echo "created repos/${name}.git"
 echo "clone with: git clone git@${sshhost}:repos/${name}.git"
diff --git a/server/setup-http-push.sh b/server/setup-http-push.sh
new file mode 100755
index 0000000..1d50111
--- /dev/null
+++ b/server/setup-http-push.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+# Authenticated `git push` over HTTPS, using API keys created on the
+# website (https://<host>/?a=account) as the credential -- the HTTPS
+# equivalent of an SSH key, for people who'd rather not manage one.
+#
+# Anonymous clone/fetch (server/setup-anon-clone.sh) is untouched: this only
+# adds an auth check in front of git-http-backend for push (receive-pack)
+# requests. It works by swapping one env var in the existing /repos/* Caddy
+# block so it runs a small wrapper (server/git-http-backend-auth) instead of
+# calling git-http-backend directly:
+#
+#   - clone/fetch requests: the wrapper execs straight into git-http-backend,
+#     unchanged from today.
+#   - push requests: the wrapper checks the request's HTTP Basic Auth
+#     password against the API keys created via the web app (stored, hashed,
+#     in /var/git/foxygit-auth.json) before allowing it through.
+#
+# Requires server/setup-anon-clone.sh to have been run first (this script
+# assumes the /repos/* block and the fcgiwrap-git service already exist).
+#
+# What it does:
+#   1. Creates /var/git/foxygit-auth.json if missing (git:git, 0600).
+#   2. Installs server/git-http-backend-auth + server/verify-api-key.php to
+#      /usr/local/lib/foxygit/ (root:root, 0755 -- root-owned so the
+#      git-user-owned web app can't rewrite its own auth gate).
+#   3. Points the /repos/* block's SCRIPT_FILENAME at the wrapper instead of
+#      git-http-backend directly. Backs up the Caddyfile first, validates
+#      before reloading. Idempotent -- safe to re-run.
+#   4. Sets http.receivepack=true on every existing bare repo under
+#      /var/git/repos (new repos get this automatically from
+#      server/git-shell-commands/create and the web admin panel's
+#      create_bare_repo()).
+#
+# Run as root:
+#   sudo bash /home/mrfox/foxygit/server/setup-http-push.sh
+
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo "run as root: sudo bash $0" >&2
+    exit 1
+fi
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+CADDYFILE="/etc/caddy/Caddyfile"
+REPOS_DIR="/var/git/repos"
+AUTH_STORE="/var/git/foxygit-auth.json"
+LIB_DIR="/usr/local/lib/foxygit"
+
+echo "==> 1/4  auth store: $AUTH_STORE"
+if [ ! -e "$AUTH_STORE" ]; then
+    printf '{"users":{},"keys":{}}' > "$AUTH_STORE"
+    chown git:git "$AUTH_STORE"
+    chmod 0600 "$AUTH_STORE"
+    echo "    created"
+else
+    echo "    already exists, left untouched"
+fi
+
+echo "==> 2/4  installing wrapper scripts to $LIB_DIR"
+mkdir -p "$LIB_DIR"
+install -m 0755 -o root -g root "$ROOT/git-http-backend-auth" "$LIB_DIR/git-http-backend-auth"
+install -m 0755 -o root -g root "$ROOT/verify-api-key.php"    "$LIB_DIR/verify-api-key.php"
+
+echo "==> 3/4  updating Caddyfile ($CADDYFILE)"
+
+python3 - "$CADDYFILE" "$LIB_DIR/git-http-backend-auth" <<'PYEOF'
+import sys, subprocess, datetime
+
+path, wrapper = sys.argv[1], sys.argv[2]
+with open(path, "r", encoding="utf-8") as f:
+    content = f.read()
+
+old_line = "\t\t\t\tenv SCRIPT_FILENAME /usr/lib/git-core/git-http-backend\n"
+new_line = "\t\t\t\tenv SCRIPT_FILENAME " + wrapper + "\n"
+
+if new_line in content:
+    print("    already up to date, nothing to change")
+    sys.exit(0)
+
+if old_line not in content:
+    print("ERROR: expected line not found verbatim in " + path
+          + " -- has the /repos/* block changed since setup-anon-clone.sh "
+          + "wrote it? Edit the Caddyfile by hand instead: point that "
+          + "block's SCRIPT_FILENAME at " + wrapper + ". No changes made.",
+          file=sys.stderr)
+    sys.exit(1)
+
+backup = path + ".bak." + datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+subprocess.run(["cp", path, backup], check=True)
+print("    backup saved to " + backup)
+
+content = content.replace(old_line, new_line, 1)
+with open(path, "w", encoding="utf-8") as f:
+    f.write(content)
+print("    SCRIPT_FILENAME repointed at " + wrapper)
+PYEOF
+
+echo "==> validating Caddy config"
+caddy validate --config "$CADDYFILE"
+
+echo "==> reloading caddy"
+systemctl reload caddy
+
+echo "==> 4/4  enabling HTTPS push on existing repos under $REPOS_DIR"
+count=0
+for dest in "$REPOS_DIR"/*.git; do
+    [ -d "$dest" ] || continue
+    git -C "$dest" config http.receivepack true
+    count=$((count + 1))
+done
+echo "    done ($count repo(s))"
+
+cat <<'EOF'
+
+Done. To push over HTTPS:
+  1. Log in at https://<host>/, go to "Account", create an API key.
+  2. git clone https://<host>/repos/<reponame>.git   (still anonymous)
+  3. git push                                        -- prompts for
+     username (anything) and password: paste the API key.
+     Or embed it: git remote set-url origin https://<host>/repos/<reponame>.git
+     and use a credential helper, or https://<user>:<key>@<host>/repos/<reponame>.git
+
+Any valid API key can push to any repo, the same trust level SSH keyholders
+already have.
+EOF
diff --git a/server/verify-api-key.php b/server/verify-api-key.php
new file mode 100755
index 0000000..bd23ace
--- /dev/null
+++ b/server/verify-api-key.php
@@ -0,0 +1,25 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+/*
+ * CLI shim for server/git-http-backend-auth: reads a raw API key from
+ * stdin, exits 0 if it's valid, 1 otherwise. Deliberately thin — the actual
+ * hashing/lookup is verify_api_key() in inc/auth.php, the same function the
+ * web app's ?a=account page uses, so there is exactly one place that knows
+ * what makes a key valid.
+ *
+ * Installed alongside server/git-http-backend-auth at
+ * /usr/local/lib/foxygit/verify-api-key.php by server/setup-http-push.sh.
+ * Hardcodes the deployed web root below -- if foxygit is ever deployed
+ * somewhere other than /var/www/foxygit, update both this path and
+ * deploy.sh/setup-http-push.sh together.
+ */
+
+const FOXYGIT_ROOT = '/var/www/foxygit';
+
+require FOXYGIT_ROOT . '/inc/config.php';
+require FOXYGIT_ROOT . '/inc/auth.php';
+
+$rawKey = trim((string) stream_get_contents(STDIN));
+exit(verify_api_key($rawKey) !== null ? 0 : 1);
diff --git a/views/account.php b/views/account.php
new file mode 100644
index 0000000..f2b8ced
--- /dev/null
+++ b/views/account.php
@@ -0,0 +1,69 @@
+<?php
+/** @var string $username @var bool $isAdmin @var array $keys @var ?string $newKey
+ *  @var ?string $error @var string $csrf */
+$warnIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<rect x="7.25" y="4" width="1.5" height="5.5" rx="0.75"></rect><circle cx="8" cy="11.75" r="0.9"></circle></svg>';
+$okIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<path d="M4.5 8.2 7 10.7l4.5-5" style="fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round"></path></svg>';
+$plusIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<rect x="7" y="2" width="2" height="12" rx="1"></rect><rect x="2" y="7" width="12" height="2" rx="1"></rect></svg>';
+?>
+<h1>Account</h1>
+<p class="desc">Logged in as <strong><?= h($username) ?></strong><?= $isAdmin ? ' (admin)' : '' ?>.</p>
+
+<?php if ($error !== null): ?>
+<div class="msg-error"><?= $warnIcon ?><div><?= h($error) ?></div></div>
+<?php endif; ?>
+
+<?php if ($newKey !== null): ?>
+<div class="msg-ok">
+  <?= $okIcon ?>
+  <div>
+    <p><strong>New API key created.</strong> Copy it now — it won't be shown again:</p>
+    <div class="key-reveal"><code><?= h($newKey) ?></code></div>
+    <p>Use it as the password when <code>git push</code> asks for one over HTTPS (any username works).</p>
+  </div>
+</div>
+<?php endif; ?>
+
+<div class="box">
+  <div class="box-header">API keys</div>
+  <?php if (!$keys): ?>
+  <table><tr><td class="desc">No API keys yet.</td></tr></table>
+  <?php else: ?>
+  <table>
+    <thead><tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr></thead>
+    <?php foreach ($keys as $hash => $key): ?>
+    <tr>
+      <td><?= h($key['label']) ?></td>
+      <td class="desc"><?= h(format_relative_time($key['created'])) ?></td>
+      <td class="desc"><?= $key['last_used'] !== null ? h(format_relative_time($key['last_used'])) : 'never' ?></td>
+      <td class="num">
+        <form class="inline-form" method="post" action="?a=account" onsubmit="return confirm('Revoke this key? Anything using it will stop working immediately.');">
+          <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+          <input type="hidden" name="sub" value="revoke-key">
+          <input type="hidden" name="hash" value="<?= h($hash) ?>">
+          <button type="submit" class="danger">Revoke</button>
+        </form>
+      </td>
+    </tr>
+    <?php endforeach; ?>
+  </table>
+  <?php endif; ?>
+</div>
+
+<div class="box">
+  <div class="box-header"><?= $plusIcon ?> New API key</div>
+  <form class="form" method="post" action="?a=account">
+    <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+    <input type="hidden" name="sub" value="create-key">
+    <div class="field-row">
+      <input type="text" id="label" name="label" placeholder="label, e.g. laptop" maxlength="60"
+             aria-label="New API key label">
+      <button type="submit" class="primary">Create API key</button>
+    </div>
+    <p class="field-hint desc">The key is shown once, right after you create it. Use it as the password for <code>git push</code> over HTTPS.</p>
+  </form>
+</div>
diff --git a/views/admin.php b/views/admin.php
new file mode 100644
index 0000000..45f6cd2
--- /dev/null
+++ b/views/admin.php
@@ -0,0 +1,118 @@
+<?php
+/** @var array $repos @var array $users @var string $currentUsername
+ *  @var ?string $error @var ?string $success @var string $csrf */
+$warnIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<rect x="7.25" y="4" width="1.5" height="5.5" rx="0.75"></rect><circle cx="8" cy="11.75" r="0.9"></circle></svg>';
+$okIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<path d="M4.5 8.2 7 10.7l4.5-5" style="fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round"></path></svg>';
+$plusIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<rect x="7" y="2" width="2" height="12" rx="1"></rect><rect x="2" y="7" width="12" height="2" rx="1"></rect></svg>';
+$trashIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<rect x="6" y="1.5" width="4" height="2" rx="0.5"></rect><rect x="2.5" y="4" width="11" height="1.5" rx="0.5"></rect>'
+    . '<rect x="3.5" y="6.2" width="9" height="8.3" rx="1"></rect></svg>';
+// exact same path as the repo icon in partials/head.php / repo-index.php, reused here
+$repoIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"></path></svg>';
+$personIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<circle cx="8" cy="5" r="3"></circle><path d="M2 14.5c0-3.5 2.7-6 6-6s6 2.5 6 6" style="fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round"></path></svg>';
+?>
+<h1>Admin</h1>
+
+<?php if ($error !== null): ?>
+<div class="msg-error"><?= $warnIcon ?><div><?= h($error) ?></div></div>
+<?php endif; ?>
+<?php if ($success !== null): ?>
+<div class="msg-ok"><?= $okIcon ?><div><?= h($success) ?></div></div>
+<?php endif; ?>
+
+<div class="box">
+  <div class="box-header"><?= $repoIcon ?> Repositories</div>
+  <?php if (!$repos): ?>
+  <table><tr><td class="desc">No repositories.</td></tr></table>
+  <?php else: ?>
+  <table>
+    <thead><tr><th>Name</th><th>Description</th><th></th></tr></thead>
+    <?php foreach ($repos as $name => $meta): ?>
+    <?php $bareName = repo_display_name($name); // create_bare_repo()/delete_bare_repo() take the name WITHOUT .git ?>
+    <tr>
+      <td><a href="?r=<?= h($name) ?>"><?= h($bareName) ?></a></td>
+      <td class="desc"><?= h($meta['desc']) ?></td>
+      <td class="num">
+        <details class="row-actions">
+          <summary title="Delete <?= h($bareName) ?>" aria-label="Delete <?= h($bareName) ?>"><?= $trashIcon ?></summary>
+          <form class="confirm-delete" method="post" action="?a=admin">
+            <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+            <input type="hidden" name="sub" value="delete-repo">
+            <input type="hidden" name="name" value="<?= h($bareName) ?>">
+            <input type="text" name="confirm" placeholder="type &quot;<?= h($bareName) ?>&quot; to confirm" required>
+            <button type="submit" class="danger">Delete</button>
+          </form>
+        </details>
+      </td>
+    </tr>
+    <?php endforeach; ?>
+  </table>
+  <?php endif; ?>
+</div>
+
+<div class="box">
+  <div class="box-header"><?= $plusIcon ?> New repository</div>
+  <form class="form" method="post" action="?a=admin">
+    <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+    <input type="hidden" name="sub" value="create-repo">
+    <div class="field-row">
+      <input type="text" id="repo-name" name="name" placeholder="repository name" pattern="[A-Za-z0-9._-]+" required
+             aria-label="New repository name">
+      <button type="submit" class="primary">Create repo</button>
+    </div>
+    <p class="field-hint desc">Letters, digits, dot, dash and underscore. Created bare, and push-ready over SSH and HTTPS.</p>
+  </form>
+</div>
+
+<div class="box">
+  <div class="box-header"><?= $personIcon ?> Accounts</div>
+  <table>
+    <thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead>
+    <?php foreach ($users as $name => $user): ?>
+    <tr>
+      <td><?= h($name) ?></td>
+      <td class="desc"><?= $user['is_admin'] ? 'admin' : 'user' ?></td>
+      <td class="desc"><?= h(format_relative_time($user['created'])) ?></td>
+      <td class="num">
+        <?php if ($name !== $currentUsername): ?>
+        <details class="row-actions">
+          <summary title="Delete <?= h($name) ?>" aria-label="Delete <?= h($name) ?>"><?= $trashIcon ?></summary>
+          <form class="confirm-delete" method="post" action="?a=admin">
+            <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+            <input type="hidden" name="sub" value="delete-user">
+            <input type="hidden" name="username" value="<?= h($name) ?>">
+            <button type="submit" class="danger">Delete account &amp; revoke its keys</button>
+          </form>
+        </details>
+        <?php endif; ?>
+      </td>
+    </tr>
+    <?php endforeach; ?>
+  </table>
+</div>
+
+<div class="box">
+  <div class="box-header"><?= $plusIcon ?> New account</div>
+  <form class="form" method="post" action="?a=admin">
+    <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+    <input type="hidden" name="sub" value="add-user">
+    <div class="form-row">
+      <label for="new-username">Username</label>
+      <input type="text" id="new-username" name="username" placeholder="username" autocomplete="off" required>
+    </div>
+    <div class="form-row">
+      <label for="new-password">Password</label>
+      <input type="password" id="new-password" name="password" placeholder="at least 8 characters" autocomplete="new-password" required minlength="8">
+    </div>
+    <div class="form-row check-row">
+      <label for="new-is-admin"><input type="checkbox" id="new-is-admin" name="is_admin"> Can manage repos and accounts</label>
+    </div>
+    <button type="submit" class="primary">Create account</button>
+  </form>
+</div>
diff --git a/views/login.php b/views/login.php
new file mode 100644
index 0000000..5b9a387
--- /dev/null
+++ b/views/login.php
@@ -0,0 +1,31 @@
+<?php
+/** @var ?string $error @var string $csrf @var string $next */
+$warnIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<rect x="7.25" y="4" width="1.5" height="5.5" rx="0.75"></rect><circle cx="8" cy="11.75" r="0.9"></circle></svg>';
+?>
+<div class="auth-page">
+  <h1>Log in</h1>
+
+  <?php if ($error !== null): ?>
+  <div class="msg-error"><?= $warnIcon ?><div><?= h($error) ?></div></div>
+  <?php endif; ?>
+
+  <div class="box">
+    <form class="form" method="post" action="?a=login">
+      <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+      <?php if ($next !== ''): ?>
+      <input type="hidden" name="next" value="<?= h($next) ?>">
+      <?php endif; ?>
+      <div class="form-row">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" autocomplete="username" required autofocus>
+      </div>
+      <div class="form-row">
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" autocomplete="current-password" required>
+      </div>
+      <button type="submit" class="primary">Log in</button>
+    </form>
+  </div>
+</div>
diff --git a/views/partials/foot.php b/views/partials/foot.php
index b306b0c..7e3ee07 100644
--- a/views/partials/foot.php
+++ b/views/partials/foot.php
@@ -2,7 +2,7 @@
 </div></main>
 <footer>
   <div class="inner">
-    <span>foxygit · read-only · push/pull lives in your bare repos over SSH</span>
+    <span>foxygit · clone anonymously · push over SSH or HTTPS with an API key</span>
     <?php render('partials/theme-switcher', ['theme' => $theme, 'themes' => $themes]); ?>
   </div>
 </footer>
diff --git a/views/partials/head.php b/views/partials/head.php
index d8ae38f..7fa0b4b 100644
--- a/views/partials/head.php
+++ b/views/partials/head.php
@@ -14,8 +14,8 @@ $description = $description ?? '';
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title><?= h($title) ?></title>
-<link rel="stylesheet" href="themes/<?= h($theme) ?>.css">
-<link rel="stylesheet" href="assets/base.css">
+<link rel="stylesheet" href="<?= h(asset_url('themes/' . $theme . '.css')) ?>">
+<link rel="stylesheet" href="<?= h(asset_url('assets/base.css')) ?>">
 </head><body>
 <header>
   <div class="inner">
@@ -44,6 +44,30 @@ $description = $description ?? '';
     <a class="icon-btn" href="?a=help" title="Help" aria-label="Help">
       <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4c.73 0 1.334.192 1.752.545.416.353.65.822.667 1.328.033.988-.65 1.606-1.166 2.02l-.147.117c-.363.288-.573.485-.573.86v.007a.75.75 0 0 1-1.5-.037c.03-.673.457-1.109.877-1.454l.087-.07c.529-.421.867-.723.85-1.132-.007-.174-.09-.375-.323-.564-.234-.19-.575-.32-1.024-.32-.44 0-.77.12-.996.26a1.28 1.28 0 0 0-.481.523ZM8 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>
     </a>
+    <?php if (is_logged_in()): ?>
+      <span class="nav-auth">
+        <a class="nav-btn" href="?a=account" title="Your account and API keys">
+          <svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="4.5" r="2.75"></circle><path d="M8 8.75c-3.02 0-5.5 2.02-5.5 4.5 0 .41.34.75.75.75h9.5c.41 0 .75-.34.75-.75 0-2.48-2.48-4.5-5.5-4.5Z"></path></svg>
+          <span class="label"><?= h((string) current_username()) ?></span>
+        </a>
+        <?php if (is_admin()): ?>
+          <span class="nav-sep"></span>
+          <a class="nav-btn" href="?a=admin" title="Manage repos and accounts">Admin</a>
+        <?php endif; ?>
+        <span class="nav-sep"></span>
+        <form method="post" action="?a=logout">
+          <input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>">
+          <button type="submit" class="nav-btn" title="Log out">
+            <svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 2.25a.75.75 0 0 0-.75-.75h-2A1.75 1.75 0 0 0 2 3.25v9.5c0 .966.784 1.75 1.75 1.75h2a.75.75 0 0 0 0-1.5h-2a.25.25 0 0 1-.25-.25v-9.5A.25.25 0 0 1 3.75 3h2a.75.75 0 0 0 .75-.75Z"></path><path d="M10.44 4.72a.75.75 0 0 1 1.06 0l2.75 2.75a.75.75 0 0 1 0 1.06l-2.75 2.75a.75.75 0 1 1-1.06-1.06l1.47-1.47H6.75a.75.75 0 0 1 0-1.5h5.16l-1.47-1.47a.75.75 0 0 1 0-1.06Z"></path></svg>
+          <span class="label">Log out</span>
+          </button>
+        </form>
+      </span>
+    <?php else: ?>
+      <span class="nav-auth">
+        <a class="nav-btn" href="?a=login">Log in</a>
+      </span>
+    <?php endif; ?>
   </div>
 </header>
 <?php if ($tab !== null && $repoName !== null) render('partials/tabs', ['repoName' => $repoName, 'cur' => $tab]); ?>
diff --git a/views/setup.php b/views/setup.php
new file mode 100644
index 0000000..6b278e4
--- /dev/null
+++ b/views/setup.php
@@ -0,0 +1,37 @@
+<?php
+/** @var ?string $error @var string $csrf
+ *  Reused across setup/login/account/admin: a ring path lifted verbatim from
+ *  the help icon in partials/head.php (so it's already proven to render),
+ *  plus a plain filled bar+dot for the "!" — no strokes, so nothing here can
+ *  be knocked out by .icon's fill:currentColor. */
+$warnIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">'
+    . '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path>'
+    . '<rect x="7.25" y="4" width="1.5" height="5.5" rx="0.75"></rect><circle cx="8" cy="11.75" r="0.9"></circle></svg>';
+?>
+<div class="auth-page">
+  <h1>Set up foxygit</h1>
+  <p class="lede desc">No accounts exist yet. Create the first one — it's made an admin automatically.</p>
+
+  <?php if ($error !== null): ?>
+  <div class="msg-error"><?= $warnIcon ?><div><?= h($error) ?></div></div>
+  <?php endif; ?>
+
+  <div class="box">
+    <form class="form" method="post" action="?a=setup">
+      <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
+      <div class="form-row">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" autocomplete="username" required autofocus>
+      </div>
+      <div class="form-row">
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" autocomplete="new-password" required minlength="8">
+      </div>
+      <div class="form-row">
+        <label for="confirm">Confirm password</label>
+        <input type="password" id="confirm" name="confirm" autocomplete="new-password" required minlength="8">
+      </div>
+      <button type="submit" class="primary">Create admin account</button>
+    </form>
+  </div>
+</div>