feat(phase2): bulk delete + audit log; update roadmap/parity docs
This commit is contained in:
+15
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
||||
|
||||
Compiled 2026-06-30. Guilded data reflects its pre-shutdown state.
|
||||
Compiled 2026-06-30. Updated after Phase 1 completion.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,16 +22,16 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state.
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Direct messages | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Markdown support | ⚠️ basic | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Threads | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Forum channels | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Message search | ❌ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Rich embeds / link unfurling | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
@@ -80,17 +80,17 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state.
|
||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||
| Per-channel permission overrides | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Per-channel permission overrides | 🔄 | ✅ | ✅ | ✅ | ✅ |
|
||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Role colors | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### dumpsterChat Permissions (current)
|
||||
|
||||
`VIEW_CHANNEL`, `SEND_MESSAGES`, `MANAGE_MESSAGES`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_SERVER`, `MANAGE_CHANNELS`, `ADMINISTRATOR`, `CONNECT_VOICE`, `SPEAK_VOICE`, `SHARE_SCREEN`.
|
||||
`VIEW_CHANNEL`, `SEND_MESSAGES`, `MANAGE_MESSAGES`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_SERVER`, `MANAGE_CHANNELS`, `ADMINISTRATOR`, `CONNECT_VOICE`, `SPEAK_VOICE`, `SHARE_SCREEN`, `MUTE_MEMBERS`, `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`.
|
||||
|
||||
**Missing compared to Discord/Guilded:** `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`, `USE_VOICE_ACTIVITY`, `PRIORITY_SPEAKER`, `MUTE_MEMBERS`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`, `REQUEST_TO_SPEAK`, etc.
|
||||
**Missing compared to Discord/Guilded:** `USE_VOICE_ACTIVITY`, `PRIORITY_SPEAKER`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`, `REQUEST_TO_SPEAK`, etc.
|
||||
|
||||
---
|
||||
|
||||
@@ -98,14 +98,14 @@ Compiled 2026-06-30. Guilded data reflects its pre-shutdown state.
|
||||
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
| Kick | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ban | ⚠️ permission exists, UI? | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mute (voice/text) | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Kick | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ban | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mute (voice/text) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Timeout | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Slowmode | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Slowmode | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| AutoMod | ❌ | ✅ | ✅ Flow Bots | ❌ | 🔄 |
|
||||
| Audit log | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Message delete bulk | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Audit log | 🔄 | ✅ | ✅ | ❌ | ✅ |
|
||||
| Message delete bulk | 🔄 | ✅ | ✅ | ❌ | ✅ |
|
||||
| Member screening / applications | ❌ | ❌ | ✅ recruitment | ❌ | ❌ |
|
||||
| Reports | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
|
||||
+28
-47
@@ -6,56 +6,37 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core Chat Polish (2–3 weeks)
|
||||
## Phase 1 — Core Chat Polish ✅ COMPLETE
|
||||
|
||||
> Must-have features that every Guilded user expects. Low risk, high impact.
|
||||
|
||||
### 1.1 Direct Messages
|
||||
- **Backend:** New `conversations` table (id, type: DM/group_dm, created_at), `conversation_members` join table. New `conversation_messages` table or reuse existing `messages` with a nullable `channel_id` and a required `conversation_id`.
|
||||
- **Route:** `POST /conversations` (create DM), `GET /conversations`, `POST /conversations/{id}/messages`, `GET /conversations/{id}/messages`.
|
||||
- **WS events:** `MESSAGE_CREATE` already works; just scope to conversation members.
|
||||
- **Frontend:** New `ConversationsList` component in a sidebar panel (switch between servers view and DMs view). New `DMChatArea` or reuse `ChatArea` with a conversation context.
|
||||
- **Estimated:** 4–6 files backend, 3–4 files frontend.
|
||||
|
||||
### 1.2 Full Markdown Rendering
|
||||
- **Frontend:** Install `react-markdown` + `remark-gfm` (tables, strikethrough, task lists, autolinks). Replace the plain text renderer in `ChatArea` with `<ReactMarkdown>`.
|
||||
- **Backend:** No changes (store raw markdown).
|
||||
- **Estimated:** 1 file frontend.
|
||||
|
||||
### 1.3 Rich Embeds / Link Unfurling
|
||||
- **Backend:** On message create, scan for URLs. For each URL, fetch `<title>`, `<description>`, `<og:image>` via `og:` meta tags. Store in `embeds` table (message_id FK, url, title, description, image_url, color).
|
||||
- **Rate limit:** Max 5 embeds per message, async fetch with 2s timeout, cache for 24h.
|
||||
- **Frontend:** Render embeds as cards below the message (dark card with image, title, description).
|
||||
- **Estimated:** 2 files backend, 1 file frontend.
|
||||
|
||||
### 1.4 Message Search
|
||||
- **Backend:** Add PostgreSQL full-text search. `ALTER TABLE messages ADD COLUMN search_vector tsvector`. Create a GIN index. Add trigger to populate on insert/update.
|
||||
- **Route:** `GET /channels/{channelID}/messages/search?q=...&author_id=...&before=...&after=...&limit=25`.
|
||||
- **Frontend:** Search bar in channel header or a dedicated search panel. Highlight matching terms in results.
|
||||
- **Estimated:** 2 files backend, 2 files frontend.
|
||||
|
||||
### 1.5 Ban / Kick / Mute UI
|
||||
- **Backend:** Already have `KICK_MEMBERS` and `BAN_MEMBERS` permissions. Add:
|
||||
- `DELETE /servers/{serverID}/members/{userID}` (kick)
|
||||
- `POST /servers/{serverID}/bans` (ban, with optional reason and delete_message_days)
|
||||
- `DELETE /servers/{serverID}/bans/{userID}` (unban)
|
||||
- `GET /servers/{serverID}/bans` (list bans)
|
||||
- `bans` table (server_id, user_id, reason, banned_by, created_at)
|
||||
- Mute: `server_mutes` table (server_id, user_id, muted_by, expires_at, reason). New permission `MUTE_MEMBERS`. Muted users cannot send messages or speak in voice.
|
||||
- **Frontend:** Context menu on member list items (right-click or three-dot menu) with Kick/Ban/Mute options. Confirmation modals for each action.
|
||||
- **Estimated:** 3 files backend, 2 files frontend.
|
||||
|
||||
### 1.6 Slowmode
|
||||
- **Backend:** Add `slowmode_seconds` column to `channels`. New permission `MANAGE_SLOWMODE`. In message Create handler, check if user's last message in this channel was within `slowmode_seconds`; if so, return 429 with retry-after.
|
||||
- **Frontend:** Show cooldown timer on the message input when slowmode is active. Channel settings to configure slowmode (0/5/10/30/60/120/300 seconds).
|
||||
- **Estimated:** 2 files backend, 2 files frontend.
|
||||
| # | Feature | Status |
|
||||
|---|---------|--------|
|
||||
| 1.1 | Direct Messages | ✅ Backend + frontend live |
|
||||
| 1.2 | Full Markdown Rendering | ✅ `react-markdown` + `remark-gfm` |
|
||||
| 1.3 | Rich Embeds / Link Unfurling | ✅ `internal/embed`, `embeds` table |
|
||||
| 1.4 | Message Search | ✅ PostgreSQL full-text search |
|
||||
| 1.5 | Ban / Kick / Mute UI | ✅ `internal/moderation`, `MemberContextMenu` |
|
||||
| 1.6 | Slowmode | ✅ DB column + message handler + UI |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Moderation & Permissions (2–3 weeks)
|
||||
## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS
|
||||
|
||||
> 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.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.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.
|
||||
- **Route:** `PUT /channels/{channelID}/permissions/{targetType}/{targetID}`, `GET /channels/{channelID}/permissions`, `DELETE /channels/{channelID}/permissions/{targetType}/{targetID}`.
|
||||
@@ -63,14 +44,14 @@
|
||||
- **Estimated:** 3 files backend, 2 files frontend.
|
||||
|
||||
### 2.2 Expanded Permission Flags
|
||||
- Add: `CREATE_INSTANT_INVITE`, `CHANGE_NICKNAME`, `MANAGE_NICKNAMES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `EMBED_LINKS`, `ATTACH_FILES`, `ADD_REACTIONS`, `USE_EXTERNAL_EMOJIS`, `MENTION_EVERYONE`, `MUTE_MEMBERS`, `DEAFEN_MEMBERS`, `MOVE_MEMBERS`.
|
||||
- Update permission checker to gate the corresponding handlers.
|
||||
- **Estimated:** 2 files backend.
|
||||
- **Status:** ✅ Bits already defined and seeding default permissions.
|
||||
- **Remaining:** Gate the corresponding handlers (webhooks, file upload, external emojis, mention everyone, voice mute/deaf/move).
|
||||
- **Estimated:** 1 file backend.
|
||||
|
||||
### 2.3 Role Colors & Hierarchy
|
||||
- **Backend:** Add `color` (hex string) and `position` (integer) columns to `roles`. Permission resolution uses highest-position role's color.
|
||||
- **Frontend:** Role editor with color picker. Member names shown in their highest role color in chat and member list.
|
||||
- **Estimated:** 2 files backend, 2 files frontend.
|
||||
- **Status:** ✅ `color` and `position` columns already exist on `roles`.
|
||||
- **Remaining:** Render member names in highest-position role color in chat and member list.
|
||||
- **Estimated:** 2 files frontend.
|
||||
|
||||
### 2.4 Audit Log
|
||||
- **Backend:** New `audit_log` table (server_id, user_id, action_type, target_type, target_id, reason, changes JSONB, created_at). Instrument all moderation actions (kick, ban, mute, role changes, channel CRUD, server settings changes) to write audit entries.
|
||||
|
||||
+33
-5
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
_ "git.dustin.coffee/hobokenchicken/dumpsterChat/docs"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/audit"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
|
||||
@@ -142,22 +143,49 @@ func main() {
|
||||
r.Get("/", serverHandler.List)
|
||||
|
||||
modHandler := moderation.NewHandler(database.DB, hub)
|
||||
auditLogger := audit.NewLogger(database.DB)
|
||||
auditHandler := audit.NewHandler(database.DB)
|
||||
|
||||
r.Route("/{serverID}", func(r chi.Router) {
|
||||
r.Get("/", serverHandler.Get)
|
||||
r.Patch("/", serverHandler.Update)
|
||||
r.Delete("/", serverHandler.Delete)
|
||||
|
||||
// Audit log
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MANAGE_SERVER)).Get("/audit-log", auditHandler.List)
|
||||
|
||||
// Server members
|
||||
memberHandler.RegisterRoutes(r)
|
||||
|
||||
// Moderation
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", modHandler.Kick)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", modHandler.Ban)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.KICK_MEMBERS)).Delete("/members/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
modHandler.Kick(w, r)
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionKick, "user", targetID, "", nil)
|
||||
})
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Post("/bans", func(w http.ResponseWriter, r *http.Request) {
|
||||
modHandler.Ban(w, r)
|
||||
})
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Get("/bans", modHandler.ListBans)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", modHandler.Unban)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", modHandler.Mute)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", modHandler.Unmute)
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.BAN_MEMBERS)).Delete("/bans/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
modHandler.Unban(w, r)
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnban, "user", targetID, "", nil)
|
||||
})
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Post("/mutes", func(w http.ResponseWriter, r *http.Request) {
|
||||
modHandler.Mute(w, r)
|
||||
})
|
||||
r.With(middleware.RequirePermission(permissionsChecker, permissions.MUTE_MEMBERS)).Delete("/mutes/{userID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
modHandler.Unmute(w, r)
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||
_ = auditLogger.Log(r.Context(), serverID, userID, audit.ActionUnmute, "user", targetID, "", nil)
|
||||
})
|
||||
|
||||
// Channels (nested under servers)
|
||||
r.Route("/channels", func(r chi.Router) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Action type constants.
|
||||
const (
|
||||
ActionKick = "KICK"
|
||||
ActionBan = "BAN"
|
||||
ActionUnban = "UNBAN"
|
||||
ActionMute = "MUTE"
|
||||
ActionUnmute = "UNMUTE"
|
||||
ActionRoleCreate = "ROLE_CREATE"
|
||||
ActionRoleUpdate = "ROLE_UPDATE"
|
||||
ActionRoleDelete = "ROLE_DELETE"
|
||||
ActionMemberRoles = "MEMBER_ROLES_UPDATE"
|
||||
ActionChannelCreate = "CHANNEL_CREATE"
|
||||
ActionChannelUpdate = "CHANNEL_UPDATE"
|
||||
ActionChannelDelete = "CHANNEL_DELETE"
|
||||
ActionServerUpdate = "SERVER_UPDATE"
|
||||
ActionMessageDelete = "MESSAGE_DELETE"
|
||||
ActionBulkDelete = "BULK_DELETE"
|
||||
)
|
||||
|
||||
// Logger writes audit log entries to the database.
|
||||
type Logger struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLogger creates a new audit logger.
|
||||
func NewLogger(db *sql.DB) *Logger {
|
||||
return &Logger{db: db}
|
||||
}
|
||||
|
||||
// Log records a single audit entry.
|
||||
func (l *Logger) Log(ctx context.Context, serverID, userID, actionType, targetType, targetID, reason string, changes map[string]interface{}) error {
|
||||
if l == nil || l.db == nil {
|
||||
return nil
|
||||
}
|
||||
var changesJSON []byte
|
||||
var err error
|
||||
if len(changes) > 0 {
|
||||
changesJSON, err = json.Marshal(changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = l.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (server_id, user_id, action_type, target_type, target_id, reason, changes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, serverID, userID, actionType, targetType, targetID, reason, changesJSON)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler exposes the audit log read API.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewHandler creates a new audit log handler.
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
// Entry represents a single audit log entry.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
UserID *string `json:"user_id"`
|
||||
Username *string `json:"username"`
|
||||
ActionType string `json:"action_type"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
Reason *string `json:"reason"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List handles GET /servers/{serverID}/audit-log.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// ponytail: membership check keeps it simple; MANAGE_SERVER permission is the real gate.
|
||||
var isMember bool
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM members WHERE server_id = $1 AND user_id = $2)`, serverID, userID).Scan(&isMember)
|
||||
if err != nil || !isMember {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 50
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
actionType := r.URL.Query().Get("action_type")
|
||||
before := r.URL.Query().Get("before")
|
||||
|
||||
args := []interface{}{serverID}
|
||||
query := `
|
||||
SELECT a.id, a.server_id, a.user_id, u.username, a.action_type, a.target_type, a.target_id, a.reason, a.changes, a.created_at::text
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.server_id = $1
|
||||
`
|
||||
if actionType != "" {
|
||||
args = append(args, actionType)
|
||||
query += ` AND a.action_type = $` + strconv.Itoa(len(args))
|
||||
}
|
||||
if before != "" {
|
||||
args = append(args, before)
|
||||
query += ` AND a.created_at < (SELECT created_at FROM audit_log WHERE id = $` + strconv.Itoa(len(args)) + `)`
|
||||
}
|
||||
query += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]Entry, 0)
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var userIDStr, username, targetType, targetID, reason sql.NullString
|
||||
err := rows.Scan(&e.ID, &e.ServerID, &userIDStr, &username, &e.ActionType, &targetType, &targetID, &reason, &e.Changes, &e.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if userIDStr.Valid {
|
||||
e.UserID = &userIDStr.String
|
||||
}
|
||||
if username.Valid {
|
||||
e.Username = &username.String
|
||||
}
|
||||
if targetType.Valid {
|
||||
e.TargetType = &targetType.String
|
||||
}
|
||||
if targetID.Valid {
|
||||
e.TargetID = &targetID.String
|
||||
}
|
||||
if reason.Valid {
|
||||
e.Reason = &reason.String
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
}
|
||||
|
||||
// Cleanup removes audit log entries older than 90 days.
|
||||
func (h *Handler) Cleanup(ctx context.Context) error {
|
||||
_, err := h.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '90 days'`)
|
||||
return err
|
||||
}
|
||||
@@ -284,6 +284,38 @@ CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
|
||||
-- Channel slowmode
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Audit log
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action_type VARCHAR(32) NOT NULL,
|
||||
target_type VARCHAR(32),
|
||||
target_id VARCHAR(64),
|
||||
reason TEXT,
|
||||
changes JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_server_created ON audit_log(server_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(server_id, action_type);
|
||||
|
||||
-- Channel permission overrides
|
||||
CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
target_type VARCHAR(8) NOT NULL CHECK (target_type IN ('role', 'user')),
|
||||
target_id UUID NOT NULL,
|
||||
allow_bitflags BIGINT NOT NULL DEFAULT 0,
|
||||
deny_bitflags BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (channel_id, target_type, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
||||
|
||||
-- Message full-text search
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -46,10 +48,121 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/search", h.Search)
|
||||
r.Post("/bulk-delete", h.BulkDelete)
|
||||
r.Patch("/{messageID}", h.Update)
|
||||
r.Delete("/{messageID}", h.Delete)
|
||||
}
|
||||
|
||||
type bulkDeleteRequest struct {
|
||||
Messages []string `json:"messages"`
|
||||
}
|
||||
|
||||
type bulkDeleteResponse struct {
|
||||
Deleted int `json:"deleted"`
|
||||
}
|
||||
|
||||
// BulkDelete deletes up to 100 messages in a channel. Requires MANAGE_MESSAGES
|
||||
// and messages must be within the last 14 days.
|
||||
func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
userID, serverID, ok := h.requireChannelAccess(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// ponytail: reuse existing auth structure; requireChannelAccess already verifies membership.
|
||||
// Permission check uses the server's permission checker via the caller's middleware chain.
|
||||
allowed, err := h.checkPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req bulkDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: 0})
|
||||
return
|
||||
}
|
||||
if len(req.Messages) > 100 {
|
||||
http.Error(w, `{"error":"too many messages (max 100)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// ponytail: simple IN query with 14-day guard and channel_id filter in DB.
|
||||
placeholders := make([]string, len(req.Messages))
|
||||
args := make([]interface{}, 0, len(req.Messages)+1)
|
||||
for i, id := range req.Messages {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, channelID)
|
||||
query := fmt.Sprintf(`
|
||||
DELETE FROM messages
|
||||
WHERE id IN (%s) AND channel_id = $%d AND created_at > NOW() - INTERVAL '14 days'
|
||||
`, strings.Join(placeholders, ","), len(args))
|
||||
res, err := h.db.ExecContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to delete messages"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
if h.hub != nil {
|
||||
for _, id := range req.Messages {
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": id,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: int(deleted)})
|
||||
}
|
||||
|
||||
// checkPermission is a helper wrapper around a permission checker lookup.
|
||||
func (h *Handler) checkPermission(ctx context.Context, serverID, userID string, perm int64) (bool, error) {
|
||||
// ponytail: keep it minimal; caller already has serverID. If no checker is wired, fall back to true only for admins.
|
||||
rows, err := h.db.QueryContext(ctx, `
|
||||
SELECT r.permissions 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 false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var effective int64
|
||||
for rows.Next() {
|
||||
var p int64
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return false, err
|
||||
}
|
||||
effective |= p
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
var everyone int64
|
||||
_ = h.db.QueryRowContext(ctx, `
|
||||
SELECT permissions FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1
|
||||
`, serverID).Scan(&everyone)
|
||||
effective |= everyone
|
||||
return permissions.Has(effective, permissions.ADMINISTRATOR) || permissions.Has(effective, perm), nil
|
||||
}
|
||||
|
||||
type embedResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
|
||||
@@ -114,9 +114,18 @@ export function ChatArea() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
||||
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
|
||||
const bulkDeleteMessages = useMessageStore((s) => s.bulkDeleteMessages);
|
||||
const selectedIds = useMessageStore((s) =>
|
||||
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
|
||||
);
|
||||
const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth
|
||||
|
||||
const channels = activeServerId
|
||||
? channelsByServer[activeServerId] || []
|
||||
: [];
|
||||
@@ -241,13 +250,22 @@ export function ChatArea() {
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
</span>
|
||||
{activeChannelId && (
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectMode((prev) => !prev)}
|
||||
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Toggle bulk delete selection"
|
||||
>
|
||||
[SELECT]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
@@ -257,13 +275,50 @@ export function ChatArea() {
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
{selectMode && activeChannelId && (
|
||||
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
||||
<span>{selectedIds.size} selected</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => clearSelectedMessages(activeChannelId)}
|
||||
className="text-gb-fg-f hover:text-gb-orange"
|
||||
>
|
||||
[CLEAR]
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!activeChannelId || selectedIds.size === 0) return;
|
||||
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
|
||||
try {
|
||||
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Bulk delete failed');
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0 || !canBulkDelete}
|
||||
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="break-words">
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
|
||||
>
|
||||
{selectMode && activeChannelId && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">
|
||||
[{formatTime(message.created_at)}]
|
||||
</span>{" "}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChannelList } from './ChannelList.tsx';
|
||||
import { ConversationList } from './ConversationList.tsx';
|
||||
import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
function statusColor(status: UserStatus): string {
|
||||
switch (status) {
|
||||
@@ -38,6 +39,8 @@ export function Layout() {
|
||||
const location = useLocation();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
const [dmMode, setDmMode] = useState(false);
|
||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
useEffect(() => {
|
||||
fetchMe();
|
||||
wsConnect();
|
||||
@@ -101,6 +104,14 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
||||
{activeServerId && (
|
||||
<button
|
||||
onClick={() => setShowServerSettings(true)}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[SERVER SETTINGS]
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||
[LOGOUT]
|
||||
</button>
|
||||
@@ -146,6 +157,9 @@ export function Layout() {
|
||||
</div>
|
||||
{!dmMode && <MemberList />}
|
||||
</div>
|
||||
{showServerSettings && activeServerId && (
|
||||
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
||||
)}
|
||||
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
|
||||
<span>TERM v1.0</span>
|
||||
<span>{new Date().toISOString().slice(0, 10)}</span>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
|
||||
interface ServerSettingsModalProps {
|
||||
serverId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
server_id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
action_type: string;
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
reason: string | null;
|
||||
changes: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
KICK: 'kicked member',
|
||||
BAN: 'banned member',
|
||||
UNBAN: 'unbanned member',
|
||||
MUTE: 'muted member',
|
||||
UNMUTE: 'unmuted member',
|
||||
ROLE_CREATE: 'created role',
|
||||
ROLE_UPDATE: 'updated role',
|
||||
ROLE_DELETE: 'deleted role',
|
||||
MEMBER_ROLES_UPDATE: 'updated member roles',
|
||||
CHANNEL_CREATE: 'created channel',
|
||||
CHANNEL_UPDATE: 'updated channel',
|
||||
CHANNEL_DELETE: 'deleted channel',
|
||||
SERVER_UPDATE: 'updated server',
|
||||
MESSAGE_DELETE: 'deleted message',
|
||||
BULK_DELETE: 'bulk deleted messages',
|
||||
};
|
||||
|
||||
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
|
||||
const [tab, setTab] = useState<'audit'>('audit');
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const server = servers.find((s) => s.id === serverId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'audit') return;
|
||||
setLoading(true);
|
||||
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
|
||||
.then((data) => setEntries(Array.isArray(data) ? data : []))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab, serverId]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
<span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
</div>
|
||||
<div className="flex border-b border-gb-bg-t">
|
||||
<button
|
||||
onClick={() => setTab('audit')}
|
||||
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
>
|
||||
[AUDIT LOG]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{tab === 'audit' && (
|
||||
<>
|
||||
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
|
||||
{error && <p className="text-gb-red text-xs">ERR: {error}</p>}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs">[no audit log entries]</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="text-xs border border-gb-bg-t p-2 bg-gb-bg-s">
|
||||
<div className="flex items-center justify-between text-gb-fg-f">
|
||||
<span>{formatTime(entry.created_at)}</span>
|
||||
<span className="text-gb-orange">{entry.action_type}</span>
|
||||
</div>
|
||||
<div className="text-gb-fg mt-1">
|
||||
<span className="text-gb-aqua">{entry.username ?? entry.user_id ?? 'system'}</span>
|
||||
{' '}<span className="text-gb-fg-s">{ACTION_LABELS[entry.action_type] ?? entry.action_type}</span>
|
||||
{entry.target_id && (
|
||||
<span className="text-gb-fg-f"> target:{entry.target_id}</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.reason && (
|
||||
<div className="text-gb-fg-f mt-1">reason: {entry.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,10 @@ export interface SearchResultMessage extends Message {
|
||||
channel_name?: string;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
export interface MessageState {
|
||||
messagesByChannel: Record<string, Message[]>;
|
||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
||||
@@ -38,11 +39,15 @@ interface MessageState {
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (message: Message) => void;
|
||||
removeMessage: (channelId: string, messageId: string) => void;
|
||||
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
|
||||
toggleSelectedMessage: (channelId: string, messageId: string) => void;
|
||||
clearSelectedMessages: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set) => ({
|
||||
messagesByChannel: {},
|
||||
searchResultsByChannel: {},
|
||||
selectedMessageIds: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
@@ -140,4 +145,49 @@ export const useMessageStore = create<MessageState>((set) => ({
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
bulkDeleteMessages: async (channelId, messageIds) => {
|
||||
const res = await api.post<{ deleted: number }>(
|
||||
`/channels/${channelId}/messages/bulk-delete`,
|
||||
{ messages: messageIds },
|
||||
);
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const ids = new Set(messageIds);
|
||||
const selected = { ...state.selectedMessageIds };
|
||||
delete selected[channelId];
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[channelId]: list.filter((m) => !ids.has(m.id)),
|
||||
},
|
||||
selectedMessageIds: selected,
|
||||
};
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
toggleSelectedMessage: (channelId, messageId) =>
|
||||
set((state) => {
|
||||
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
||||
const next = new Set(current);
|
||||
if (next.has(messageId)) {
|
||||
next.delete(messageId);
|
||||
} else {
|
||||
next.add(messageId);
|
||||
}
|
||||
return {
|
||||
selectedMessageIds: {
|
||||
...state.selectedMessageIds,
|
||||
[channelId]: next,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
clearSelectedMessages: (channelId) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedMessageIds };
|
||||
delete next[channelId];
|
||||
return { selectedMessageIds: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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/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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user