Add admin governance roles and UI

This commit is contained in:
thePR0M3TH3AN committed 2025-09-28 18:38:10 -04:00
1 parent 2eb00740fd
commit 875cee4428
6 files changed
+1086 -227

No files matched your search

+145
View File
@@ -88,6 +88,15 @@
>
Blocked
</button>
<button
id="profileNavAdmin"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 hidden"
aria-controls="profilePaneAdmin"
aria-selected="false"
>
Admin (Moderation)
</button>
</nav>
<!-- Right content panes -->
@@ -176,6 +185,142 @@
</div>
<ul id="blockedList" class="space-y-3"></ul>
</section>
<section
id="profilePaneAdmin"
class="profile-pane hidden space-y-6"
role="tabpanel"
aria-labelledby="profileNavAdmin"
>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-white">Platform moderation</h3>
<p class="text-sm text-gray-400">
Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
</p>
</div>
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Moderators</h4>
<p class="text-xs uppercase tracking-wide text-gray-400">
Super Admin only
</p>
</div>
<p class="text-sm text-gray-400">
Moderators can edit the whitelist and blacklist but cannot manage other moderators or whitelist mode.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminModeratorInput">
<span class="mb-2 block font-medium">Moderator npub</span>
<input
id="adminModeratorInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddModeratorBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add moderator
</button>
</div>
<div
id="adminModeratorsEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No moderators configured yet.
</div>
<ul id="adminModeratorList" class="space-y-3"></ul>
</div>
<div id="adminWhitelistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Whitelist</h4>
<label
id="adminWhitelistModeWrapper"
class="flex items-center gap-2 text-xs font-medium text-gray-300 hidden"
for="adminWhitelistModeToggle"
>
<input
id="adminWhitelistModeToggle"
type="checkbox"
class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-400"
/>
<span>Enable whitelist-only mode</span>
</label>
</div>
<p class="text-sm text-gray-400">
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminWhitelistInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="adminWhitelistInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddWhitelistBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add to whitelist
</button>
</div>
<div
id="adminWhitelistEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No whitelisted creators yet.
</div>
<ul id="adminWhitelistList" class="space-y-3"></ul>
</div>
<div id="adminBlacklistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Blacklist</h4>
</div>
<p class="text-sm text-gray-400">
Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminBlacklistInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="adminBlacklistInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddBlacklistBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add to blacklist
</button>
</div>
<div
id="adminBlacklistEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No blacklisted creators yet.
</div>
<ul id="adminBlacklistList" class="space-y-3"></ul>
</div>
</section>
</div>
</div>
</div>
+296 -190
View File
@@ -1,228 +1,334 @@
// js/accessControl.js
import { isDevMode, isWhitelistEnabled } from "./config.js";
import { initialWhitelist, initialBlacklist } from "./lists.js";
import {
ADMIN_EDITORS_NPUBS,
ADMIN_SUPER_NPUB,
getWhitelistMode,
setWhitelistMode as persistWhitelistMode,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
} from "./config.js";
import {
ADMIN_INITIAL_BLACKLIST,
ADMIN_INITIAL_WHITELIST,
} from "./lists.js";
class AccessControl {
constructor() {
// Debug logging for initialization
console.log("DEBUG: AccessControl constructor called");
const ADMIN_EDITORS_KEY = "bitvid_admin_editors";
const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist";
const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist";
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
const LEGACY_WHITELIST_KEY = "bitvid_whitelist";
const LEGACY_BLACKLIST_KEY = "bitvid_blacklist";
if (storedWhitelist !== null) {
this.whitelist = new Set(storedWhitelist);
} else {
this.whitelist = new Set(initialWhitelist);
this.saveWhitelist();
if (whitelistStatus && whitelistStatus !== "missing") {
console.warn(
`Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
);
}
}
if (storedBlacklist !== null) {
this.blacklist = new Set(storedBlacklist);
} else {
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
this.saveBlacklist();
if (blacklistStatus && blacklistStatus !== "missing") {
console.warn(
`Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
);
}
}
// Debug the sets
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
}
// Rest of the class remains the same...
loadWhitelist() {
try {
const stored = localStorage.getItem("bitvid_whitelist");
if (!stored) {
if (isDevMode) console.log("No stored whitelist found in localStorage.");
return { data: null, status: "missing" };
}
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "whitelist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading whitelist, using defaults:", error);
return { data: null, status: "error" };
}
}
loadBlacklist() {
try {
const stored = localStorage.getItem("bitvid_blacklist");
if (!stored) {
if (isDevMode) console.log("No stored blacklist found in localStorage.");
return { data: null, status: "missing" };
}
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "blacklist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading blacklist, using defaults:", error);
return { data: null, status: "error" };
}
}
sanitizeList(list, listName) {
if (!Array.isArray(list)) {
console.warn(
`Stored ${listName} is not an array. Received:`,
list
);
function loadJSONList(key) {
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (error) {
console.warn(`Failed to parse list for ${key}:`, error);
return null;
}
}
const sanitized = [];
let invalidEntries = 0;
function saveJSONList(key, values) {
try {
localStorage.setItem(key, JSON.stringify(values));
} catch (error) {
console.warn(`Failed to persist list for ${key}:`, error);
}
}
list.forEach((value) => {
if (typeof value !== "string") {
invalidEntries += 1;
return;
function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
function dedupeNpubs(values) {
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
return Array.from(
normalized.reduce((set, npub) => {
if (npub) {
set.add(npub);
}
return set;
}, new Set())
);
}
const trimmed = value.trim();
if (trimmed) {
sanitized.push(trimmed);
} else {
invalidEntries += 1;
}
});
if (invalidEntries > 0) {
console.warn(
`Stored ${listName} contained ${invalidEntries} invalid entr${
invalidEntries === 1 ? "y" : "ies"
}. Sanitized list:`,
sanitized
);
}
function migrateLegacyList(targetKey, legacyKey, fallback) {
const stored = loadJSONList(targetKey);
if (stored !== null) {
return dedupeNpubs(stored);
}
const legacy = legacyKey ? loadJSONList(legacyKey) : null;
if (legacy !== null) {
const sanitized = dedupeNpubs(legacy);
saveJSONList(targetKey, sanitized);
return sanitized;
}
saveWhitelist() {
try {
localStorage.setItem(
"bitvid_whitelist",
JSON.stringify([...this.whitelist])
);
} catch (error) {
console.error("Error saving whitelist:", error);
}
const sanitizedFallback = dedupeNpubs(fallback);
if (sanitizedFallback.length) {
saveJSONList(targetKey, sanitizedFallback);
}
return sanitizedFallback;
}
function isValidNpub(npub) {
if (typeof npub !== "string") {
return false;
}
const trimmed = npub.trim();
if (!trimmed) {
return false;
}
saveBlacklist() {
try {
localStorage.setItem(
"bitvid_blacklist",
JSON.stringify([...this.blacklist])
);
} catch (error) {
console.error("Error saving blacklist:", error);
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
return decoded?.type === "npub";
} catch (error) {
return false;
}
}
class AccessControl {
constructor() {
this.editors = new Set();
this.whitelist = new Set();
this.blacklist = new Set();
this.whitelistEnabled = getWhitelistMode();
this.refresh();
}
addToWhitelist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.whitelist.add(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Added ${npub} to whitelist`);
refresh() {
this.editors = new Set(
dedupeNpubs([
...ADMIN_EDITORS_NPUBS,
...migrateLegacyList(ADMIN_EDITORS_KEY, null, []),
])
);
this.whitelist = new Set(
migrateLegacyList(ADMIN_WHITELIST_KEY, LEGACY_WHITELIST_KEY, [
...ADMIN_INITIAL_WHITELIST,
])
);
this.blacklist = new Set(
migrateLegacyList(ADMIN_BLACKLIST_KEY, LEGACY_BLACKLIST_KEY, [
...ADMIN_INITIAL_BLACKLIST,
])
);
this.whitelistEnabled = getWhitelistMode();
}
removeFromWhitelist(npub) {
this.whitelist.delete(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
whitelistMode() {
return this.whitelistEnabled;
}
addToBlacklist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.blacklist.add(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Added ${npub} to blacklist`);
isSuperAdmin(npub) {
const normalized = normalizeNpub(npub);
return normalized ? normalized === ADMIN_SUPER_NPUB : false;
}
removeFromBlacklist(npub) {
this.blacklist.delete(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
}
isWhitelisted(npub) {
const result = this.whitelist.has(npub);
if (isDevMode)
console.log(
`Checking if ${npub} is whitelisted:`,
result,
"Current whitelist:",
[...this.whitelist]
);
return result;
}
isBlacklisted(npub) {
return this.blacklist.has(npub);
}
canAccess(npub) {
if (this.isBlacklisted(npub)) {
isAdminEditor(npub) {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
return canAccess;
}
filterVideos(videos) {
return videos.filter((video) => {
try {
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
return !this.isBlacklisted(npub);
} catch (error) {
console.error("Error filtering video:", error);
return false;
}
});
}
isValidNpub(npub) {
try {
return npub.startsWith("npub1") && npub.length === 63;
} catch (error) {
return false;
if (this.isSuperAdmin(normalized)) {
return true;
}
return this.editors.has(normalized);
}
canEditAdminLists(npub) {
return this.isAdminEditor(npub);
}
getWhitelist() {
return [...this.whitelist];
return Array.from(this.whitelist);
}
getBlacklist() {
return [...this.blacklist];
return Array.from(this.blacklist);
}
getEditors() {
return Array.from(this.editors);
}
addModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(moderatorNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(moderatorNpub);
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
return { ok: false, error: "immutable" };
}
this.editors.add(normalized);
saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
this.refresh();
return { ok: true };
}
removeModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(moderatorNpub);
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
return { ok: false, error: "immutable" };
}
this.editors.delete(normalized);
saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
this.refresh();
return { ok: true };
}
addToWhitelist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(targetNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
this.blacklist.delete(normalized);
this.whitelist.add(normalized);
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
this.refresh();
return { ok: true };
}
removeFromWhitelist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
this.whitelist.delete(normalized);
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
this.refresh();
return { ok: true };
}
addToBlacklist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(targetNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
return { ok: false, error: "immutable" };
}
this.whitelist.delete(normalized);
this.blacklist.add(normalized);
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
this.refresh();
return { ok: true };
}
removeFromBlacklist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
this.blacklist.delete(normalized);
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
this.refresh();
return { ok: true };
}
setWhitelistMode(actorNpub, enabled) {
if (!this.isSuperAdmin(actorNpub)) {
return { ok: false, error: "forbidden" };
}
persistWhitelistMode(!!enabled);
this.whitelistEnabled = !!enabled;
return { ok: true };
}
isBlacklisted(npub) {
const normalized = normalizeNpub(npub);
return normalized ? this.blacklist.has(normalized) : false;
}
canAccess(candidate) {
let npub = "";
if (typeof candidate === "string") {
npub = candidate;
} else if (candidate && typeof candidate === "object") {
if (typeof candidate.npub === "string") {
npub = candidate.npub;
} else if (typeof candidate.pubkey === "string") {
try {
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
} catch (error) {
npub = candidate.pubkey;
}
}
}
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (this.blacklist.has(normalized)) {
return false;
}
if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
return false;
}
return true;
}
}
export const accessControl = new AccessControl();
export {
ADMIN_EDITORS_KEY,
ADMIN_WHITELIST_KEY,
ADMIN_BLACKLIST_KEY,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
normalizeNpub,
isValidNpub,
};
+573 -26
View File
@@ -3,8 +3,8 @@
import { loadView } from "./viewManager.js";
import { nostrClient } from "./nostr.js";
import { torrentClient } from "./webtorrent.js";
import { isDevMode } from "./config.js";
import { isWhitelistEnabled } from "./config.js";
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
import { accessControl } from "./accessControl.js";
import { safeDecodeMagnet } from "./magnetUtils.js";
import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js";
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
@@ -12,11 +12,7 @@ import { URL_FIRST_ENABLED } from "./constants.js";
import { trackVideoView } from "./analytics.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import {
initialWhitelist,
initialBlacklist,
initialEventBlacklist,
} from "./lists.js";
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
import {
loadR2Settings,
saveR2Settings,
@@ -503,11 +499,13 @@ class bitvidApp {
account: null,
relays: null,
blocked: null,
admin: null,
};
this.profilePaneElements = {
account: null,
relays: null,
blocked: null,
admin: null,
};
this.profileRelayList = null;
this.profileBlockedList = null;
@@ -515,10 +513,28 @@ class bitvidApp {
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
this.profileRestoreRelaysBtn = null;
this.adminModeratorInput = null;
this.adminAddModeratorBtn = null;
this.adminModeratorList = null;
this.adminModeratorsEmpty = null;
this.adminModeratorsSection = null;
this.adminWhitelistInput = null;
this.adminAddWhitelistBtn = null;
this.adminWhitelistList = null;
this.adminWhitelistEmpty = null;
this.adminWhitelistSection = null;
this.adminWhitelistModeToggle = null;
this.adminWhitelistModeWrapper = null;
this.adminBlacklistInput = null;
this.adminAddBlacklistBtn = null;
this.adminBlacklistList = null;
this.adminBlacklistEmpty = null;
this.adminBlacklistSection = null;
this.lastFocusedBeforeProfileModal = null;
this.boundProfileModalKeydown = null;
this.boundProfileModalFocusIn = null;
this.profileModalFocusables = [];
this.currentUserNpub = null;
// Upload modal elements
this.uploadButton = document.getElementById("uploadButton") || null;
@@ -655,7 +671,7 @@ class bitvidApp {
// Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties
this.blacklistedEventIds = new Set();
for (const neventStr of initialEventBlacklist) {
for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
// Skip any empty or obviously invalid strings
if (!neventStr || neventStr.trim().length < 8) {
continue;
@@ -3262,12 +3278,16 @@ class bitvidApp {
document.getElementById("profileNavRelays") || null;
this.profileNavButtons.blocked =
document.getElementById("profileNavBlocked") || null;
this.profileNavButtons.admin =
document.getElementById("profileNavAdmin") || null;
this.profilePaneElements.account =
document.getElementById("profilePaneAccount") || null;
this.profilePaneElements.relays =
document.getElementById("profilePaneRelays") || null;
this.profilePaneElements.blocked =
document.getElementById("profilePaneBlocked") || null;
this.profilePaneElements.admin =
document.getElementById("profilePaneAdmin") || null;
this.profileRelayList = document.getElementById("relayList") || null;
this.profileBlockedList = document.getElementById("blockedList") || null;
this.profileBlockedEmpty =
@@ -3276,6 +3296,40 @@ class bitvidApp {
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
this.profileRestoreRelaysBtn =
document.getElementById("restoreRelaysBtn") || null;
this.adminModeratorsSection =
document.getElementById("adminModeratorsSection") || null;
this.adminModeratorsEmpty =
document.getElementById("adminModeratorsEmpty") || null;
this.adminModeratorList =
document.getElementById("adminModeratorList") || null;
this.adminModeratorInput =
document.getElementById("adminModeratorInput") || null;
this.adminAddModeratorBtn =
document.getElementById("adminAddModeratorBtn") || null;
this.adminWhitelistSection =
document.getElementById("adminWhitelistSection") || null;
this.adminWhitelistEmpty =
document.getElementById("adminWhitelistEmpty") || null;
this.adminWhitelistList =
document.getElementById("adminWhitelistList") || null;
this.adminWhitelistInput =
document.getElementById("adminWhitelistInput") || null;
this.adminAddWhitelistBtn =
document.getElementById("adminAddWhitelistBtn") || null;
this.adminWhitelistModeToggle =
document.getElementById("adminWhitelistModeToggle") || null;
this.adminWhitelistModeWrapper =
document.getElementById("adminWhitelistModeWrapper") || null;
this.adminBlacklistSection =
document.getElementById("adminBlacklistSection") || null;
this.adminBlacklistEmpty =
document.getElementById("adminBlacklistEmpty") || null;
this.adminBlacklistList =
document.getElementById("adminBlacklistList") || null;
this.adminBlacklistInput =
document.getElementById("adminBlacklistInput") || null;
this.adminAddBlacklistBtn =
document.getElementById("adminAddBlacklistBtn") || null;
// Wire up
if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) {
@@ -3331,9 +3385,89 @@ class bitvidApp {
});
}
if (
this.adminAddModeratorBtn &&
this.adminAddModeratorBtn.dataset.bound !== "true"
) {
this.adminAddModeratorBtn.dataset.bound = "true";
this.adminAddModeratorBtn.addEventListener("click", () => {
this.handleAddModerator();
});
}
if (
this.adminModeratorInput &&
this.adminModeratorInput.dataset.bound !== "true"
) {
this.adminModeratorInput.dataset.bound = "true";
this.adminModeratorInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleAddModerator();
}
});
}
if (
this.adminAddWhitelistBtn &&
this.adminAddWhitelistBtn.dataset.bound !== "true"
) {
this.adminAddWhitelistBtn.dataset.bound = "true";
this.adminAddWhitelistBtn.addEventListener("click", () => {
this.handleAdminListMutation("whitelist", "add");
});
}
if (
this.adminWhitelistInput &&
this.adminWhitelistInput.dataset.bound !== "true"
) {
this.adminWhitelistInput.dataset.bound = "true";
this.adminWhitelistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleAdminListMutation("whitelist", "add");
}
});
}
if (
this.adminAddBlacklistBtn &&
this.adminAddBlacklistBtn.dataset.bound !== "true"
) {
this.adminAddBlacklistBtn.dataset.bound = "true";
this.adminAddBlacklistBtn.addEventListener("click", () => {
this.handleAdminListMutation("blacklist", "add");
});
}
if (
this.adminBlacklistInput &&
this.adminBlacklistInput.dataset.bound !== "true"
) {
this.adminBlacklistInput.dataset.bound = "true";
this.adminBlacklistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleAdminListMutation("blacklist", "add");
}
});
}
if (
this.adminWhitelistModeToggle &&
this.adminWhitelistModeToggle.dataset.bound !== "true"
) {
this.adminWhitelistModeToggle.dataset.bound = "true";
this.adminWhitelistModeToggle.addEventListener("change", (event) => {
const enabled = event.target instanceof HTMLInputElement
? event.target.checked
: false;
this.handleWhitelistModeToggle(enabled);
});
}
this.selectProfilePane("account");
this.populateProfileRelays();
this.populateBlockedList();
this.refreshAdminPaneState();
console.log("Profile modal initialization successful");
return true;
@@ -3346,12 +3480,27 @@ class bitvidApp {
selectProfilePane(name = "account") {
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
const target = ["account", "relays", "blocked"].includes(normalized)
const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
const pane = this.profilePaneElements[key];
if (!(pane instanceof HTMLElement)) {
return false;
}
const button = this.profileNavButtons[key];
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
return false;
}
return true;
});
const fallbackTarget = availableKeys.includes("account")
? "account"
: availableKeys[0] || "account";
const target = availableKeys.includes(normalized)
? normalized
: "account";
: fallbackTarget;
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
if (!pane) {
if (!(pane instanceof HTMLElement)) {
return;
}
const isActive = key === target;
@@ -3360,7 +3509,7 @@ class bitvidApp {
});
Object.entries(this.profileNavButtons).forEach(([key, button]) => {
if (!button) {
if (!(button instanceof HTMLElement)) {
return;
}
const isActive = key === target;
@@ -3407,6 +3556,8 @@ class bitvidApp {
return;
}
this.refreshAdminPaneState();
this.selectProfilePane("account");
this.populateProfileRelays();
this.populateBlockedList();
@@ -3670,6 +3821,384 @@ class bitvidApp {
});
}
refreshAdminPaneState() {
accessControl.refresh();
const adminNav = this.profileNavButtons.admin;
const adminPane = this.profilePaneElements.admin;
const actorNpub = this.getCurrentUserNpub();
const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub);
const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub);
if (adminNav instanceof HTMLElement) {
adminNav.classList.toggle("hidden", !canEdit);
if (!canEdit) {
adminNav.setAttribute("aria-selected", "false");
}
}
if (adminPane instanceof HTMLElement) {
adminPane.classList.toggle("hidden", !canEdit);
adminPane.setAttribute("aria-hidden", (!canEdit).toString());
}
if (!canEdit) {
this.clearAdminLists();
if (typeof actorNpub !== "string" || !actorNpub) {
this.currentUserNpub = null;
}
if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) {
this.selectProfilePane("account");
}
return;
}
if (this.adminModeratorsSection instanceof HTMLElement) {
this.adminModeratorsSection.classList.toggle("hidden", !isSuperAdmin);
this.adminModeratorsSection.setAttribute("aria-hidden", (!isSuperAdmin).toString());
}
if (this.adminWhitelistModeWrapper instanceof HTMLElement) {
this.adminWhitelistModeWrapper.classList.toggle("hidden", !isSuperAdmin);
}
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
this.populateAdminLists();
}
clearAdminLists() {
if (this.adminModeratorList) {
this.adminModeratorList.innerHTML = "";
}
if (this.adminWhitelistList) {
this.adminWhitelistList.innerHTML = "";
}
if (this.adminBlacklistList) {
this.adminBlacklistList.innerHTML = "";
}
if (this.adminModeratorsEmpty instanceof HTMLElement) {
this.adminModeratorsEmpty.classList.remove("hidden");
}
if (this.adminWhitelistEmpty instanceof HTMLElement) {
this.adminWhitelistEmpty.classList.remove("hidden");
}
if (this.adminBlacklistEmpty instanceof HTMLElement) {
this.adminBlacklistEmpty.classList.remove("hidden");
}
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
}
renderAdminList(listEl, emptyEl, entries, options = {}) {
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
return;
}
const { onRemove, removeLabel = "Remove", confirmMessage, removable = true } =
options;
listEl.innerHTML = "";
const values = Array.isArray(entries) ? [...entries] : [];
values.sort((a, b) => a.localeCompare(b));
if (!values.length) {
emptyEl.classList.remove("hidden");
listEl.classList.add("hidden");
return;
}
emptyEl.classList.add("hidden");
listEl.classList.remove("hidden");
values.forEach((npub) => {
const item = document.createElement("li");
item.className =
"flex flex-col gap-2 rounded-lg bg-gray-800 px-4 py-3 sm:flex-row sm:items-center sm:justify-between";
const label = document.createElement("p");
label.className = "text-sm font-medium text-gray-100 break-all";
label.textContent = npub;
item.appendChild(label);
if (removable && typeof onRemove === "function") {
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className =
"self-start rounded-md bg-gray-700 px-3 py-1 text-xs font-medium text-gray-100 transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
removeBtn.textContent = removeLabel;
removeBtn.addEventListener("click", () => {
if (confirmMessage) {
const message = confirmMessage.replace("{npub}", npub);
if (!window.confirm(message)) {
return;
}
}
removeBtn.disabled = true;
removeBtn.setAttribute("aria-busy", "true");
onRemove(npub, removeBtn);
});
item.appendChild(removeBtn);
}
listEl.appendChild(item);
});
}
populateAdminLists() {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub || !accessControl.canEditAdminLists(actorNpub)) {
this.clearAdminLists();
return;
}
const isSuperAdmin = accessControl.isSuperAdmin(actorNpub);
const editors = accessControl
.getEditors()
.filter((npub) => npub && npub !== ADMIN_SUPER_NPUB);
const whitelist = accessControl.getWhitelist();
const blacklist = accessControl.getBlacklist();
this.renderAdminList(this.adminModeratorList, this.adminModeratorsEmpty, editors, {
onRemove: (npub, button) => this.handleRemoveModerator(npub, button),
removeLabel: "Remove",
confirmMessage:
"Remove moderator {npub}? They will immediately lose access to the admin panel.",
removable: isSuperAdmin,
});
this.renderAdminList(this.adminWhitelistList, this.adminWhitelistEmpty, whitelist, {
onRemove: (npub, button) =>
this.handleAdminListMutation("whitelist", "remove", npub, button),
removeLabel: "Remove",
confirmMessage: "Remove {npub} from the whitelist?",
removable: true,
});
this.renderAdminList(this.adminBlacklistList, this.adminBlacklistEmpty, blacklist, {
onRemove: (npub, button) =>
this.handleAdminListMutation("blacklist", "remove", npub, button),
removeLabel: "Unblock",
confirmMessage: "Remove {npub} from the blacklist?",
removable: true,
});
}
getCurrentUserNpub() {
if (typeof this.currentUserNpub === "string" && this.currentUserNpub) {
return this.currentUserNpub;
}
if (!this.pubkey) {
return null;
}
const encoded = this.safeEncodeNpub(this.pubkey);
if (encoded) {
this.currentUserNpub = encoded;
}
return this.currentUserNpub;
}
ensureAdminActor(requireSuperAdmin = false) {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub) {
this.showError("Please login with a Nostr account to manage admin settings.");
return null;
}
if (!accessControl.canEditAdminLists(actorNpub)) {
this.showError("You do not have permission to manage BitVid moderation lists.");
return null;
}
if (requireSuperAdmin && !accessControl.isSuperAdmin(actorNpub)) {
this.showError("Only the Super Admin can manage moderators or whitelist mode.");
return null;
}
return actorNpub;
}
handleAddModerator() {
const actorNpub = this.ensureAdminActor(true);
if (!actorNpub || !this.adminModeratorInput) {
return;
}
const value = this.adminModeratorInput.value.trim();
if (!value) {
this.showError("Enter an npub to add as a moderator.");
return;
}
if (this.adminAddModeratorBtn) {
this.adminAddModeratorBtn.disabled = true;
this.adminAddModeratorBtn.setAttribute("aria-busy", "true");
}
try {
const result = accessControl.addModerator(actorNpub, value);
if (!result.ok) {
this.showError(this.describeAdminError(result.error));
return;
}
this.adminModeratorInput.value = "";
this.showSuccess("Moderator added successfully.");
this.onAccessControlUpdated();
} finally {
if (this.adminAddModeratorBtn) {
this.adminAddModeratorBtn.disabled = false;
this.adminAddModeratorBtn.removeAttribute("aria-busy");
}
}
}
handleRemoveModerator(npub, button) {
const actorNpub = this.ensureAdminActor(true);
if (!actorNpub) {
if (button instanceof HTMLElement) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
const result = accessControl.removeModerator(actorNpub, npub);
if (!result.ok) {
this.showError(this.describeAdminError(result.error));
if (button instanceof HTMLElement) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
this.showSuccess("Moderator removed.");
this.onAccessControlUpdated();
}
handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
const actorNpub = this.ensureAdminActor(false);
if (!actorNpub) {
if (sourceButton instanceof HTMLElement) {
sourceButton.disabled = false;
sourceButton.removeAttribute("aria-busy");
}
return;
}
const isWhitelist = listType === "whitelist";
const input = isWhitelist ? this.adminWhitelistInput : this.adminBlacklistInput;
const addButton = isWhitelist ? this.adminAddWhitelistBtn : this.adminAddBlacklistBtn;
const isAdd = action === "add";
let target = typeof explicitNpub === "string" ? explicitNpub.trim() : "";
if (!target && input instanceof HTMLInputElement) {
target = input.value.trim();
}
if (isAdd && !target) {
this.showError("Enter an npub before adding it to the list.");
if (sourceButton instanceof HTMLElement) {
sourceButton.disabled = false;
sourceButton.removeAttribute("aria-busy");
}
return;
}
const buttonToToggle = sourceButton || (isAdd ? addButton : null);
if (buttonToToggle instanceof HTMLElement) {
buttonToToggle.disabled = true;
buttonToToggle.setAttribute("aria-busy", "true");
}
let result;
if (isWhitelist) {
result = isAdd
? accessControl.addToWhitelist(actorNpub, target)
: accessControl.removeFromWhitelist(actorNpub, target);
} else {
result = isAdd
? accessControl.addToBlacklist(actorNpub, target)
: accessControl.removeFromBlacklist(actorNpub, target);
}
if (!result.ok) {
this.showError(this.describeAdminError(result.error));
if (buttonToToggle instanceof HTMLElement) {
buttonToToggle.disabled = false;
buttonToToggle.removeAttribute("aria-busy");
}
return;
}
if (isAdd && input instanceof HTMLInputElement) {
input.value = "";
}
const successMessage = isWhitelist
? isAdd
? "Added to the whitelist."
: "Removed from the whitelist."
: isAdd
? "Added to the blacklist."
: "Removed from the blacklist.";
this.showSuccess(successMessage);
this.onAccessControlUpdated();
if (buttonToToggle instanceof HTMLElement) {
buttonToToggle.disabled = false;
buttonToToggle.removeAttribute("aria-busy");
}
}
describeAdminError(code) {
switch (code) {
case "invalid npub":
return "Please provide a valid npub address.";
case "immutable":
return "That account cannot be modified.";
case "forbidden":
return "You do not have permission to perform that action.";
default:
return "Unable to update moderation settings. Please try again.";
}
}
handleWhitelistModeToggle(enabled) {
const actorNpub = this.ensureAdminActor(true);
if (!actorNpub) {
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
return;
}
const result = accessControl.setWhitelistMode(actorNpub, enabled);
if (!result.ok) {
this.showError(this.describeAdminError(result.error));
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
return;
}
this.showSuccess(
enabled
? "Whitelist mode enabled. Only approved creators will appear in feeds."
: "Whitelist mode disabled. All creators except those blacklisted will appear."
);
this.onAccessControlUpdated();
}
onAccessControlUpdated() {
this.refreshAdminPaneState();
this.loadVideos(true).catch((error) => {
console.error("Failed to refresh videos after admin update:", error);
});
window.dispatchEvent(new CustomEvent("bitvid:access-control-updated"));
}
/**
* Setup general event listeners for logout, modals, etc.
*/
@@ -4277,6 +4806,7 @@ class bitvidApp {
console.log("[app.js] login() called with pubkey =", pubkey);
this.pubkey = pubkey;
this.currentUserNpub = this.safeEncodeNpub(pubkey);
let reloadScheduled = false;
if (saveToStorage) {
@@ -4293,6 +4823,8 @@ class bitvidApp {
return;
}
this.refreshAdminPaneState();
// Hide login button if present
if (this.loginButton) {
this.loginButton.classList.add("hidden");
@@ -4336,6 +4868,7 @@ class bitvidApp {
async logout() {
nostrClient.logout();
this.pubkey = null;
this.currentUserNpub = null;
// Show the login button again
if (this.loginButton) {
@@ -4374,6 +4907,8 @@ class bitvidApp {
// Clear localStorage
localStorage.removeItem("userPubKey");
this.refreshAdminPaneState();
// Refresh the video list so user sees only public videos again
await this.loadVideos();
@@ -4811,6 +5346,8 @@ class bitvidApp {
async loadVideos(forceFetch = false) {
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
accessControl.refresh();
const shouldIncludeVideo = (video) => {
if (!video || typeof video !== "object") {
return false;
@@ -4820,12 +5357,7 @@ class bitvidApp {
return false;
}
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
return false;
}
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
if (!accessControl.canAccess(video)) {
return false;
}
@@ -7117,14 +7649,16 @@ class bitvidApp {
return;
}
accessControl.refresh();
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
return;
}
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
this.showError("This content is not from a whitelisted author.");
if (!accessControl.canAccess(authorNpub)) {
if (accessControl.isBlacklisted(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
} else if (accessControl.whitelistMode()) {
this.showError("This content is not from a whitelisted author.");
} else {
this.showError("This content has been removed or is not allowed.");
}
return;
}
@@ -7326,8 +7860,21 @@ class bitvidApp {
* Simple helper to safely encode an npub.
*/
safeEncodeNpub(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
try {
return window.NostrTools.nip19.npubEncode(pubkey);
return window.NostrTools.nip19.npubEncode(trimmed);
} catch (err) {
return null;
}
+33 -7
View File
@@ -8,8 +8,10 @@ import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
import { accessControl } from "./accessControl.js";
let currentChannelHex = null;
let currentChannelNpub = null;
let cachedZapButton = null;
@@ -55,6 +57,9 @@ export async function initChannelProfileView() {
return;
}
currentChannelHex = null;
currentChannelNpub = null;
// 2) Decode npub => hex pubkey
let hexPub;
try {
@@ -69,6 +74,9 @@ export async function initChannelProfileView() {
return;
}
currentChannelHex = hexPub;
currentChannelNpub = npub;
// 3) If user is logged in, load subscriptions and show sub/unsub button
if (app.pubkey) {
await subscriptions.loadSubscriptions(app.pubkey);
@@ -243,6 +251,8 @@ async function loadUserProfile(pubkey) {
*/
async function loadUserVideos(pubkey) {
try {
accessControl.refresh();
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
@@ -279,11 +289,7 @@ async function loadUserVideos(pubkey) {
if (app.blacklistedEventIds.has(video.id)) return false;
// Author-level
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) return false;
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
return false;
}
if (!accessControl.canAccess(video)) return false;
return true;
});
@@ -688,6 +694,26 @@ async function loadUserVideos(pubkey) {
}
}
window.addEventListener("bitvid:access-control-updated", () => {
const hashParams = new URLSearchParams(window.location.hash.slice(1));
if (hashParams.get("view") !== "channel-profile") {
return;
}
if (!currentChannelHex) {
return;
}
const activeNpub = hashParams.get("npub");
if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) {
return;
}
loadUserVideos(currentChannelHex).catch((error) => {
console.error("Failed to refresh channel videos after admin update:", error);
});
});
/**
* Minimal placeholder for private video decryption.
*/
+36 -1
View File
@@ -1,4 +1,39 @@
// js/config.js
export const isDevMode = true; // Set to false for production
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users
// -----------------------------------------------------------------------------
// Admin governance (v1 local mode)
// -----------------------------------------------------------------------------
export const ADMIN_LIST_MODE = "local"; // Future: "nostr"
export const ADMIN_SUPER_NPUB =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
const WHITELIST_MODE_KEY = "bitvid_admin_whitelist_mode";
const DEFAULT_WHITELIST_ENABLED = false;
export function getWhitelistMode() {
try {
const raw = localStorage.getItem(WHITELIST_MODE_KEY);
if (raw === null) {
return DEFAULT_WHITELIST_ENABLED;
}
return raw === "true";
} catch (error) {
console.warn("Failed to read whitelist mode from storage:", error);
return DEFAULT_WHITELIST_ENABLED;
}
}
export function setWhitelistMode(enabled) {
try {
localStorage.setItem(WHITELIST_MODE_KEY, enabled ? "true" : "false");
} catch (error) {
console.warn("Failed to persist whitelist mode to storage:", error);
}
}
export const ADMIN_WHITELIST_MODE_STORAGE_KEY = WHITELIST_MODE_KEY;
+3 -3
View File
@@ -19,8 +19,8 @@ const npubs = [
console.log("DEBUG: lists.js loaded, npubs:", npubs);
// Blacklist of npubs that events will not be displayed in the bitvid official client
export const initialWhitelist = npubs;
export const initialBlacklist = [""];
export const ADMIN_INITIAL_WHITELIST = npubs;
export const ADMIN_INITIAL_BLACKLIST = [""];
// Block specific events with the nevent
export const initialEventBlacklist = [""];
export const ADMIN_INITIAL_EVENT_BLACKLIST = [""];