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