// static/js/03-admin.js "use strict"; window.Glow = window.Glow || {}; Glow.closeAdminPanel = () => { Glow.els.adminPanel.classList.remove("open"); Glow.els.adminPanel.setAttribute("aria-hidden", "true"); Glow.els.adminFab.focus(); }; Glow.setAdminTab = tab => { Glow.state.adminTab = ["rooms", "users", "teams"].includes(tab) ? tab : "rooms"; const isRooms = Glow.state.adminTab === "rooms"; const isUsers = Glow.state.adminTab === "users"; const isTeams = Glow.state.adminTab === "teams"; Glow.els.adminTabRooms?.classList.toggle("active", isRooms); Glow.els.adminTabUsers?.classList.toggle("active", isUsers); Glow.els.adminTabTeams?.classList.toggle("active", isTeams); Glow.els.adminTabRooms?.setAttribute("aria-selected", String(isRooms)); Glow.els.adminTabUsers?.setAttribute("aria-selected", String(isUsers)); Glow.els.adminTabTeams?.setAttribute("aria-selected", String(isTeams)); Glow.els.adminRoomsPanel?.classList.toggle("hidden", !isRooms); Glow.els.adminUsersPanel?.classList.toggle("hidden", !isUsers); Glow.els.adminTeamsPanel?.classList.toggle("hidden", !isTeams); }; Glow.buildAdminRow = ({ title, subtitle, actions = [], className = "" }) => { const row = document.createElement("div"); row.className = className || "admin-room"; const details = document.createElement("div"); const name = document.createElement("div"); name.className = className.includes("user") ? "admin-user-name" : className.includes("team") ? "admin-team-name" : "admin-room-name"; name.textContent = title; const info = document.createElement("div"); info.className = className.includes("user") ? "admin-user-info" : className.includes("team") ? "admin-team-info" : "admin-room-info"; info.textContent = subtitle || ""; details.append(name, info); const actionsWrap = document.createElement("div"); actionsWrap.className = className.includes("user") ? "admin-user-actions" : className.includes("team") ? "admin-team-actions" : "admin-room-actions"; for (const action of actions) { actionsWrap.append(action); } row.append(details, actionsWrap); return row; }; Glow.renderAdminUsers = users => { Glow.els.adminUsers.replaceChildren(); const heading = document.createElement("h3"); heading.textContent = "Users"; Glow.els.adminUsers.append(heading); if (!users.length) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "No users found."; Glow.els.adminUsers.append(empty); return; } for (const user of users) { const current = Glow.state.currentUser && Number(Glow.state.currentUser.id) === Number(user.id); const actions = []; if (!current && Glow.isAdmin()) { const role = document.createElement("button"); role.className = "small-button"; role.type = "button"; role.textContent = user.role === "moderator" ? "Make user" : "Make moderator"; role.addEventListener("click", () => { Glow.updateAdminUser(user.id, { role: user.role === "moderator" ? "user" : "moderator" }); }); const restrict = document.createElement("button"); restrict.className = "small-button"; restrict.type = "button"; restrict.textContent = user.status === "restricted" ? "Unrestrict" : "Restrict"; restrict.addEventListener("click", () => { Glow.toggleRestriction(user); }); const blacklist = document.createElement("button"); blacklist.className = "small-button danger"; blacklist.type = "button"; blacklist.textContent = user.status === "blacklisted" ? "Unblacklist" : "Blacklist"; blacklist.addEventListener("click", () => { Glow.toggleBlacklist(user); }); actions.push(role, restrict, blacklist); } Glow.els.adminUsers.append( Glow.buildAdminRow({ title: `${user.username} · ${user.role}`, subtitle: `${user.status} · ${user.message_count} message(s)` + (user.restricted_until ? ` · until ${new Date(user.restricted_until).toLocaleString()}` : ""), actions, className: "admin-user" }) ); } }; Glow.renderAdminRooms = rooms => { Glow.els.adminRooms.replaceChildren(); const heading = document.createElement("h3"); heading.textContent = "Rooms"; Glow.els.adminRooms.append(heading); if (!rooms.length) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "No rooms found."; Glow.els.adminRooms.append(empty); return; } for (const room of rooms) { const actions = []; if (Glow.isAdmin()) { const edit = document.createElement("button"); edit.className = "small-button"; edit.type = "button"; edit.textContent = "Edit"; edit.addEventListener("click", () => Glow.editRoom(room)); actions.push(edit); if (room.visibility === "private") { const members = document.createElement("button"); members.className = "small-button"; members.type = "button"; members.textContent = "Members"; members.addEventListener("click", () => { Glow.manageRoomMembers(room); }); const invite = document.createElement("button"); invite.className = "small-button"; invite.type = "button"; invite.textContent = "Invite link"; invite.addEventListener("click", () => { Glow.createInviteLink(room); }); actions.push(members, invite); } if (room.slug !== "general") { const remove = document.createElement("button"); remove.className = "small-button danger"; remove.type = "button"; remove.textContent = "Delete"; remove.addEventListener("click", () => Glow.deleteRoom(room)); actions.push(remove); } } Glow.els.adminRooms.append( Glow.buildAdminRow({ title: `${room.name} · ${room.visibility}`, subtitle: `/${room.slug}` + (room.description ? ` · ${room.description}` : ""), actions, className: "admin-room" }) ); } }; Glow.renderAdminTeamsChannels = () => { const box = Glow.els.adminTeamsChannels; if (!box) return; box.replaceChildren(); const heading = document.createElement("h3"); heading.textContent = "Teams / channels"; box.append(heading); const team = Glow.state.activeTeam; const channel = Glow.state.activeChannel || Glow.state.activeRoom; if (!team) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "Select a team to manage team/channel actions."; box.append(empty); return; } const teamActions = []; if (team.can_manage) { const editTeam = document.createElement("button"); editTeam.className = "small-button"; editTeam.type = "button"; editTeam.textContent = "Edit team"; editTeam.addEventListener("click", () => Glow.openEditTeamDialog?.(team)); const members = document.createElement("button"); members.className = "small-button"; members.type = "button"; members.textContent = "Members"; members.addEventListener("click", () => Glow.manageTeamMembers?.(team)); const invite = document.createElement("button"); invite.className = "small-button"; invite.type = "button"; invite.textContent = "Invite"; invite.addEventListener("click", () => Glow.createTeamInviteLink?.(team)); const addChannel = document.createElement("button"); addChannel.className = "small-button"; addChannel.type = "button"; addChannel.textContent = "Add channel"; addChannel.addEventListener("click", () => Glow.openCreateChannelDialog?.(team)); teamActions.push(editTeam, members, invite, addChannel); } box.append( Glow.buildAdminRow({ title: team.name, subtitle: team.description || `/${team.slug}`, actions: teamActions, className: "admin-team-row" }) ); const channels = Array.isArray(Glow.state.channels) ? Glow.state.channels : []; if (!channels.length) { const empty = document.createElement("p"); empty.className = "admin-empty"; empty.textContent = "No channels available for this team."; box.append(empty); return; } for (const item of channels) { const actions = []; const isActive = channel && item.slug === channel.slug; if (team.can_manage) { const edit = document.createElement("button"); edit.className = "small-button"; edit.type = "button"; edit.textContent = "Edit"; edit.addEventListener("click", () => { Glow.openEditChannelDialog?.(team, item); }); actions.push(edit); if (!item.legacy_room_id) { const remove = document.createElement("button"); remove.className = "small-button danger"; remove.type = "button"; remove.textContent = "Delete"; remove.addEventListener("click", async () => { const previous = Glow.state.activeChannel; Glow.state.activeChannel = item; await Glow.deleteCurrentChannel?.(team); Glow.state.activeChannel = previous; }); actions.push(remove); } } box.append( Glow.buildAdminRow({ title: `${isActive ? "• " : ""}# ${item.name}`, subtitle: item.description || `/${item.slug}`, actions, className: "admin-team-row" }) ); } }; Glow.openAdminPanel = async () => { if (!Glow.canModerate()) return; Glow.els.adminPanel.classList.add("open"); Glow.els.adminPanel.setAttribute("aria-hidden", "false"); if (Glow.els.adminUsers) { Glow.els.adminUsers.textContent = "Loading users…"; } if (Glow.els.adminRooms) { Glow.els.adminRooms.textContent = "Loading rooms…"; } if (Glow.els.adminTeamsChannels) { Glow.els.adminTeamsChannels.textContent = "Loading teams/channels…"; } try { const [usersData, roomsData] = await Promise.all([ Glow.apiRequest("/api/admin/users"), Glow.apiRequest("/api/admin/rooms") ]); Glow.renderAdminUsers(usersData.users || []); Glow.renderAdminRooms(roomsData.rooms || []); Glow.renderAdminTeamsChannels(); Glow.setAdminTab(Glow.state.adminTab || "rooms"); setTimeout(() => Glow.els.closeAdminButton.focus(), 0); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.updateThemeButton = () => { if (!Glow.els.themeButton) return; Glow.els.themeButton.textContent = "Accent color"; }; Glow.updateAdminPreferenceButtons = () => {}; Glow.openAccentColorPicker = async () => { const container = document.createElement("div"); container.className = "accent-picker"; const description = document.createElement("p"); description.className = "accent-picker-description"; description.textContent = "Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings."; const label = document.createElement("label"); label.className = "accent-picker-label"; label.textContent = "Accent color"; const row = document.createElement("div"); row.className = "accent-picker-row"; const colorInput = document.createElement("input"); colorInput.className = "accent-color-input"; colorInput.type = "color"; colorInput.value = Glow.normalizeAccentColor(Glow.state.accentColor) || Glow.DEFAULT_ACCENT_COLOR; colorInput.setAttribute("aria-label", "Choose accent color"); const hexInput = document.createElement("input"); hexInput.className = "field accent-hex-input"; hexInput.type = "text"; hexInput.value = colorInput.value; hexInput.maxLength = 7; hexInput.autocomplete = "off"; hexInput.autocapitalize = "off"; hexInput.spellcheck = false; hexInput.placeholder = "#f7dce8"; hexInput.setAttribute( "aria-label", "Accent color hexadecimal value" ); const preview = document.createElement("span"); preview.className = "accent-preview"; preview.style.background = colorInput.value; preview.setAttribute("aria-hidden", "true"); const error = document.createElement("div"); error.className = "accent-picker-error"; error.setAttribute("role", "status"); const updatePreview = value => { const normalized = Glow.normalizeAccentColor(value); if (!normalized) { preview.style.background = "transparent"; error.textContent = "Use a six-digit hex color, such as #7dd3fc."; return false; } preview.style.background = normalized; error.textContent = ""; if (colorInput.value !== normalized) { colorInput.value = normalized; } return true; }; colorInput.addEventListener("input", () => { const color = Glow.normalizeAccentColor(colorInput.value); if (!color) return; hexInput.value = color; updatePreview(color); }); hexInput.addEventListener("input", () => { updatePreview(hexInput.value.trim()); }); row.append(colorInput, hexInput, preview); label.append(row); container.append(description, label, error); const result = await Glow.openDialog({ title: "Accent color", body: container, confirmText: "Save color", cancelText: "Cancel" }); if (!result) return; const accent = Glow.normalizeAccentColor(hexInput.value); if (!accent) { Glow.showToast( "Choose a valid six-digit hexadecimal color.", "error" ); return; } Glow.applyAccentColor(accent); Glow.showToast("Accent color updated."); }; Glow.toggleRestriction = async user => { if (user.status === "restricted") { return Glow.updateAdminUser(user.id, { status: "active", restricted_until: null }); } const values = await Glow.openDialog({ title: `Restrict ${user.username}`, body: "Enter the restriction duration in minutes.", confirmText: "Restrict", danger: true, fields: [ { name: "minutes", label: "Duration in minutes", type: "number", value: "60", maxLength: 5 } ] }); if (!values) return; const minutes = Number(values.minutes); if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) { Glow.showToast( "Enter a duration between 1 minute and 30 days.", "error" ); return; } await Glow.updateAdminUser(user.id, { status: "restricted", restricted_until: new Date( Date.now() + minutes * 60 * 1000 ).toISOString() }); }; Glow.toggleBlacklist = async user => { const blacklisting = user.status !== "blacklisted"; const result = await Glow.openDialog({ title: blacklisting ? `Blacklist ${user.username}?` : `Unblacklist ${user.username}?`, body: blacklisting ? "The account will be denied access immediately." : "The account will be restored to active status.", confirmText: blacklisting ? "Blacklist" : "Unblacklist", danger: blacklisting }); if (!result) return; await Glow.updateAdminUser(user.id, { status: blacklisting ? "blacklisted" : "active" }); }; Glow.updateAdminUser = async (userId, changes) => { try { await Glow.apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); Glow.showToast("User updated."); await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.createRoom = async () => { const values = await Glow.openDialog({ title: "Create room", body: "Choose a room name, description, and visibility.", confirmText: "Create", fields: [ { name: "name", label: "Room name", placeholder: "Project chat", maxLength: 60 }, { name: "description", label: "Description", placeholder: "Optional room description", multiline: true, rows: 3, maxLength: 300 }, { name: "visibility", label: "Visibility: public or private", value: "public", maxLength: 7 } ] }); if (!values) return; const visibility = values.visibility.toLowerCase(); if (!["public", "private"].includes(visibility)) { Glow.showToast("Visibility must be public or private.", "error"); return; } try { await Glow.apiRequest("/api/rooms", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: values.name, description: values.description, visibility }) }); Glow.showToast("Room created."); await Glow.loadRooms(); await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.editRoom = async room => { const values = await Glow.openDialog({ title: `Edit ${room.name}`, confirmText: "Save", fields: [ { name: "name", label: "Room name", value: room.name, maxLength: 60 }, { name: "description", label: "Description", value: room.description || "", multiline: true, rows: 3, maxLength: 300 }, { name: "visibility", label: "Visibility: public or private", value: room.visibility, maxLength: 7 } ] }); if (!values) return; const visibility = values.visibility.toLowerCase(); if (!["public", "private"].includes(visibility)) { Glow.showToast("Visibility must be public or private.", "error"); return; } await Glow.updateRoom(room.slug, { name: values.name, description: values.description, visibility }); }; Glow.updateRoom = async (slug, changes) => { try { await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) }); Glow.showToast("Room updated."); await Glow.loadRooms(); if (!Glow.state.activeDm) { await Glow.loadMessages(); } await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.deleteRoom = async room => { const result = await Glow.openDialog({ title: `Delete ${room.name}?`, body: "This permanently deletes the room and every message in it.", confirmText: "Delete room", danger: true }); if (!result) return; try { await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}`, { method: "DELETE" }); Glow.showToast("Room deleted."); await Glow.loadRooms(); if (!Glow.state.activeDm) { await Glow.loadMessages(); } await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.clearActiveRoom = async () => { const room = Glow.state.activeRoom; if (!room || Glow.state.activeDm) return; const result = await Glow.openDialog({ title: `Clear ${room.name}?`, body: "All messages in this room will be permanently deleted.", confirmText: "Clear room", danger: true }); if (!result) return; try { await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/messages`, { method: "DELETE" } ); Glow.clearMessages(); Glow.renderEmptyState(); Glow.showToast("Room messages cleared."); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.manageRoomMembers = async room => { try { const data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members` ); const container = document.createElement("div"); container.className = "member-list"; for (const member of data.members || []) { const row = document.createElement("div"); row.className = "member-row"; const name = document.createElement("span"); name.textContent = `${member.username} · ${member.membership_role}`; const remove = document.createElement("button"); remove.className = "small-button danger"; remove.type = "button"; remove.textContent = "Remove"; remove.disabled = member.membership_role === "owner"; remove.addEventListener("click", async () => { try { await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`, { method: "DELETE" } ); Glow.showToast("Member removed."); Glow.closeDialog({}); Glow.manageRoomMembers(room); } catch (error) { Glow.showToast(error.message, "error"); } }); row.append(name, remove); container.append(row); } const values = await Glow.openDialog({ title: `${room.name} members`, body: container, confirmText: "Add member", fields: [ { name: "username", label: "Username to add", placeholder: "username", maxLength: 30 } ] }); if (!values?.username) return; await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/members`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: values.username }) } ); Glow.showToast("Member added."); await Glow.manageRoomMembers(room); } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.createInviteLink = async room => { try { const data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/invites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) } ); const url = new URL( data.invite.url, window.location.origin ).toString(); const values = await Glow.openDialog({ title: "Private-room invite link", body: "Copy and share this link. It can be used once before it expires.", confirmText: "Copy link", fields: [ { name: "invite", label: "Invite link", value: url, maxLength: 500 } ] }); if (values) { await navigator.clipboard.writeText(url); Glow.showToast("Invite link copied."); } } catch (error) { Glow.showToast(error.message, "error"); } }; Glow.els.adminTabRooms?.addEventListener("click", () => { Glow.setAdminTab("rooms"); }); Glow.els.adminTabUsers?.addEventListener("click", () => { Glow.setAdminTab("users"); }); Glow.els.adminTabTeams?.addEventListener("click", () => { Glow.setAdminTab("teams"); });