Skip to content

Commit 1cf903a

Browse files
author
hodyhq
committed
feat(banner): admin-controlled site banner above the header
Adds a tenant-scoped notice that appears above the page header for maintenance windows, releases, or incident announcements. Off by default for existing installs; admin opt-in via Site Settings → Banner. Schema - tenants.site_banner_enabled BOOL NOT NULL DEFAULT FALSE - tenants.site_banner_message TEXT NOT NULL DEFAULT '' - tenants.site_banner_variant VARCHAR(20) NOT NULL DEFAULT 'info' - Migration is additive — no behavior change on upgrade. Backend - New action UpdateTenantSiteBanner validates variant against an allowlist (info|success|warning|danger|brand) and rejects an enabled banner with empty message. Max 500 chars. - New cmd + postgres handler updates the three columns in one UPDATE. - New POST /_api/admin/settings/site-banner endpoint. - Tenant entity carries the three fields; rendered via existing Tenant context in views/base.html immediately inside <body>. Frontend - /admin/banner page with toggle / variant dropdown / 500-char message textarea / live preview / per-keystroke counter. - _site-banner.scss owns the five variant palettes (info teal, success green, warning amber, danger red, brand var --color-primary). Responsive: tightens padding/font under 640px. - No customer-facing render changes when disabled — the conditional in views/base.html elides the <div> entirely.
1 parent 0914541 commit 1cf903a

19 files changed

Lines changed: 316 additions & 13 deletions

File tree

app/actions/tenant.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package actions
33
import (
44
"context"
55
"fmt"
6+
"strings"
67
"time"
78

89
"github.com/getfider/fider/app/models/query"
@@ -280,6 +281,48 @@ func (action *UpdateTenantAdvancedSettings) Validate(ctx context.Context, user *
280281
return validate.Success()
281282
}
282283

284+
// UpdateTenantSiteBanner is the input model used to toggle and edit the
285+
// site-wide banner shown above the page header.
286+
type UpdateTenantSiteBanner struct {
287+
Enabled bool `json:"enabled"`
288+
Message string `json:"message"`
289+
Variant string `json:"variant"`
290+
}
291+
292+
// IsAuthorized returns true if current user is authorized to perform this action
293+
func (action *UpdateTenantSiteBanner) IsAuthorized(ctx context.Context, user *entity.User) bool {
294+
return user != nil && user.Role == enum.RoleAdministrator
295+
}
296+
297+
// Validate if current model is valid
298+
func (action *UpdateTenantSiteBanner) Validate(ctx context.Context, user *entity.User) *validate.Result {
299+
result := validate.Success()
300+
301+
allowedVariants := map[string]bool{
302+
"info": true,
303+
"success": true,
304+
"warning": true,
305+
"danger": true,
306+
"brand": true,
307+
}
308+
if action.Variant == "" {
309+
action.Variant = "info"
310+
}
311+
if !allowedVariants[action.Variant] {
312+
result.AddFieldFailure("variant", "Variant must be one of info, success, warning, danger, brand.")
313+
}
314+
315+
if len(action.Message) > 500 {
316+
result.AddFieldFailure("message", "Banner message must be 500 characters or fewer.")
317+
}
318+
319+
if action.Enabled && strings.TrimSpace(action.Message) == "" {
320+
result.AddFieldFailure("message", "Banner message is required when the banner is enabled.")
321+
}
322+
323+
return result
324+
}
325+
283326
// UpdateTenantPrivacySettings is the input model used to update tenant privacy settings
284327
type UpdateTenantPrivacySettings struct {
285328
IsPrivate bool `json:"isPrivate"`

app/cmd/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ func routes(r *web.Engine) *web.Engine {
170170

171171
ui.Get("/admin", handlers.GeneralSettingsPage())
172172
ui.Get("/admin/advanced", handlers.AdvancedSettingsPage())
173+
ui.Get("/admin/banner", handlers.ManageBanner())
173174
ui.Get("/admin/privacy", handlers.Page("Privacy · Site Settings", "", "Administration/pages/PrivacySettings.page"))
174175
ui.Get("/admin/invitations", handlers.Page("Invitations · Site Settings", "", "Administration/pages/Invitations.page"))
175176
ui.Get("/admin/users", handlers.ManageMembers())
@@ -211,6 +212,7 @@ func routes(r *web.Engine) *web.Engine {
211212
ui.Post("/_api/admin/settings/advanced", handlers.UpdateAdvancedSettings())
212213
ui.Post("/_api/admin/settings/privacy", handlers.UpdatePrivacySettings())
213214
ui.Post("/_api/admin/settings/emailauth", handlers.UpdateEmailAuthAllowed())
215+
ui.Post("/_api/admin/settings/site-banner", handlers.UpdateSiteBanner())
214216
ui.Post("/_api/admin/oauth", handlers.SaveOAuthConfig())
215217
ui.Post("/_api/admin/oauth/:provider/status", handlers.SetSystemProviderStatus())
216218
ui.Post("/_api/admin/roles/:role/users", handlers.ChangeUserRole())

app/handlers/admin.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ func UpdateAdvancedSettings() web.HandlerFunc {
9696
}
9797
}
9898

99+
// UpdateSiteBanner toggles and edits the site-wide banner shown above the header.
100+
func UpdateSiteBanner() web.HandlerFunc {
101+
return func(c *web.Context) error {
102+
action := new(actions.UpdateTenantSiteBanner)
103+
if result := c.BindTo(action); !result.Ok {
104+
return c.HandleValidation(result)
105+
}
106+
107+
if err := bus.Dispatch(c, &cmd.UpdateTenantSiteBanner{
108+
Enabled: action.Enabled,
109+
Message: action.Message,
110+
Variant: action.Variant,
111+
}); err != nil {
112+
return c.Failure(err)
113+
}
114+
115+
return c.Ok(web.Map{})
116+
}
117+
}
118+
99119
// UpdatePrivacySettings update current tenant's privacy settings
100120
func UpdatePrivacySettings() web.HandlerFunc {
101121
return func(c *web.Context) error {

app/handlers/banner_page.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package handlers
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/getfider/fider/app/pkg/web"
7+
)
8+
9+
// ManageBanner renders the admin page where tenants configure the
10+
// site-wide banner shown above the page header.
11+
func ManageBanner() web.HandlerFunc {
12+
return func(c *web.Context) error {
13+
tenant := c.Tenant()
14+
return c.Page(http.StatusOK, web.Props{
15+
Page: "Administration/pages/ManageBanner.page",
16+
Title: "Site Banner · Site Settings",
17+
Data: web.Map{
18+
"siteBannerEnabled": tenant.SiteBannerEnabled,
19+
"siteBannerMessage": tenant.SiteBannerMessage,
20+
"siteBannerVariant": tenant.SiteBannerVariant,
21+
},
22+
})
23+
}
24+
}

app/models/cmd/tenant.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,12 @@ type CancelTenantDeletion struct {
9696
type DeleteTenant struct {
9797
TenantID int
9898
}
99+
100+
// UpdateTenantSiteBanner toggles and configures the site-wide banner shown
101+
// above the page header. Used by /_api/admin/settings/site-banner.
102+
type UpdateTenantSiteBanner struct {
103+
TenantID int
104+
Enabled bool
105+
Message string
106+
Variant string
107+
}

app/models/entity/tenant.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type Tenant struct {
2727
PreventIndexing bool `json:"preventIndexing"`
2828
IsModerationEnabled bool `json:"isModerationEnabled"`
2929
IsPro bool `json:"isPro"`
30+
SiteBannerEnabled bool `json:"siteBannerEnabled"`
31+
SiteBannerMessage string `json:"siteBannerMessage"`
32+
SiteBannerVariant string `json:"siteBannerVariant"`
3033
// ScheduledDeletionAt is set when the account owner has requested deletion of the whole
3134
// site. The tenant stays active during the grace window; a background job performs the
3235
// hard delete once this time passes. Not exposed to clients.

app/pkg/web/testdata/home_ssr.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ <h2 class="text-display2">Please enable JavaScript</h2>
4545

4646
<script id="server-data" type="application/json">
4747

48-
{"contextID":"CONTEXT_ID","description":"My Page Description","page":"Test.page","props":{"countPerStatus":{},"posts":[],"tags":[]},"sessionID":"","settings":{"allowAllowedSchemes":true,"assetsURL":"https://demo.test.fider.io:3000","baseURL":"https://demo.test.fider.io:3000","domain":".test.fider.io","environment":"test","googleAnalytics":"","hasLegal":true,"isBillingEnabled":false,"locale":"en","localeDirection":"ltr","mode":"multi","oauth":[],"postWithTags":true,"version":"dev"},"tenant":{"id":0,"name":"","subdomain":"","invitation":"","welcomeMessage":"","welcomeHeader":"","descriptionTemplate":"","cname":"","status":0,"locale":"en","isPrivate":false,"logoBlobKey":"","allowedSchemes":"","isEmailAuthAllowed":false,"isFeedEnabled":false,"preventIndexing":false,"isModerationEnabled":false,"isPro":false},"title":"My Page Title · "}
48+
{"contextID":"CONTEXT_ID","description":"My Page Description","page":"Test.page","props":{"countPerStatus":{},"posts":[],"tags":[]},"sessionID":"","settings":{"allowAllowedSchemes":true,"assetsURL":"https://demo.test.fider.io:3000","baseURL":"https://demo.test.fider.io:3000","domain":".test.fider.io","environment":"test","googleAnalytics":"","hasLegal":true,"isBillingEnabled":false,"locale":"en","localeDirection":"ltr","mode":"multi","oauth":[],"postWithTags":true,"version":"dev"},"tenant":{"id":0,"name":"","subdomain":"","invitation":"","welcomeMessage":"","welcomeHeader":"","descriptionTemplate":"","cname":"","status":0,"locale":"en","isPrivate":false,"logoBlobKey":"","allowedSchemes":"","isEmailAuthAllowed":false,"isFeedEnabled":false,"preventIndexing":false,"isModerationEnabled":false,"isPro":false,"siteBannerEnabled":false,"siteBannerMessage":"","siteBannerVariant":""},"title":"My Page Title · "}
4949

5050
</script>
5151

app/pkg/web/testdata/tenant.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ <h2 class="text-display2">Please enable JavaScript</h2>
4545

4646
<script id="server-data" type="application/json">
4747

48-
{"contextID":"CONTEXT_ID","page":"","props":{},"sessionID":"","settings":{"allowAllowedSchemes":true,"assetsURL":"https://demo.test.fider.io:3000","baseURL":"https://demo.test.fider.io:3000","domain":".test.fider.io","environment":"test","googleAnalytics":"","hasLegal":true,"isBillingEnabled":false,"locale":"en","localeDirection":"ltr","mode":"multi","oauth":[],"postWithTags":true,"version":"dev"},"tenant":{"id":0,"name":"Game of Thrones","subdomain":"","invitation":"","welcomeMessage":"","welcomeHeader":"","descriptionTemplate":"","cname":"","status":0,"locale":"","isPrivate":false,"logoBlobKey":"","allowedSchemes":"","isEmailAuthAllowed":false,"isFeedEnabled":false,"preventIndexing":false,"isModerationEnabled":false,"isPro":false},"title":"Game of Thrones"}
48+
{"contextID":"CONTEXT_ID","page":"","props":{},"sessionID":"","settings":{"allowAllowedSchemes":true,"assetsURL":"https://demo.test.fider.io:3000","baseURL":"https://demo.test.fider.io:3000","domain":".test.fider.io","environment":"test","googleAnalytics":"","hasLegal":true,"isBillingEnabled":false,"locale":"en","localeDirection":"ltr","mode":"multi","oauth":[],"postWithTags":true,"version":"dev"},"tenant":{"id":0,"name":"Game of Thrones","subdomain":"","invitation":"","welcomeMessage":"","welcomeHeader":"","descriptionTemplate":"","cname":"","status":0,"locale":"","isPrivate":false,"logoBlobKey":"","allowedSchemes":"","isEmailAuthAllowed":false,"isFeedEnabled":false,"preventIndexing":false,"isModerationEnabled":false,"isPro":false,"siteBannerEnabled":false,"siteBannerMessage":"","siteBannerVariant":""},"title":"Game of Thrones"}
4949

5050
</script>
5151

app/services/sqlstore/dbEntities/tenant.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ type Tenant struct {
2929
IsPro bool `db:"is_pro"`
3030
HasPaddleSubscription bool `db:"has_paddle_subscription"`
3131
ScheduledDeletionAt dbx.NullTime `db:"scheduled_deletion_at"`
32+
SiteBannerEnabled bool `db:"site_banner_enabled"`
33+
SiteBannerMessage string `db:"site_banner_message"`
34+
SiteBannerVariant string `db:"site_banner_variant"`
3235
}
3336

3437
func (t *Tenant) ToModel() *entity.Tenant {
@@ -63,6 +66,9 @@ func (t *Tenant) ToModel() *entity.Tenant {
6366
PreventIndexing: t.PreventIndexing,
6467
IsModerationEnabled: isPro && t.IsModerationEnabled,
6568
IsPro: isPro,
69+
SiteBannerEnabled: t.SiteBannerEnabled,
70+
SiteBannerMessage: t.SiteBannerMessage,
71+
SiteBannerVariant: t.SiteBannerVariant,
6672
}
6773

6874
if t.ScheduledDeletionAt.Valid {

app/services/sqlstore/postgres/postgres.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ func (s Service) Init() {
115115
bus.AddHandler(updateTenantPrivacySettings)
116116
bus.AddHandler(updateTenantEmailAuthAllowedSettings)
117117
bus.AddHandler(updateTenantAdvancedSettings)
118+
bus.AddHandler(updateTenantSiteBanner)
118119
bus.AddHandler(scheduleTenantDeletion)
119120
bus.AddHandler(cancelTenantDeletion)
120121
bus.AddHandler(deleteTenant)

0 commit comments

Comments
 (0)