mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
polish(hub): round-3 — mobile actions reachable, admin gating settings UI
Fixes the two round-2 majors. User: on mobile the per-file actions (Share/History/Upload/Download) are now icon buttons in the header — reachable again (round 2 wrongly hid them). Search tolerates simple English plurals (ideas→idea), the no-matches state states what search covers, the sidebar shows the hub brand instead of the raw device name, and history-row downloads carry a download attribute. Logged-out loads redirect to sign-in from /api/config instead of firing 401-ing API calls. Admin: a hub-admin "Signup & access" settings screen (⚙ Admin in the sidebar) toggles email verification and admin approval live — persisted to auth.json and surviving restart — while the domain allowlist and admin list are shown read-only (deliberately server-config-owned so a browser session can't widen access). Pending approvals live on the same screen. Config toggles are now *bool so an explicit config value pins the setting each boot, else the UI-saved policy stands. Invite revoke confirms; role change re-renders the panel. Tests: policy persistence + reload, policy API admin-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Fable 5
parent
e022998c9f
commit
40fcda7cf7
+11
-4
@@ -38,8 +38,8 @@ type webConfig struct {
|
||||
AllowSignup *bool `json:"allow_signup,omitempty"` // default true
|
||||
UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"])
|
||||
RequireVerification bool `json:"require_verification,omitempty"` // new accounts verify email before activation
|
||||
RequireApproval bool `json:"require_approval,omitempty"` // new accounts await admin approval
|
||||
RequireVerification *bool `json:"require_verification,omitempty"` // new accounts verify email before activation
|
||||
RequireApproval *bool `json:"require_approval,omitempty"` // new accounts await admin approval
|
||||
Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares)
|
||||
Brand string `json:"brand,omitempty"` // name shown on the sign-in page
|
||||
SMTP *struct {
|
||||
@@ -239,8 +239,15 @@ credentials); otherwise it is relayed through this server.`,
|
||||
}
|
||||
if cfg.Auth != nil {
|
||||
auth.AllowedDomains = cfg.Auth.AllowedDomains
|
||||
auth.RequireVerification = cfg.Auth.RequireVerification
|
||||
auth.RequireApproval = cfg.Auth.RequireApproval
|
||||
// Toggles: an explicit config value pins the setting each
|
||||
// boot; otherwise the UI-saved policy (loaded from auth.json)
|
||||
// stands.
|
||||
if cfg.Auth.RequireVerification != nil {
|
||||
auth.RequireVerification = *cfg.Auth.RequireVerification
|
||||
}
|
||||
if cfg.Auth.RequireApproval != nil {
|
||||
auth.RequireApproval = *cfg.Auth.RequireApproval
|
||||
}
|
||||
auth.Brand = cfg.Auth.Brand
|
||||
if len(cfg.Auth.Admins) > 0 {
|
||||
auth.Admins = make(map[string]bool, len(cfg.Auth.Admins))
|
||||
|
||||
@@ -124,6 +124,48 @@ func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"pending": a.PendingUsers()})
|
||||
}
|
||||
|
||||
// handleAdminPolicy reads (GET) or updates (POST) the signup/access policy.
|
||||
// Domains and the admin list are reported read-only — they're server-config
|
||||
// owned so a browser session can't widen access — while verification and
|
||||
// approval toggles can be flipped live and are persisted.
|
||||
func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requestUser(r).Admin {
|
||||
http.Error(w, "hub admins only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a := s.builtinAuth()
|
||||
if a == nil {
|
||||
http.Error(w, "policy is not supported by this auth provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
RequireVerification bool `json:"require_verification"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
admins := make([]string, 0, len(a.Admins))
|
||||
for e := range a.Admins {
|
||||
admins = append(admins, e)
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"require_verification": a.RequireVerification,
|
||||
"require_approval": a.RequireApproval,
|
||||
"allow_signup": a.AllowSignup,
|
||||
"allowed_domains": a.AllowedDomains, // read-only (server config)
|
||||
"admins": admins, // read-only (server config)
|
||||
"mailer": a.Mail != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminApprove activates a pending account. Hub admins only.
|
||||
func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requestUser(r).Admin {
|
||||
|
||||
@@ -100,6 +100,7 @@ func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth,
|
||||
var file struct {
|
||||
Users []*authUser `json:"users"`
|
||||
Tokens []authToken `json:"tokens"`
|
||||
Policy *authPolicy `json:"policy,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
@@ -110,14 +111,40 @@ func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth,
|
||||
for _, t := range file.Tokens {
|
||||
a.tokens[t.Hash] = t
|
||||
}
|
||||
// A UI-saved policy is the persisted operational default; the server
|
||||
// config can still override it at startup (see web.go), so a sysadmin who
|
||||
// pins a value in the config file always wins over a browser toggle.
|
||||
if file.Policy != nil {
|
||||
a.RequireVerification = file.Policy.RequireVerification
|
||||
a.RequireApproval = file.Policy.RequireApproval
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// authPolicy is the UI-tunable slice of gating (persisted in auth.json).
|
||||
// Domain allowlist and the admin list are intentionally NOT here — they are
|
||||
// security-critical identity config owned by whoever controls the server,
|
||||
// not something a browser session should be able to widen.
|
||||
type authPolicy struct {
|
||||
RequireVerification bool `json:"require_verification"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
}
|
||||
|
||||
// SetPolicy updates the tunable gating toggles and persists them.
|
||||
func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.RequireVerification = requireVerification
|
||||
a.RequireApproval = requireApproval
|
||||
return a.save()
|
||||
}
|
||||
|
||||
// save persists users and tokens. Callers hold mu.
|
||||
func (a *BuiltinAuth) save() error {
|
||||
var file struct {
|
||||
Users []*authUser `json:"users"`
|
||||
Tokens []authToken `json:"tokens"`
|
||||
Policy *authPolicy `json:"policy,omitempty"`
|
||||
}
|
||||
for _, u := range a.users {
|
||||
file.Users = append(file.Users, u)
|
||||
@@ -125,6 +152,7 @@ func (a *BuiltinAuth) save() error {
|
||||
for _, t := range a.tokens {
|
||||
file.Tokens = append(file.Tokens, t)
|
||||
}
|
||||
file.Policy = &authPolicy{RequireVerification: a.RequireVerification, RequireApproval: a.RequireApproval}
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -107,6 +107,46 @@ func TestSignupPageVerificationFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The policy toggles persist to auth.json and reload; the domain allowlist
|
||||
// and admin list are reported but not mutated by the policy API.
|
||||
func TestPolicyPersistence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "auth.json")
|
||||
a, _ := OpenBuiltinAuth(path, true, nil)
|
||||
a.AllowedDomains = []string{"x.io"}
|
||||
a.Admins = map[string]bool{"admin@x.io": true}
|
||||
if err := a.SetPolicy(true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// reload: toggles survive, and initialStatus reflects them
|
||||
a2, _ := OpenBuiltinAuth(path, true, nil)
|
||||
if !a2.RequireVerification || !a2.RequireApproval {
|
||||
t.Fatal("policy toggles did not persist across reload")
|
||||
}
|
||||
if a2.initialStatus() != statusUnverified {
|
||||
t.Fatalf("initialStatus = %q", a2.initialStatus())
|
||||
}
|
||||
}
|
||||
|
||||
// A hub admin can read and flip the policy over HTTP; a non-admin cannot.
|
||||
func TestPolicyAPIAdminOnly(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
auth := gatedAuth(t, func(a *BuiltinAuth) { a.Admins = map[string]bool{"boss@x.io": true} })
|
||||
srv.Auth = auth
|
||||
h := srv.Handler()
|
||||
boss := signupAndSession(t, h, "boss@x.io", "Boss", "password1")
|
||||
pleb := signupAndSession(t, h, "pleb@x.io", "Pleb", "password1")
|
||||
|
||||
if rec := doAs(t, h, "GET", "/api/admin/policy", nil, pleb); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin policy read: %d", rec.Code)
|
||||
}
|
||||
if rec := doAs(t, h, "POST", "/api/admin/policy", map[string]bool{"require_approval": true}, boss); rec.Code != 200 {
|
||||
t.Fatalf("admin policy write: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if !auth.RequireApproval {
|
||||
t.Fatal("policy POST did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRateLimit(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
srv.Auth = gatedAuth(t, nil)
|
||||
|
||||
@@ -340,6 +340,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectRename)
|
||||
mux.HandleFunc("DELETE /api/projects/{project}", s.handleProjectDelete)
|
||||
|
||||
mux.HandleFunc("GET /api/admin/policy", s.handleAdminPolicy)
|
||||
mux.HandleFunc("POST /api/admin/policy", s.handleAdminPolicy)
|
||||
mux.HandleFunc("GET /api/admin/pending", s.handleAdminPending)
|
||||
mux.HandleFunc("POST /api/admin/pending/{id}/approve", s.handleAdminApprove)
|
||||
mux.HandleFunc("POST /api/admin/pending/{id}/deny", s.handleAdminDeny)
|
||||
|
||||
@@ -52,6 +52,13 @@ async function boot() {
|
||||
serverConfig = await getJSON("api/config");
|
||||
} catch { /* non-fatal */ }
|
||||
document.title = serverConfig.brand || serverConfig.volume || "BearDrive";
|
||||
// If auth is on and we're not signed in, go straight to the login page
|
||||
// rather than firing authed API calls that 401 (noisy console, and the
|
||||
// redirect happens anyway). /api/config reports `me` only when signed in.
|
||||
if (serverConfig.auth && serverConfig.auth.enabled && !serverConfig.me) {
|
||||
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash);
|
||||
return;
|
||||
}
|
||||
if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false;
|
||||
if (serverConfig.mode === "hub") {
|
||||
await acceptInviteFromHash();
|
||||
@@ -65,7 +72,7 @@ async function boot() {
|
||||
|| (joinedOrgId && projects.find((x) => x.org === joinedOrgId))
|
||||
|| projects[0];
|
||||
if (proj) selectProject(proj, path);
|
||||
else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
|
||||
else { $("vault-name").textContent = serverConfig.brand || serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
|
||||
setInterval(loadProjects, 30000); // pick up new projects
|
||||
} else {
|
||||
$("vault-name").textContent = serverConfig.volume || "BearDrive";
|
||||
@@ -283,7 +290,7 @@ async function showOrgAdmin(org) {
|
||||
if (m.role === r) o.selected = true; sel.appendChild(o);
|
||||
}
|
||||
sel.onchange = async () => {
|
||||
try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); }
|
||||
try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); }
|
||||
catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); }
|
||||
};
|
||||
row.appendChild(sel);
|
||||
@@ -357,6 +364,7 @@ async function showOrgAdmin(org) {
|
||||
el(row, "span", "ai-tag", meta);
|
||||
const rv = el(row, "button", "ai-del", "Revoke");
|
||||
rv.onclick = async () => {
|
||||
if (!confirm("Revoke this invite link? Anyone still holding it won't be able to join.")) return;
|
||||
try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
};
|
||||
@@ -424,12 +432,81 @@ async function updateAdminBar() {
|
||||
if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; }
|
||||
let pending = [];
|
||||
try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { }
|
||||
if (!pending.length) { bar.hidden = true; return; }
|
||||
bar.hidden = false;
|
||||
bar.innerHTML = "";
|
||||
const b = el(bar, "button", "adminbar-btn");
|
||||
b.textContent = "⚑ " + pending.length + " pending signup" + (pending.length > 1 ? "s" : "");
|
||||
b.onclick = () => showPending();
|
||||
bar.textContent = pending.length ? "⚑ Admin · " + pending.length : "⚙ Admin";
|
||||
bar.title = "Hub administration — signup policy" + (pending.length ? " and pending approvals" : "");
|
||||
bar.onclick = () => showHubSettings();
|
||||
}
|
||||
|
||||
/* Hub-admin settings: signup/access policy. Verification & approval are
|
||||
live toggles; the domain allowlist and admin list are shown read-only
|
||||
(they're server-config owned, deliberately not browser-editable). */
|
||||
async function showHubSettings() {
|
||||
let pol = {};
|
||||
try { pol = await getJSON("api/admin/policy"); } catch (e) { toast(e.message, true); return; }
|
||||
currentPath = null; markActive(); closeSidebarOnMobile();
|
||||
$("crumb").textContent = "Signup & access";
|
||||
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true;
|
||||
const box = $("content");
|
||||
box.innerHTML = `<div class="admin"><h1>Signup & access</h1>
|
||||
<p class="admin-sub">Who can create an account on this hub, and how new accounts are vetted.</p></div>`;
|
||||
const panel = box.querySelector(".admin");
|
||||
|
||||
el(panel, "h3", null, "New-account vetting");
|
||||
const toggles = el(panel, "div", "admin-list");
|
||||
const mkToggle = (label, desc, key, on) => {
|
||||
const row = el(toggles, "label", "admin-item toggle");
|
||||
const left = el(row, "span", "ai-main");
|
||||
el(left, "div", "tg-label", label);
|
||||
el(left, "div", "tg-desc", desc);
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox"; cb.checked = !!on; cb.dataset.key = key;
|
||||
row.appendChild(cb);
|
||||
return cb;
|
||||
};
|
||||
const ver = mkToggle("Require email verification",
|
||||
pol.mailer ? "New accounts must click an emailed link before they can sign in." :
|
||||
"New accounts must verify via a link (no mailer configured — the link is written to the server log).",
|
||||
"require_verification", pol.require_verification);
|
||||
const app = mkToggle("Require admin approval",
|
||||
"New accounts wait for a hub admin to approve them before they gain access.",
|
||||
"require_approval", pol.require_approval);
|
||||
const save = el(panel, "button", "pbtn", "Save policy");
|
||||
save.style.marginTop = "14px";
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await postJSON("api/admin/policy", { require_verification: ver.checked, require_approval: app.checked });
|
||||
toast("Signup policy saved.");
|
||||
} catch (e) { toast(e.message, true); }
|
||||
};
|
||||
|
||||
el(panel, "h3", null, "Who can sign up");
|
||||
const info = el(panel, "div", "admin-list");
|
||||
const dom = el(info, "div", "admin-item");
|
||||
el(dom, "span", "ai-main", "Allowed email domains");
|
||||
el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any (open signup)");
|
||||
const sg = el(info, "div", "admin-item");
|
||||
el(sg, "span", "ai-main", "Self-signup");
|
||||
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "closed");
|
||||
const ad = el(info, "div", "admin-item");
|
||||
el(ad, "span", "ai-main", "Hub admins");
|
||||
el(ad, "span", "ai-tag", (pol.admins && pol.admins.length) ? pol.admins.join(", ") : "none");
|
||||
el(panel, "p", "admin-sub", "Domains and admins are set in the server config file (they can't be widened from the browser).");
|
||||
|
||||
// Pending approvals live here too, so this is the single admin home.
|
||||
el(panel, "h3", null, "Pending signups");
|
||||
const plist = el(panel, "div", "admin-list");
|
||||
let pending = [];
|
||||
try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { }
|
||||
if (!pending.length) el(plist, "div", "admin-empty", "No one is waiting for approval.");
|
||||
for (const u of pending) {
|
||||
const row = el(plist, "div", "admin-item");
|
||||
el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email);
|
||||
const ok = el(row, "button", "pbtn", "Approve");
|
||||
ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
|
||||
const no = el(row, "button", "ai-del", "Deny");
|
||||
no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
|
||||
}
|
||||
}
|
||||
|
||||
async function showPending() {
|
||||
@@ -749,6 +826,7 @@ async function showHistory(q) {
|
||||
const dl = document.createElement("a");
|
||||
dl.textContent = "download";
|
||||
dl.href = view.href + "&download=1";
|
||||
dl.setAttribute("download", e.path.split("/").pop());
|
||||
row.querySelector(".hact").append(view, " ", dl);
|
||||
}
|
||||
const p = e.path;
|
||||
@@ -963,10 +1041,24 @@ function paletteCandidates() {
|
||||
return items;
|
||||
}
|
||||
|
||||
/* Match a query against a label, tolerating a simple English plural so
|
||||
"ideas" still finds idea.md. Tries the raw query first, then a lightly
|
||||
de-pluralized form (…ies→…y, …es→…, …s→…). */
|
||||
function fuzzyStemmed(query, label) {
|
||||
const m = fuzzy(query, label);
|
||||
if (m) return m;
|
||||
const q = query.toLowerCase();
|
||||
let stem = null;
|
||||
if (q.length > 3 && q.endsWith("ies")) stem = q.slice(0, -3) + "y";
|
||||
else if (q.length > 3 && q.endsWith("es")) stem = q.slice(0, -2);
|
||||
else if (q.length > 2 && q.endsWith("s")) stem = q.slice(0, -1);
|
||||
return stem ? fuzzy(stem, label) : null;
|
||||
}
|
||||
|
||||
function buildPaletteItems(query) {
|
||||
const scored = [];
|
||||
for (const c of paletteCandidates()) {
|
||||
const m = fuzzy(query, c.label);
|
||||
const m = fuzzyStemmed(query, c.label);
|
||||
if (m) scored.push({ ...c, score: m.score, hits: m.hits });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
@@ -981,7 +1073,7 @@ function renderPalette() {
|
||||
if (paletteItems.length === 0) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "pempty";
|
||||
li.textContent = "No matches";
|
||||
li.textContent = "No matches — search covers file names, projects, and actions";
|
||||
ul.appendChild(li);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,11 @@ body {
|
||||
.ai-del { color: #ff9b91; }
|
||||
.ai-del:hover { background: #4a2420; color: #ff7b72; }
|
||||
.admin-empty { padding: 14px 12px; color: var(--text-faint); font-size: 13px; }
|
||||
.admin-sub { color: var(--text-dim); font-size: 13.5px; margin: -8px 0 4px; }
|
||||
.admin-item.toggle { cursor: pointer; }
|
||||
.admin-item.toggle input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
.tg-label { font-size: 13.5px; color: var(--text); }
|
||||
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 2px; }
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 150; padding: 20px; }
|
||||
@@ -296,12 +301,14 @@ body {
|
||||
#topbar { padding: 8px 14px; gap: 8px; }
|
||||
#crumb { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#search-btn kbd { display: none; }
|
||||
#search-btn { font-size: 0; gap: 0; padding: 4px 9px; }
|
||||
#search-btn::before { content: "🔍"; font-size: 14px; }
|
||||
/* The per-file action buttons overflow a phone header; on mobile they
|
||||
live in the ⌘K palette instead (which offers all of them), so the bar
|
||||
stays to Menu · crumb · Search. */
|
||||
#share-btn, #history-btn, #upload-btn, #download { display: none !important; }
|
||||
/* On a phone the header shrinks every control to an icon so all the
|
||||
per-file actions stay reachable (the R2 mistake was hiding them). */
|
||||
#topbar .btn { font-size: 0; gap: 0; padding: 5px 8px; }
|
||||
#search-btn::before { content: "🔍"; font-size: 15px; }
|
||||
#share-btn::before { content: "↗"; font-size: 15px; }
|
||||
#history-btn::before { content: "🕘"; font-size: 14px; }
|
||||
#upload-btn::before { content: "⬆"; font-size: 14px; }
|
||||
#download::before { content: "⬇"; font-size: 14px; }
|
||||
#meta { display: none; }
|
||||
.markdown, .admin, .onboard, .history { max-width: 100%; }
|
||||
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user