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 @@ + +Dashboard access · SquirrelBot +

Who can open this

+

Dashboard access

+

+ 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.

+ + + +
+ + + {#if notice}{/if} +
+
+ {#each roleIds as id (id)} +
+
+ {roleName(id)} + +
+
+ {:else} +

No extra roles yet. Moderators stay signed out until you add one.

+ {/each} +
+
+{:else} +
+ {#each data.bundle.managerRoleIds as id (id)} +
{roleName(id)}
+ {:else} +

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 @@ Welcome · SquirrelBot

Arrival

Welcome

-

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}'}.

{ event.preventDefault(); save(); }}>