// 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 or 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 || "could not load moderation data.", "error"); } }; 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 || "could not update user.", "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 = String(values.visibility || "").toLowerCase(); if (!["public", "private"].includes(visibility)) { Glow.showToast("visibility must be public or private.", "error"); return; } try { await Glow.apiCreateRoom({ name: values.name, description: values.description, visibility }); Glow.showToast("room created."); await Glow.loadRooms(); await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message || "could not create room.", "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 = String(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.apiUpdateRoom(slug, changes); Glow.showToast("room updated."); await Glow.loadRooms(); if (!Glow.state.activeDm) { await Glow.loadMessages(); } await Glow.openAdminPanel(); } catch (error) { Glow.showToast(error.message || "could not update room.", "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 || "could not delete room.", "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 || "could not clear room.", "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 || "could not remove member.", "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 || "could not manage room members.", "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 || "could not create invite link.", "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"); });