diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md
index ae4f26e..e309c17 100644
--- a/GUILDED_ROADMAP.md
+++ b/GUILDED_ROADMAP.md
@@ -25,17 +25,13 @@
> Unlocks serious server organization. Guilded's strength was granular permissions.
----
-
-## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS
-
| # | Feature | Status |
|---|---------|--------|
-| 2.1 | Per-Channel Permission Overrides | Pending |
+| 2.1 | Per-Channel Permission Overrides | In progress |
| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` |
-| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI pending |
-| 2.4 | Audit Log | In progress |
-| 2.5 | Bulk Message Delete | In progress |
+| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI in progress |
+| 2.4 | Audit Log | ✅ Backend + server settings panel live |
+| 2.5 | Bulk Message Delete | ✅ Backend + frontend live |
### 2.1 Per-Channel Permission Overrides
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go
index 986a5cf..0cb6051 100644
--- a/internal/channel/handlers.go
+++ b/internal/channel/handlers.go
@@ -27,6 +27,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/{channelID}", h.Get)
r.Patch("/{channelID}", h.Update)
r.Delete("/{channelID}", h.Delete)
+ h.registerOverrideRoutes(r)
}
type createChannelRequest struct {
diff --git a/internal/channel/overrides.go b/internal/channel/overrides.go
new file mode 100644
index 0000000..ddc5847
--- /dev/null
+++ b/internal/channel/overrides.go
@@ -0,0 +1,130 @@
+package channel
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
+ "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
+ "github.com/go-chi/chi/v5"
+)
+
+type overrideRequest struct {
+ Allow int64 `json:"allow"`
+ Deny int64 `json:"deny"`
+}
+
+type overrideResponse struct {
+ ID string `json:"id"`
+ ChannelID string `json:"channel_id"`
+ TargetType string `json:"target_type"`
+ TargetID string `json:"target_id"`
+ Allow int64 `json:"allow_bitflags"`
+ Deny int64 `json:"deny_bitflags"`
+}
+
+func (h *Handler) registerOverrideRoutes(r chi.Router) {
+ r.Put("/{channelID}/permissions/{targetType}/{targetID}", h.SetOverride)
+ r.Get("/{channelID}/permissions", h.ListOverrides)
+ r.Delete("/{channelID}/permissions/{targetType}/{targetID}", h.DeleteOverride)
+}
+
+func (h *Handler) requireChannelManager(w http.ResponseWriter, r *http.Request, channelID string) (userID, serverID string, ok bool) {
+ userID, authOK := middleware.UserIDFromContext(r.Context())
+ if !authOK {
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+ return "", "", false
+ }
+ serverID, err := h.serverIDForChannel(r.Context(), channelID)
+ if err != nil {
+ http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
+ return "", "", false
+ }
+ allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
+ if err != nil || !allowed {
+ http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
+ return "", "", false
+ }
+ return userID, serverID, true
+}
+
+func (h *Handler) SetOverride(w http.ResponseWriter, r *http.Request) {
+ channelID := chi.URLParam(r, "channelID")
+ _, _, ok := h.requireChannelManager(w, r, channelID)
+ if !ok {
+ return
+ }
+ targetType := chi.URLParam(r, "targetType")
+ targetID := chi.URLParam(r, "targetID")
+ if targetType != "role" && targetType != "user" {
+ http.Error(w, `{"error":"invalid target_type"}`, http.StatusBadRequest)
+ return
+ }
+ var req overrideRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
+ return
+ }
+
+ var res overrideResponse
+ err := h.db.QueryRowContext(r.Context(), `
+ INSERT INTO channel_overrides (channel_id, target_type, target_id, allow_bitflags, deny_bitflags)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (channel_id, target_type, target_id)
+ DO UPDATE SET allow_bitflags = EXCLUDED.allow_bitflags, deny_bitflags = EXCLUDED.deny_bitflags, updated_at = NOW()
+ RETURNING id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
+ `, channelID, targetType, targetID, req.Allow, req.Deny).Scan(&res.ID, &res.ChannelID, &res.TargetType, &res.TargetID, &res.Allow, &res.Deny)
+ if err != nil {
+ http.Error(w, `{"error":"failed to set override"}`, http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(res)
+}
+
+func (h *Handler) ListOverrides(w http.ResponseWriter, r *http.Request) {
+ channelID := chi.URLParam(r, "channelID")
+ _, _, ok := h.requireChannelManager(w, r, channelID)
+ if !ok {
+ return
+ }
+ rows, err := h.db.QueryContext(r.Context(), `
+ SELECT id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
+ FROM channel_overrides
+ WHERE channel_id = $1
+ ORDER BY target_type, target_id
+ `, channelID)
+ if err != nil {
+ http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+ overrides := make([]overrideResponse, 0)
+ for rows.Next() {
+ var o overrideResponse
+ if err := rows.Scan(&o.ID, &o.ChannelID, &o.TargetType, &o.TargetID, &o.Allow, &o.Deny); err != nil {
+ continue
+ }
+ overrides = append(overrides, o)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(overrides)
+}
+
+func (h *Handler) DeleteOverride(w http.ResponseWriter, r *http.Request) {
+ channelID := chi.URLParam(r, "channelID")
+ _, _, ok := h.requireChannelManager(w, r, channelID)
+ if !ok {
+ return
+ }
+ targetType := chi.URLParam(r, "targetType")
+ targetID := chi.URLParam(r, "targetID")
+ _, err := h.db.ExecContext(r.Context(), `
+ DELETE FROM channel_overrides WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
+ `, channelID, targetType, targetID)
+ if err != nil {
+ http.Error(w, `{"error":"failed to delete override"}`, http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/permissions/overrides.go b/internal/permissions/overrides.go
new file mode 100644
index 0000000..528e1ee
--- /dev/null
+++ b/internal/permissions/overrides.go
@@ -0,0 +1,77 @@
+package permissions
+
+import (
+ "context"
+ "database/sql"
+)
+
+// ComputeChannelPermissions returns effective permissions for a user in a specific channel,
+// layering channel overrides on top of server-wide role permissions.
+func (c *Checker) ComputeChannelPermissions(ctx context.Context, serverID, channelID, userID string) (int64, error) {
+ base, err := c.GetUserPermissions(ctx, serverID, userID)
+ if err != nil {
+ return 0, err
+ }
+ if Has(base, ADMINISTRATOR) {
+ return base, nil
+ }
+
+ // Everyone role override applies first.
+ var everyoneID string
+ _ = c.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1`, serverID).Scan(&everyoneID)
+ if everyoneID != "" {
+ allow, deny, err := c.getOverride(ctx, channelID, "role", everyoneID)
+ if err != nil {
+ return 0, err
+ }
+ base |= allow
+ base &^= deny
+ }
+
+ // Member-specific role overrides.
+ rows, err := c.db.QueryContext(ctx, `
+ SELECT r.id FROM roles r
+ INNER JOIN member_roles mr ON mr.role_id = r.id
+ WHERE mr.user_id = $1 AND mr.server_id = $2
+ `, userID, serverID)
+ if err != nil {
+ return 0, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var roleID string
+ if err := rows.Scan(&roleID); err != nil {
+ return 0, err
+ }
+ allow, deny, err := c.getOverride(ctx, channelID, "role", roleID)
+ if err != nil {
+ return 0, err
+ }
+ base |= allow
+ base &^= deny
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+
+ // User-specific override wins last.
+ allow, deny, err := c.getOverride(ctx, channelID, "user", userID)
+ if err != nil {
+ return 0, err
+ }
+ base |= allow
+ base &^= deny
+
+ return base, nil
+}
+
+func (c *Checker) getOverride(ctx context.Context, channelID, targetType, targetID string) (allow, deny int64, err error) {
+ err = c.db.QueryRowContext(ctx, `
+ SELECT allow_bitflags, deny_bitflags FROM channel_overrides
+ WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
+ `, channelID, targetType, targetID).Scan(&allow, &deny)
+ if err == sql.ErrNoRows {
+ return 0, 0, nil
+ }
+ return allow, deny, err
+}
diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go
index 4f7be43..3187564 100644
--- a/internal/permissions/permissions.go
+++ b/internal/permissions/permissions.go
@@ -26,6 +26,35 @@ const (
MENTION_EVERYONE int64 = 1 << 21
)
+// AllPermissionLabels maps flags to human-friendly names.
+var AllPermissionLabels = []struct {
+ Key string
+ Flag int64
+}{
+ {"VIEW_CHANNEL", VIEW_CHANNEL},
+ {"SEND_MESSAGES", SEND_MESSAGES},
+ {"MANAGE_MESSAGES", MANAGE_MESSAGES},
+ {"KICK_MEMBERS", KICK_MEMBERS},
+ {"BAN_MEMBERS", BAN_MEMBERS},
+ {"MANAGE_SERVER", MANAGE_SERVER},
+ {"MANAGE_CHANNELS", MANAGE_CHANNELS},
+ {"ADMINISTRATOR", ADMINISTRATOR},
+ {"CONNECT_VOICE", CONNECT_VOICE},
+ {"SPEAK_VOICE", SPEAK_VOICE},
+ {"SHARE_SCREEN", SHARE_SCREEN},
+ {"MUTE_MEMBERS", MUTE_MEMBERS},
+ {"CREATE_INSTANT_INVITE", CREATE_INSTANT_INVITE},
+ {"CHANGE_NICKNAME", CHANGE_NICKNAME},
+ {"MANAGE_NICKNAMES", MANAGE_NICKNAMES},
+ {"MANAGE_ROLES", MANAGE_ROLES},
+ {"MANAGE_WEBHOOKS", MANAGE_WEBHOOKS},
+ {"EMBED_LINKS", EMBED_LINKS},
+ {"ATTACH_FILES", ATTACH_FILES},
+ {"ADD_REACTIONS", ADD_REACTIONS},
+ {"USE_EXTERNAL_EMOJIS", USE_EXTERNAL_EMOJIS},
+ {"MENTION_EVERYONE", MENTION_EVERYONE},
+}
+
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx
index 4f9fbc5..27bdfc3 100644
--- a/web/src/components/ChannelList.tsx
+++ b/web/src/components/ChannelList.tsx
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
import { VoiceChannel } from './VoiceChannel.tsx';
import { CreateChannelModal } from './CreateChannelModal.tsx';
import { InviteModal } from './InviteModal.tsx';
+import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
@@ -14,6 +15,7 @@ export function ChannelList() {
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const [showCreate, setShowCreate] = useState(false);
const [showInvite, setShowInvite] = useState(false);
+ const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
useEffect(() => {
if (activeServerId) {
@@ -83,16 +85,26 @@ export function ChannelList() {
channelName={channel.name}
/>
) : (
-
+
+
+
)
)}
@@ -104,6 +116,14 @@ export function ChannelList() {
{showInvite && activeServerId && activeServer && (
setShowInvite(false)} />
)}
+ {settingsChannel && activeServerId && (
+ setSettingsChannel(null)}
+ />
+ )}
);
}
diff --git a/web/src/components/ChannelSettingsModal.tsx b/web/src/components/ChannelSettingsModal.tsx
new file mode 100644
index 0000000..f09bb9c
--- /dev/null
+++ b/web/src/components/ChannelSettingsModal.tsx
@@ -0,0 +1,193 @@
+import { useEffect, useState } from 'react';
+import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from '../stores/permissions.ts';
+import { useRoleStore } from '../stores/role.ts';
+import { useMemberStore } from '../stores/member.ts';
+
+interface ChannelSettingsModalProps {
+ serverId: string;
+ channelId: string;
+ channelName: string;
+ onClose: () => void;
+}
+
+type Tab = 'permissions';
+type TargetMode = 'role' | 'user';
+
+interface OverrideFormData {
+ targetType: TargetMode;
+ targetId: string;
+ allow: number;
+ deny: number;
+}
+
+function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number; onChange: (allow: number, deny: number) => void }) {
+ const toggle = (flag: number, state: 'allow' | 'deny' | 'inherit') => {
+ let nextAllow = allow;
+ let nextDeny = deny;
+ if (state === 'allow') {
+ nextAllow |= flag;
+ nextDeny &= ~flag;
+ } else if (state === 'deny') {
+ nextAllow &= ~flag;
+ nextDeny |= flag;
+ } else {
+ nextAllow &= ~flag;
+ nextDeny &= ~flag;
+ }
+ onChange(nextAllow, nextDeny);
+ };
+
+ const state = (flag: number): 'allow' | 'deny' | 'inherit' => {
+ if (hasPermission(allow, flag)) return 'allow';
+ if (hasPermission(deny, flag)) return 'deny';
+ return 'inherit';
+ };
+
+ return (
+
+ {(Object.keys(PERMISSION_LABELS) as (keyof typeof PERMISSION_LABELS)[]).map((key) => {
+ const flag = PERMS[key];
+ const s = state(flag);
+ return (
+
+
{PERMISSION_LABELS[key]}
+
+ {(['inherit', 'allow', 'deny'] as const).map((opt) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
+ const [tab] = useState('permissions');
+ const [form, setForm] = useState({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
+ const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
+ const setOverride = usePermissionStore((s) => s.setOverride);
+ const deleteOverride = usePermissionStore((s) => s.deleteOverride);
+
+ const roles = useRoleStore((s) => s.roles);
+ const fetchRoles = useRoleStore((s) => s.fetchRoles);
+ const members = useMemberStore((s) => s.membersByServer[serverId] || []);
+ const fetchMembers = useMemberStore((s) => s.fetchMembers);
+
+ useEffect(() => {
+ fetchOverrides(channelId).catch(() => setError('Failed to load overrides'));
+ fetchRoles(serverId).catch(() => {});
+ fetchMembers(serverId).catch(() => {});
+ }, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const handleSave = async () => {
+ if (!form.targetId) {
+ setError('Select a target');
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ try {
+ await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
+ setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save override');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const targets = form.targetType === 'role'
+ ? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
+ : members.map((m) => ({ id: m.id, label: m.username }));
+
+ return (
+
+
e.stopPropagation()}>
+
+ CHANNEL SETTINGS: #{channelName}
+
+
+
+
+
+
+ {tab === 'permissions' && (
+
+
Existing overrides
+ {overrides.length === 0 &&
[none]
}
+ {overrides.map((o: ChannelOverride) => (
+
+
{o.target_type}:{o.target_id}
+
+ allow:{o.allow_bitflags}
+ deny:{o.deny_bitflags}
+
+
+
+ ))}
+
+
+
New override
+
+
+
+
+
setForm({ ...form, allow: a, deny: d })} />
+ {error && ERR: {error}
}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/components/RoleManager.tsx b/web/src/components/RoleManager.tsx
index 4b732ff..3032598 100644
--- a/web/src/components/RoleManager.tsx
+++ b/web/src/components/RoleManager.tsx
@@ -15,6 +15,17 @@ const PERMS = {
CONNECT_VOICE: 256,
SPEAK_VOICE: 512,
SHARE_SCREEN: 1024,
+ MUTE_MEMBERS: 2048,
+ CREATE_INSTANT_INVITE: 4096,
+ CHANGE_NICKNAME: 8192,
+ MANAGE_NICKNAMES: 16384,
+ MANAGE_ROLES: 32768,
+ MANAGE_WEBHOOKS: 65536,
+ EMBED_LINKS: 131072,
+ ATTACH_FILES: 262144,
+ ADD_REACTIONS: 524288,
+ USE_EXTERNAL_EMOJIS: 1048576,
+ MENTION_EVERYONE: 2097152,
} as const;
const PERM_CATEGORIES = [
@@ -24,6 +35,11 @@ const PERM_CATEGORIES = [
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_MESSAGES },
+ { key: 'ADD_REACTIONS', label: 'Add Reactions', flag: PERMS.ADD_REACTIONS },
+ { key: 'EMBED_LINKS', label: 'Embed Links', flag: PERMS.EMBED_LINKS },
+ { key: 'ATTACH_FILES', label: 'Attach Files', flag: PERMS.ATTACH_FILES },
+ { key: 'MENTION_EVERYONE', label: 'Mention @everyone', flag: PERMS.MENTION_EVERYONE },
+ { key: 'USE_EXTERNAL_EMOJIS', label: 'Use External Emojis', flag: PERMS.USE_EXTERNAL_EMOJIS },
],
},
{
@@ -31,6 +47,9 @@ const PERM_CATEGORIES = [
perms: [
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
{ key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_MEMBERS },
+ { key: 'MUTE_MEMBERS', label: 'Mute Members', flag: PERMS.MUTE_MEMBERS },
+ { key: 'MANAGE_NICKNAMES', label: 'Manage Nicknames', flag: PERMS.MANAGE_NICKNAMES },
+ { key: 'CHANGE_NICKNAME', label: 'Change Nickname', flag: PERMS.CHANGE_NICKNAME },
],
},
{
@@ -38,6 +57,9 @@ const PERM_CATEGORIES = [
perms: [
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
{ key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS },
+ { key: 'MANAGE_ROLES', label: 'Manage Roles', flag: PERMS.MANAGE_ROLES },
+ { key: 'MANAGE_WEBHOOKS', label: 'Manage Webhooks', flag: PERMS.MANAGE_WEBHOOKS },
+ { key: 'CREATE_INSTANT_INVITE', label: 'Create Instant Invite', flag: PERMS.CREATE_INSTANT_INVITE },
{ key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR },
],
},
@@ -66,9 +88,11 @@ function permSummary(permissions: number): string {
const active: string[] = [];
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN');
+ if (hasPerm(permissions, PERMS.MANAGE_ROLES)) active.push('MGR_ROLES');
if (hasPerm(permissions, PERMS.MANAGE_MESSAGES)) active.push('MGR_MSG');
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN');
+ if (hasPerm(permissions, PERMS.MUTE_MEMBERS)) active.push('MUTE');
return active.length > 0 ? active.join('+') : 'BASIC';
}
diff --git a/web/src/stores/permissions.ts b/web/src/stores/permissions.ts
new file mode 100644
index 0000000..8e3c9f0
--- /dev/null
+++ b/web/src/stores/permissions.ts
@@ -0,0 +1,109 @@
+import { create } from 'zustand';
+import { api } from '../lib/api.ts';
+
+// ponytail: mirror backend permission bitflags exactly.
+export const PERMS = {
+ VIEW_CHANNEL: 1,
+ SEND_MESSAGES: 2,
+ MANAGE_MESSAGES: 4,
+ KICK_MEMBERS: 8,
+ BAN_MEMBERS: 16,
+ MANAGE_SERVER: 32,
+ MANAGE_CHANNELS: 64,
+ ADMINISTRATOR: 128,
+ CONNECT_VOICE: 256,
+ SPEAK_VOICE: 512,
+ SHARE_SCREEN: 1024,
+ MUTE_MEMBERS: 2048,
+ CREATE_INSTANT_INVITE: 4096,
+ CHANGE_NICKNAME: 8192,
+ MANAGE_NICKNAMES: 16384,
+ MANAGE_ROLES: 32768,
+ MANAGE_WEBHOOKS: 65536,
+ EMBED_LINKS: 131072,
+ ATTACH_FILES: 262144,
+ ADD_REACTIONS: 524288,
+ USE_EXTERNAL_EMOJIS: 1048576,
+ MENTION_EVERYONE: 2097152,
+} as const;
+
+export type PermissionKey = keyof typeof PERMS;
+
+export const PERMISSION_LABELS: Record = {
+ VIEW_CHANNEL: 'View Channel',
+ SEND_MESSAGES: 'Send Messages',
+ MANAGE_MESSAGES: 'Manage Messages',
+ KICK_MEMBERS: 'Kick Members',
+ BAN_MEMBERS: 'Ban Members',
+ MANAGE_SERVER: 'Manage Server',
+ MANAGE_CHANNELS: 'Manage Channels',
+ ADMINISTRATOR: 'Administrator',
+ CONNECT_VOICE: 'Connect Voice',
+ SPEAK_VOICE: 'Speak Voice',
+ SHARE_SCREEN: 'Share Screen',
+ MUTE_MEMBERS: 'Mute Members',
+ CREATE_INSTANT_INVITE: 'Create Instant Invite',
+ CHANGE_NICKNAME: 'Change Nickname',
+ MANAGE_NICKNAMES: 'Manage Nicknames',
+ MANAGE_ROLES: 'Manage Roles',
+ MANAGE_WEBHOOKS: 'Manage Webhooks',
+ EMBED_LINKS: 'Embed Links',
+ ATTACH_FILES: 'Attach Files',
+ ADD_REACTIONS: 'Add Reactions',
+ USE_EXTERNAL_EMOJIS: 'Use External Emojis',
+ MENTION_EVERYONE: 'Mention @everyone',
+};
+
+export function hasPermission(perms: number, flag: number): boolean {
+ return (perms & flag) !== 0;
+}
+
+export interface ChannelOverride {
+ id: string;
+ channel_id: string;
+ target_type: 'role' | 'user';
+ target_id: string;
+ allow_bitflags: number;
+ deny_bitflags: number;
+}
+
+interface PermissionState {
+ overridesByChannel: Record;
+ fetchOverrides: (channelId: string) => Promise;
+ setOverride: (channelId: string, targetType: 'role' | 'user', targetId: string, allow: number, deny: number) => Promise;
+ deleteOverride: (channelId: string, targetType: 'role' | 'user', targetId: string) => Promise;
+}
+
+export const usePermissionStore = create((set) => ({
+ overridesByChannel: {},
+
+ fetchOverrides: async (channelId) => {
+ const overrides = await api.get(`/servers/_/channels/${channelId}/permissions`);
+ set((state) => ({
+ overridesByChannel: { ...state.overridesByChannel, [channelId]: Array.isArray(overrides) ? overrides : [] },
+ }));
+ },
+
+ setOverride: async (channelId, targetType, targetId, allow, deny) => {
+ const override = await api.put(`/servers/_/channels/${channelId}/permissions/${targetType}/${targetId}`, { allow, deny });
+ set((state) => {
+ const list = state.overridesByChannel[channelId] || [];
+ const filtered = list.filter((o) => !(o.target_type === targetType && o.target_id === targetId));
+ return { overridesByChannel: { ...state.overridesByChannel, [channelId]: [...filtered, override] } };
+ });
+ return override;
+ },
+
+ deleteOverride: async (channelId, targetType, targetId) => {
+ await api.delete(`/servers/_/channels/${channelId}/permissions/${targetType}/${targetId}`);
+ set((state) => {
+ const list = state.overridesByChannel[channelId] || [];
+ return {
+ overridesByChannel: {
+ ...state.overridesByChannel,
+ [channelId]: list.filter((o) => !(o.target_type === targetType && o.target_id === targetId)),
+ },
+ };
+ });
+ },
+}));
diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo
index 7f839ce..bcde1d6 100644
--- a/web/tsconfig.app.tsbuildinfo
+++ b/web/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file