diff --git a/README.md b/README.md
index ca71cea..5e18603 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Three jobs get extra attention:
1. Create an application and a bot at the Discord developer portal.
2. Turn on the **Server Members Intent** and the **Message Content Intent**.
3. Add this redirect: `{PUBLIC_URL}/auth/discord/callback`.
-4. Sign in from the dashboard. Discord asks you to add the bot to a server where you have Manage Server. Sign in again to add another. The permission integer is `1099847265366` (view channels, send messages, embed links, read history, manage messages, manage channels, add reactions, change nickname, kick, ban, manage roles, and moderate members). Change Nickname lets the bot use a display name on that server. Add the server again if an older invite is missing it. The same install, without signing in, is:
+4. Sign in from the dashboard. Discord signs you in and lists servers where SquirrelBot is already installed and you have Manage Server, or a role saved under Access. Someone with Manage Server chooses those roles. Use Add a server when you want Discord to install the bot on another server you manage. The permission integer is `1099847265366` (view channels, send messages, embed links, read history, manage messages, manage channels, add reactions, change nickname, kick, ban, manage roles, and moderate members). Change Nickname lets the bot use a display name on that server. Add the server again if an older invite is missing it. The same install, without signing in, is:
```text
https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=1099847265366&integration_type=0&scope=bot%20applications.commands
diff --git a/e2e/api_test.go b/e2e/api_test.go
index d746423..83e54f8 100644
--- a/e2e/api_test.go
+++ b/e2e/api_test.go
@@ -10,6 +10,7 @@ import (
"io"
"mime/multipart"
"net/http"
+ "net/url"
"os"
"strconv"
"strings"
@@ -69,6 +70,9 @@ func TestHealthAndSignIn(t *testing.T) {
if res.StatusCode != http.StatusFound || !strings.Contains(res.Header.Get("Location"), "https://discord.com/oauth2/authorize") {
t.Fatalf("login redirect %d %s", res.StatusCode, res.Header.Get("Location"))
}
+ if scope := oauthScope(t, res.Header.Get("Location")); scope != "identify guilds" {
+ t.Fatalf("login scope %q", scope)
+ }
var state string
for _, cookie := range res.Cookies() {
if cookie.Name == "sb_oauth_state" {
@@ -80,6 +84,15 @@ func TestHealthAndSignIn(t *testing.T) {
t.Fatal("login did not set the state cookie")
}
+ res = noFollow(t, http.MethodGet, "/auth/discord/invite", nil, "")
+ if res.StatusCode != http.StatusFound {
+ t.Fatalf("invite redirect %d", res.StatusCode)
+ }
+ if scope := oauthScope(t, res.Header.Get("Location")); scope != "identify guilds bot applications.commands" {
+ t.Fatalf("invite scope %q", scope)
+ }
+ res.Body.Close()
+
res = noFollow(t, http.MethodGet, "/auth/discord/callback?error=denied", nil, "")
if res.StatusCode != http.StatusFound || !strings.Contains(res.Header.Get("Location"), "error=denied") {
t.Fatalf("denied callback %d %s", res.StatusCode, res.Header.Get("Location"))
@@ -142,6 +155,57 @@ func TestGuildAccess(t *testing.T) {
if status != http.StatusNotFound {
t.Fatalf("bad id %d %s", status, body)
}
+
+ status, body = call(t, http.MethodGet, "/api/guilds", nil, stack.GuestCookie)
+ if status != http.StatusOK || strings.Contains(string(body), harness.GuildName) {
+ t.Fatalf("guest guild list %d %s", status, body)
+ }
+ status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
+ "roleIds": []string{harness.GuildID},
+ }, stack.Cookie)
+ if status != http.StatusBadRequest {
+ t.Fatalf("everyone role %d %s", status, body)
+ }
+ status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
+ "roleIds": []string{harness.BotRoleID},
+ }, stack.Cookie)
+ if status != http.StatusBadRequest {
+ t.Fatalf("managed role %d %s", status, body)
+ }
+ t.Cleanup(func() {
+ status, body := call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
+ "roleIds": []string{},
+ }, stack.Cookie)
+ if status != http.StatusOK {
+ t.Errorf("clear dashboard roles %d %s", status, body)
+ }
+ })
+ status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
+ "roleIds": []string{harness.MemberRoleID},
+ }, stack.Cookie)
+ if status != http.StatusOK || !strings.Contains(string(body), harness.MemberRoleID) {
+ t.Fatalf("save dashboard roles %d %s", status, body)
+ }
+ status, body = call(t, http.MethodGet, "/api/guilds", nil, stack.GuestCookie)
+ if status != http.StatusOK || !strings.Contains(string(body), harness.GuildName) {
+ t.Fatalf("guest guild list after grant %d %s", status, body)
+ }
+ status, body = call(t, http.MethodGet, "/api/guilds/"+harness.GuildID, nil, stack.GuestCookie)
+ if status != http.StatusOK || !strings.Contains(string(body), `"manageServer":false`) {
+ t.Fatalf("guest bundle %d %s", status, body)
+ }
+ status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
+ "roleIds": []string{harness.AdminRoleID},
+ }, stack.GuestCookie)
+ if status != http.StatusForbidden {
+ t.Fatalf("guest edit roles %d %s", status, body)
+ }
+ status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/automod", map[string]any{
+ "action": "nope",
+ }, stack.GuestCookie)
+ if status != http.StatusBadRequest {
+ t.Fatalf("guest passed the guild gate %d %s", status, body)
+ }
}
func TestDashboardJourney(t *testing.T) {
@@ -438,6 +502,15 @@ func noFollow(t *testing.T, method, path string, payload any, cookie string) *ht
return res
}
+func oauthScope(t *testing.T, location string) string {
+ t.Helper()
+ parsed, err := url.Parse(location)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parsed.Query().Get("scope")
+}
+
func request(t *testing.T, method, path string, payload any, cookie string) *http.Request {
t.Helper()
var body io.Reader
diff --git a/e2e/harness/discord.go b/e2e/harness/discord.go
index 1846f5e..528c50d 100644
--- a/e2e/harness/discord.go
+++ b/e2e/harness/discord.go
@@ -48,6 +48,7 @@ func newDiscord(access, guestAccess, refresh string) *discordFake {
}
members := map[string]member{
UserID: {id: UserID, username: "acorn", globalName: UserName, roles: []string{AdminRoleID}},
+ GuestUserID: {id: GuestUserID, username: "guest", globalName: "Guest", roles: []string{MemberRoleID}},
TargetUserID: {id: TargetUserID, username: TargetUsername, globalName: TargetName, nick: TargetName, roles: []string{MemberRoleID}},
BotUserID: {id: BotUserID, username: "squirrelbot", globalName: "squirrelbot", roles: []string{BotRoleID}, bot: true},
}
diff --git a/internal/api/auth.go b/internal/api/auth.go
index aceaa68..16e25f9 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -71,29 +71,47 @@ func (v *jsonIntString) UnmarshalJSON(data []byte) error {
const botInvitePermissions = "1099847265366"
-const oauthScopes = "identify guilds bot applications.commands"
+const (
+ // loginScopes identifies the user and lists servers they can manage.
+ // It leaves out the bot scope so Discord does not ask them to install SquirrelBot.
+ loginScopes = "identify guilds"
+ // inviteScopes is the same sign-in, plus permission to add the bot to one server.
+ inviteScopes = "identify guilds bot applications.commands"
+)
-func discordAuthorizeURL(clientID, redirectURI, state string) string {
+func discordAuthorizeURL(clientID, redirectURI, state string, invite bool) string {
query := url.Values{}
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
- query.Set("permissions", botInvitePermissions)
- query.Set("integration_type", "0")
query.Set("state", state)
query.Set("prompt", "consent")
+ scopes := loginScopes
+ if invite {
+ query.Set("permissions", botInvitePermissions)
+ query.Set("integration_type", "0")
+ scopes = inviteScopes
+ }
// Discord's authorize page splits scopes on %20. url.Values encodes a space as +.
- scope := strings.ReplaceAll(url.QueryEscape(oauthScopes), "+", "%20")
+ scope := strings.ReplaceAll(url.QueryEscape(scopes), "+", "%20")
return "https://discord.com/oauth2/authorize?" + query.Encode() + "&scope=" + scope
}
func (s *Server) startLogin(c *fiber.Ctx) error {
+ return s.beginOAuth(c, false)
+}
+
+func (s *Server) startInvite(c *fiber.Ctx) error {
+ return s.beginOAuth(c, true)
+}
+
+func (s *Server) beginOAuth(c *fiber.Ctx, invite bool) error {
state, err := randomID()
if err != nil {
return fail(c, fiber.StatusInternalServerError, "Could not start sign-in.")
}
s.setCookie(c, stateCookie, state, time.Now().Add(10*time.Minute))
- return c.Redirect(discordAuthorizeURL(s.cfg.DiscordClientID, s.cfg.PublicURL+"/auth/discord/callback", state), fiber.StatusFound)
+ return c.Redirect(discordAuthorizeURL(s.cfg.DiscordClientID, s.cfg.PublicURL+"/auth/discord/callback", state, invite), fiber.StatusFound)
}
func (s *Server) finishLogin(c *fiber.Ctx) error {
@@ -318,9 +336,19 @@ func (s *Server) requireGuild(c *fiber.Ctx) error {
return fail(c, fiber.StatusNotFound, "Unknown server.")
}
session := c.Locals("session").(store.Session)
- perms, ok := s.permissions(c, session, guildID)
- if !ok || !domain.CanManageGuild(perms) {
- return fail(c, fiber.StatusForbidden, "You need Manage Server on that Discord server.")
+ perms, inGuild := s.permissions(c, session, guildID)
+ manageServer := inGuild && domain.CanManageGuild(perms)
+ if !manageServer {
+ if !inGuild {
+ return fail(c, fiber.StatusForbidden, "You need Manage Server, or a role chosen for dashboard access.")
+ }
+ allowed, err := s.holdsManagerRole(c, guildID, session.UserID)
+ if err != nil {
+ return managerRoleError(c, err)
+ }
+ if !allowed {
+ return fail(c, fiber.StatusForbidden, "You need Manage Server, or a role chosen for dashboard access.")
+ }
}
if _, err := s.store.GetGuild(c.UserContext(), guildID); err != nil {
if errors.Is(err, store.ErrNotFound) {
@@ -330,9 +358,53 @@ func (s *Server) requireGuild(c *fiber.Ctx) error {
return fail(c, fiber.StatusInternalServerError, "Could not load that server.")
}
c.Locals("guildID", guildID)
+ c.Locals("manageServer", manageServer)
return c.Next()
}
+// holdsManagerRole is true when the member has a role saved for dashboard access.
+// OAuth guild permissions do not include role ids, so the bot looks the member up.
+func (s *Server) holdsManagerRole(c *fiber.Ctx, guildID, userID string) (bool, error) {
+ allowed, err := s.store.ManagerRoleIDs(c.UserContext(), guildID)
+ if errors.Is(err, store.ErrNotFound) || (err == nil && len(allowed) == 0) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ held, err := s.memberRoleIDs(guildID, userID)
+ if err != nil {
+ return false, err
+ }
+ return domain.SharesRole(held, allowed), nil
+}
+
+func (s *Server) memberRoleIDs(guildID, userID string) ([]string, error) {
+ if ids, ok := s.roles.get(guildID, userID); ok {
+ return ids, nil
+ }
+ if err := s.requireBot(); err != nil {
+ return nil, err
+ }
+ member, err := s.bot.Session().GuildMember(guildID, userID)
+ if err != nil {
+ return nil, err
+ }
+ ids := member.Roles
+ if ids == nil {
+ ids = []string{}
+ }
+ s.roles.set(guildID, userID, ids)
+ return ids, nil
+}
+
+func managerRoleError(c *fiber.Ctx, err error) error {
+ if err != nil && err.Error() == "The bot is still connecting to Discord." {
+ return fail(c, fiber.StatusServiceUnavailable, err.Error())
+ }
+ return fail(c, fiber.StatusBadGateway, "Discord could not confirm your roles in that server.")
+}
+
func (s *Server) permissions(c *fiber.Ctx, session store.Session, guildID string) (int64, bool) {
guilds, ok := s.access.get(session.ID)
if !ok {
@@ -386,12 +458,18 @@ func (s *Server) guilds(c *fiber.Ctx) error {
bits |= domain.PermAdministrator
}
cached[guild.ID] = bits
- if !domain.CanManageGuild(bits) {
- continue
- }
if _, err := s.store.GetGuild(c.UserContext(), guild.ID); err != nil {
continue
}
+ if !domain.CanManageGuild(bits) {
+ allowed, err := s.holdsManagerRole(c, guild.ID, session.UserID)
+ if err != nil || !allowed {
+ if err != nil {
+ s.log.Warn("dashboard role", "guild", guild.ID, "err", err)
+ }
+ continue
+ }
+ }
out = append(out, item{ID: guild.ID, Name: guild.Name, IconURL: iconURL(guild.ID, guild.Icon)})
}
s.access.set(session.ID, cached)
@@ -482,3 +560,40 @@ func (c *accessCache) drop(sessionID string) {
defer c.mu.Unlock()
delete(c.items, sessionID)
}
+
+type memberRoleCache struct {
+ mu sync.Mutex
+ items map[string]memberRoleEntry
+}
+
+type memberRoleEntry struct {
+ roles []string
+ exp time.Time
+}
+
+func newMemberRoleCache() *memberRoleCache {
+ return &memberRoleCache{items: map[string]memberRoleEntry{}}
+}
+
+func memberRoleKey(guildID, userID string) string {
+ return guildID + "/" + userID
+}
+
+func (c *memberRoleCache) get(guildID, userID string) ([]string, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.items[memberRoleKey(guildID, userID)]
+ if !ok || time.Now().After(entry.exp) {
+ return nil, false
+ }
+ return entry.roles, true
+}
+
+func (c *memberRoleCache) set(guildID, userID string, roles []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(c.items) > 500 {
+ c.items = map[string]memberRoleEntry{}
+ }
+ c.items[memberRoleKey(guildID, userID)] = memberRoleEntry{roles: roles, exp: time.Now().Add(45 * time.Second)}
+}
diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go
index 7a0ffe7..e94d5de 100644
--- a/internal/api/auth_test.go
+++ b/internal/api/auth_test.go
@@ -8,14 +8,41 @@ import (
"testing"
)
+func TestDiscordAuthorizeSignsInWithoutInstalling(t *testing.T) {
+ raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state", false)
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ query := parsed.Query()
+ if query.Get("scope") != loginScopes {
+ t.Fatalf("scope %q", query.Get("scope"))
+ }
+ if !strings.Contains(raw, "scope=identify%20guilds") {
+ t.Fatalf("scope encoding %s", raw)
+ }
+ if strings.Contains(query.Get("scope"), "bot") || strings.Contains(query.Get("scope"), "applications.commands") {
+ t.Fatalf("login asked to install the bot: %s", raw)
+ }
+ if query.Get("permissions") != "" || query.Get("integration_type") != "" {
+ t.Fatalf("login carried invite fields %s", raw)
+ }
+ if query.Get("prompt") != "consent" {
+ t.Fatalf("prompt %q", query.Get("prompt"))
+ }
+ if query.Get("response_type") != "code" {
+ t.Fatalf("response_type %q", query.Get("response_type"))
+ }
+}
+
func TestDiscordAuthorizeInvitesManagedServers(t *testing.T) {
- raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state")
+ raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state", true)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
- if query.Get("scope") != oauthScopes {
+ if query.Get("scope") != inviteScopes {
t.Fatalf("scope %q", query.Get("scope"))
}
if !strings.Contains(raw, "scope=identify%20guilds%20bot%20applications.commands") {
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 1ebd68c..fd505ca 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -93,10 +93,16 @@ func (s *Server) bundle(c *fiber.Ctx) error {
if err != nil {
return fail(c, fiber.StatusInternalServerError, "Could not load the starboard.")
}
+ managerRoles, err := s.store.ManagerRoleIDs(ctx, guildID)
+ if err != nil {
+ return fail(c, fiber.StatusInternalServerError, "Could not load dashboard access.")
+ }
+ manageServer, _ := c.Locals("manageServer").(bool)
return c.JSON(fiber.Map{
"guild": guild, "counts": counts, "nextKick": nextKick,
"channels": channels, "roles": roles, "emojis": emojis, "gate": gate, "automod": automod,
"logging": logs, "escalationRoles": escalationRoles, "starboard": starboard,
+ "managerRoleIds": managerRoles, "manageServer": manageServer,
})
}
@@ -963,9 +969,33 @@ func (s *Server) saveCommand(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(body)
}
+func (s *Server) putManagerRoles(c *fiber.Ctx) error {
+ if allowed, _ := c.Locals("manageServer").(bool); !allowed {
+ return fail(c, fiber.StatusForbidden, "You need Manage Server to choose dashboard roles.")
+ }
+ var body struct {
+ RoleIDs []string `json:"roleIds"`
+ }
+ if err := c.BodyParser(&body); err != nil {
+ return fail(c, fiber.StatusBadRequest, "Could not read that request.")
+ }
+ ids, err := s.checkedRoles(c.Params("guildID"), body.RoleIDs, domain.MaxManagerRoles, "Choose at most 20 roles.")
+ if err != nil {
+ return fail(c, commandRoleStatus(err), err.Error())
+ }
+ if err := s.store.SetManagerRoleIDs(c.UserContext(), c.Params("guildID"), ids); err != nil {
+ return fail(c, fiber.StatusInternalServerError, "Could not save dashboard access.")
+ }
+ return c.JSON(fiber.Map{"roleIds": ids})
+}
+
func (s *Server) checkedCommandRoles(guildID string, ids []string) ([]string, error) {
- if len(ids) > domain.MaxCommandRoles {
- return nil, errors.New("Choose at most 10 roles.")
+ return s.checkedRoles(guildID, ids, domain.MaxCommandRoles, "Choose at most 10 roles.")
+}
+
+func (s *Server) checkedRoles(guildID string, ids []string, max int, tooMany string) ([]string, error) {
+ if len(ids) > max {
+ return nil, errors.New(tooMany)
}
if len(ids) == 0 {
return []string{}, nil
diff --git a/internal/api/server.go b/internal/api/server.go
index a1439a9..83ffda4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -34,6 +34,7 @@ type Server struct {
log *slog.Logger
client *http.Client
access *accessCache
+ roles *memberRoleCache
meta *metaCache
csp string
}
@@ -49,6 +50,7 @@ func New(cfg config.Config, st *store.Store, bot *discord.Bot, log *slog.Logger)
Timeout: 15 * time.Second,
},
access: newAccessCache(),
+ roles: newMemberRoleCache(),
meta: newMetaCache(),
}
s.app = fiber.New(fiber.Config{
@@ -104,6 +106,7 @@ func (s *Server) routes() {
})
s.app.Get("/readyz", s.ready)
s.app.Get("/auth/discord", s.startLogin)
+ s.app.Get("/auth/discord/invite", s.startInvite)
s.app.Get("/auth/discord/callback", s.finishLogin)
s.app.Post("/auth/logout", s.requireSameOrigin, s.logout)
@@ -126,6 +129,7 @@ func (s *Server) routes() {
guild.Delete("/announcement-templates/:templateID", s.deleteAnnouncementTemplate)
guild.Post("/announcement-templates/:templateID/render", s.renderAnnouncementTemplate)
guild.Put("/escalation-roles", s.putEscalationRoles)
+ guild.Put("/manager-roles", s.putManagerRoles)
guild.Get("/escalations", s.escalations)
guild.Post("/escalations", s.createEscalation)
guild.Post("/escalations/:escalationID/revoke", s.revokeEscalation)
diff --git a/internal/db/migrations/013_manager_roles.sql b/internal/db/migrations/013_manager_roles.sql
new file mode 100644
index 0000000..7c7c789
--- /dev/null
+++ b/internal/db/migrations/013_manager_roles.sql
@@ -0,0 +1,2 @@
+ALTER TABLE guilds
+ ADD COLUMN manager_role_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
diff --git a/internal/domain/rules.go b/internal/domain/rules.go
index cd903f0..62eec22 100644
--- a/internal/domain/rules.go
+++ b/internal/domain/rules.go
@@ -33,6 +33,7 @@ const (
MaxCustomResponse = 1500
MaxCommandSource = 8000
MaxCommandRoles = 10
+ MaxManagerRoles = 20
MaxWelcome = 1500
MaxBadWords = 100
MaxBadWordLen = 40
@@ -52,6 +53,16 @@ func CanManageGuild(bits int64) bool {
return HasPermission(bits, PermManageGuild)
}
+// SharesRole reports whether a member holds any role in the allowed list.
+func SharesRole(held, allowed []string) bool {
+ for _, id := range held {
+ if ContainsID(allowed, id) {
+ return true
+ }
+ }
+ return false
+}
+
func ValidSnowflake(id string) bool {
if len(id) < 17 || len(id) > 20 {
return false
diff --git a/internal/domain/rules_test.go b/internal/domain/rules_test.go
index 0264d55..4661d75 100644
--- a/internal/domain/rules_test.go
+++ b/internal/domain/rules_test.go
@@ -82,4 +82,10 @@ func TestPermissions(t *testing.T) {
if !HasPermission(PermManageMessages, PermManageMessages) {
t.Fatal("expected exact permission")
}
+ if !SharesRole([]string{"mod", "member"}, []string{"mod"}) {
+ t.Fatal("expected a shared role")
+ }
+ if SharesRole([]string{"member"}, []string{"mod"}) {
+ t.Fatal("member role is not a dashboard role")
+ }
}
diff --git a/internal/store/guild.go b/internal/store/guild.go
index 1b530a9..300515c 100644
--- a/internal/store/guild.go
+++ b/internal/store/guild.go
@@ -68,6 +68,32 @@ func (s *Store) GetGuild(ctx context.Context, id string) (Guild, error) {
return g, err
}
+func (s *Store) ManagerRoleIDs(ctx context.Context, guildID string) ([]string, error) {
+ var raw []byte
+ err := s.pool.QueryRow(ctx, `SELECT manager_role_ids FROM guilds WHERE id = $1`, guildID).Scan(&raw)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return decodeIDs(raw), nil
+}
+
+func (s *Store) SetManagerRoleIDs(ctx context.Context, guildID string, ids []string) error {
+ tag, err := s.pool.Exec(ctx, `
+ UPDATE guilds SET manager_role_ids = $2, updated_at = now() WHERE id = $1`,
+ guildID, encodeIDs(ids),
+ )
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
func (s *Store) UpdateGuildSettings(ctx context.Context, g Guild) error {
tag, err := s.pool.Exec(ctx, `
UPDATE guilds SET
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..81f7a51
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,22 @@
+{
+ "status": "failed",
+ "failedTests": [
+ "88deb803e271b1301744-7195f617f744608c7428",
+ "88deb803e271b1301744-f60b80876912096fd8b4",
+ "88deb803e271b1301744-92cad8b5e6fbcfdd59ed",
+ "88deb803e271b1301744-7143fe58ea0a7fa3cd51",
+ "88deb803e271b1301744-59cd83c0c3fa0df09a14",
+ "88deb803e271b1301744-7dced382d3d3c83d0807",
+ "88deb803e271b1301744-1c2e31ef631f4bf5adfe",
+ "88deb803e271b1301744-f3407da1c798599f8985",
+ "88deb803e271b1301744-50ada6f9cb9e6134764e",
+ "88deb803e271b1301744-130862c2f92178241df2",
+ "88deb803e271b1301744-a9fbfa1716f016a8a5f0",
+ "88deb803e271b1301744-b9bc056f68567a6eff72",
+ "88deb803e271b1301744-fa2bda3d952a6b9854e2",
+ "88deb803e271b1301744-29538ac1d43e4b49feed",
+ "88deb803e271b1301744-7a8837fa358420ce4dcf",
+ "88deb803e271b1301744-765cd07394e26352ee55",
+ "88deb803e271b1301744-ef9ceff91634fb1dcf2b"
+ ]
+}
\ No newline at end of file
diff --git a/test-results/web-e2e-dashboard-dashboar-73785-ver-and-opens-every-section/error-context.md b/test-results/web-e2e-dashboard-dashboar-73785-ver-and-opens-every-section/error-context.md
new file mode 100644
index 0000000..5ab7b73
--- /dev/null
+++ b/test-results/web-e2e-dashboard-dashboar-73785-ver-and-opens-every-section/error-context.md
@@ -0,0 +1,24 @@
+# Instructions
+
+- Following Playwright test failed.
+- Explain why, be concise, respect Playwright best practices.
+- Provide a snippet of code with the fix, if possible.
+
+# Test info
+
+- Name: web/e2e/dashboard.spec.ts >> dashboard >> lists the server and opens every section
+- Location: web/e2e/dashboard.spec.ts:37:2
+
+# Error details
+
+```
+Error: browserType.launch: Executable doesn't exist at /var/folders/ys/4by_31t93397sdbnxfhcspz40000gn/T/cursor-sandbox-cache/654d084a224a3ea9bf61edb53d261df4/playwright/chromium_headless_shell-1248/chrome-headless-shell-mac-arm64/chrome-headless-shell
+╔════════════════════════════════════════════════════════════╗
+║ Looks like Playwright was just installed or updated. ║
+║ Please run the following command to download new browsers: ║
+║ ║
+║ npx playwright install ║
+║ ║
+║ <3 Playwright Team ║
+╚════════════════════════════════════════════════════════════╝
+```
\ No newline at end of file
diff --git a/test-results/web-e2e-dashboard-signed-o-41089-ed-sign-in-shows-the-banner/error-context.md b/test-results/web-e2e-dashboard-signed-o-41089-ed-sign-in-shows-the-banner/error-context.md
new file mode 100644
index 0000000..eba0354
--- /dev/null
+++ b/test-results/web-e2e-dashboard-signed-o-41089-ed-sign-in-shows-the-banner/error-context.md
@@ -0,0 +1,24 @@
+# Instructions
+
+- Following Playwright test failed.
+- Explain why, be concise, respect Playwright best practices.
+- Provide a snippet of code with the fix, if possible.
+
+# Test info
+
+- Name: web/e2e/dashboard.spec.ts >> signed out >> a refused sign-in shows the banner
+- Location: web/e2e/dashboard.spec.ts:18:2
+
+# Error details
+
+```
+Error: browserType.launch: Executable doesn't exist at /var/folders/ys/4by_31t93397sdbnxfhcspz40000gn/T/cursor-sandbox-cache/654d084a224a3ea9bf61edb53d261df4/playwright/chromium_headless_shell-1248/chrome-headless-shell-mac-arm64/chrome-headless-shell
+╔════════════════════════════════════════════════════════════╗
+║ Looks like Playwright was just installed or updated. ║
+║ Please run the following command to download new browsers: ║
+║ ║
+║ npx playwright install ║
+║ ║
+║ <3 Playwright Team ║
+╚════════════════════════════════════════════════════════════╝
+```
\ No newline at end of file
diff --git a/test-results/web-e2e-dashboard-signed-o-aa93b-s-an-anonymous-visitor-home/error-context.md b/test-results/web-e2e-dashboard-signed-o-aa93b-s-an-anonymous-visitor-home/error-context.md
new file mode 100644
index 0000000..25c5eac
--- /dev/null
+++ b/test-results/web-e2e-dashboard-signed-o-aa93b-s-an-anonymous-visitor-home/error-context.md
@@ -0,0 +1,24 @@
+# Instructions
+
+- Following Playwright test failed.
+- Explain why, be concise, respect Playwright best practices.
+- Provide a snippet of code with the fix, if possible.
+
+# Test info
+
+- Name: web/e2e/dashboard.spec.ts >> signed out >> the dashboard sends an anonymous visitor home
+- Location: web/e2e/dashboard.spec.ts:23:2
+
+# Error details
+
+```
+Error: browserType.launch: Executable doesn't exist at /var/folders/ys/4by_31t93397sdbnxfhcspz40000gn/T/cursor-sandbox-cache/654d084a224a3ea9bf61edb53d261df4/playwright/chromium_headless_shell-1248/chrome-headless-shell-mac-arm64/chrome-headless-shell
+╔════════════════════════════════════════════════════════════╗
+║ Looks like Playwright was just installed or updated. ║
+║ Please run the following command to download new browsers: ║
+║ ║
+║ npx playwright install ║
+║ ║
+║ <3 Playwright Team ║
+╚════════════════════════════════════════════════════════════╝
+```
\ No newline at end of file
diff --git a/test-results/web-e2e-dashboard-signed-o-f7cdf-page-offers-Discord-sign-in/error-context.md b/test-results/web-e2e-dashboard-signed-o-f7cdf-page-offers-Discord-sign-in/error-context.md
new file mode 100644
index 0000000..888696c
--- /dev/null
+++ b/test-results/web-e2e-dashboard-signed-o-f7cdf-page-offers-Discord-sign-in/error-context.md
@@ -0,0 +1,24 @@
+# Instructions
+
+- Following Playwright test failed.
+- Explain why, be concise, respect Playwright best practices.
+- Provide a snippet of code with the fix, if possible.
+
+# Test info
+
+- Name: web/e2e/dashboard.spec.ts >> signed out >> landing page offers Discord sign-in
+- Location: web/e2e/dashboard.spec.ts:10:2
+
+# Error details
+
+```
+Error: browserType.launch: Executable doesn't exist at /var/folders/ys/4by_31t93397sdbnxfhcspz40000gn/T/cursor-sandbox-cache/654d084a224a3ea9bf61edb53d261df4/playwright/chromium_headless_shell-1248/chrome-headless-shell-mac-arm64/chrome-headless-shell
+╔════════════════════════════════════════════════════════════╗
+║ Looks like Playwright was just installed or updated. ║
+║ Please run the following command to download new browsers: ║
+║ ║
+║ npx playwright install ║
+║ ║
+║ <3 Playwright Team ║
+╚════════════════════════════════════════════════════════════╝
+```
\ No newline at end of file
diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts
index d474355..130f723 100644
--- a/web/e2e/dashboard.spec.ts
+++ b/web/e2e/dashboard.spec.ts
@@ -10,6 +10,8 @@ test.describe('signed out', () => {
test('landing page offers Discord sign-in', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Continue with Discord' })).toHaveAttribute('href', '/auth/discord');
+ await expect(page.getByRole('link', { name: 'Add SquirrelBot to a server' })).toHaveAttribute('href', '/auth/discord/invite');
+ await expect(page.getByText('Sign in to open servers where SquirrelBot is already installed.')).toBeVisible();
await expect(page.getByText('Moderation Without Enshittification.')).toBeVisible();
});
@@ -34,12 +36,14 @@ test.describe('dashboard', () => {
test('lists the server and opens every section', async ({ page }) => {
await page.goto('/app');
+ await expect(page.getByRole('link', { name: 'Add a server' })).toHaveAttribute('href', '/auth/discord/invite');
await expect(page.getByRole('link', { name: 'Canopy' })).toBeVisible();
await expect(page.getByText('Acorn')).toBeVisible();
await page.getByRole('link', { name: 'Canopy' }).click();
await expect(page.getByRole('heading', { name: 'On duty' })).toBeVisible();
const sections: [string, string][] = [
+ ['Dashboard access', 'Dashboard access'],
['Profile', 'Profile'],
['Announcements', 'Announcements'],
['Admin Firecall Access', 'Admin Firecall Access'],
@@ -59,6 +63,26 @@ test.describe('dashboard', () => {
}
});
+ test('saves dashboard access roles', async ({ page }) => {
+ await openGuild(page);
+ await page.getByRole('navigation', { name: 'Server sections' }).getByRole('link', { name: 'Dashboard access' }).click();
+ await page.getByRole('combobox', { name: 'Role', exact: true }).selectOption({ label: 'Mods' });
+ await page.getByRole('button', { name: 'Add role' }).click();
+ await page.getByRole('button', { name: 'Save access' }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await expect(page.locator('.list').getByText('Mods')).toBeVisible();
+ await page.reload();
+ await expect(page.locator('.list').getByText('Mods')).toBeVisible();
+ await expect(page.getByLabel('Role name', { exact: true })).toHaveValue('Moderator');
+ await page.getByLabel('Role name', { exact: true }).fill('Night mods');
+ await page.getByRole('button', { name: 'Create role' }).click();
+ await expect(page.getByText('Created Night mods.')).toBeVisible();
+ await expect(page.locator('.list').getByText('Night mods')).toBeVisible();
+ await page.reload();
+ await expect(page.locator('.list').getByText('Night mods')).toBeVisible();
+ await expect(page.getByLabel('Role name', { exact: true })).toHaveValue('Moderator');
+ });
+
test('saves the server profile', async ({ page }) => {
await openGuild(page);
await page.getByRole('link', { name: 'Profile' }).click();
diff --git a/web/src/lib/CreateRole.svelte b/web/src/lib/CreateRole.svelte
index b47daf9..d8a4aa8 100644
--- a/web/src/lib/CreateRole.svelte
+++ b/web/src/lib/CreateRole.svelte
@@ -1,17 +1,20 @@
+
+
Who can open this
++ People with Manage Server can open this dashboard. A role saved here can open it and change settings. Only Manage + Server can change this list. A role SquirrelBot creates has no Discord permissions of its own. Assign it in Discord + to the people who should sign in. +
+ +{#if data.bundle.manageServer} +Or add a role that already exists.
+No extra roles yet. Moderators stay signed out until you add one.
+ {/each} +No extra roles yet.
+ {/each} +You can change settings because you have one of these roles. Someone with Manage Server edits the list.
+{/if} diff --git a/web/src/routes/app/[guildId]/welcome/+page.svelte b/web/src/routes/app/[guildId]/welcome/+page.svelte index 15a005d..7224d08 100644 --- a/web/src/routes/app/[guildId]/welcome/+page.svelte +++ b/web/src/routes/app/[guildId]/welcome/+page.svelte @@ -36,7 +36,7 @@Arrival
The mod and admin roles can use staff commands even when their Discord permissions are narrower. Placeholders in the welcome message: {'{user}'} {'{username}'} {'{server}'} {'{membercount}'}.
+The mod and admin roles can use staff commands even when their Discord permissions are narrower. Add a role under Dashboard access when those people should open this dashboard too. Placeholders in the welcome message: {'{user}'} {'{username}'} {'{server}'} {'{membercount}'}.