Skip to content

Commit 868f4ef

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 868f4ef

17 files changed

Lines changed: 314 additions & 11 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/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)

app/services/sqlstore/postgres/tenant.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@ func updateTenantAdvancedSettings(ctx context.Context, c *cmd.UpdateTenantAdvanc
116116
})
117117
}
118118

119+
func updateTenantSiteBanner(ctx context.Context, c *cmd.UpdateTenantSiteBanner) error {
120+
return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error {
121+
query := "UPDATE tenants SET site_banner_enabled = $1, site_banner_message = $2, site_banner_variant = $3 WHERE id = $4"
122+
_, err := trx.Execute(query, c.Enabled, c.Message, c.Variant, tenant.ID)
123+
if err != nil {
124+
return errors.Wrap(err, "failed update tenant site banner")
125+
}
126+
127+
tenant.SiteBannerEnabled = c.Enabled
128+
tenant.SiteBannerMessage = c.Message
129+
tenant.SiteBannerVariant = c.Variant
130+
return nil
131+
})
132+
}
133+
119134
func activateTenant(ctx context.Context, c *cmd.ActivateTenant) error {
120135
return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error {
121136
query := "UPDATE tenants SET status = $1 WHERE id = $2"
@@ -261,6 +276,7 @@ func getFirstTenant(ctx context.Context, q *query.GetFirstTenant) error {
261276

262277
err := trx.Get(&tenant, `
263278
SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.description_template, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, t.scheduled_deletion_at,
279+
t.site_banner_enabled, t.site_banner_message, t.site_banner_variant,
264280
(b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription
265281
FROM tenants t
266282
LEFT JOIN tenants_billing b ON b.tenant_id = t.id
@@ -281,6 +297,7 @@ func getTenantByDomain(ctx context.Context, q *query.GetTenantByDomain) error {
281297

282298
err := trx.Get(&tenant, `
283299
SELECT t.id, t.name, t.subdomain, t.cname, t.invitation, t.locale, t.welcome_message, t.welcome_header, t.description_template, t.status, t.is_private, t.logo_bkey, t.custom_css, t.allowed_schemes, t.is_email_auth_allowed, t.is_feed_enabled, t.is_moderation_enabled, t.prevent_indexing, t.is_pro, t.scheduled_deletion_at,
300+
t.site_banner_enabled, t.site_banner_message, t.site_banner_variant,
284301
(b.paddle_subscription_id IS NOT NULL AND b.stripe_subscription_id IS NULL) AS has_paddle_subscription
285302
FROM tenants t
286303
LEFT JOIN tenants_billing b ON b.tenant_id = t.id

locale/en/client.json

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
{
22
"action.cancel": "Cancel",
3+
"admin.banner.charsleft": "{remaining} characters remaining",
4+
"admin.banner.enable": "Show site-wide banner",
5+
"admin.banner.form.message": "Message",
6+
"admin.banner.form.variant": "Color variant",
7+
"admin.banner.page.subtitle": "Show a site-wide notice above the header for maintenance, releases, or incidents",
8+
"admin.banner.page.title": "Site Banner",
9+
"admin.banner.preview": "Preview",
10+
"admin.banner.save": "Save banner",
11+
"admin.banner.saved": "Banner settings saved.",
12+
"admin.banner.variant.brand": "Brand — tenant primary color",
13+
"admin.banner.variant.danger": "Danger — incident in progress",
14+
"admin.banner.variant.info": "Info — neutral announcement",
15+
"admin.banner.variant.success": "Success — green confirmation",
16+
"admin.banner.variant.warning": "Warning — scheduled maintenance",
317
"action.change": "change",
418
"action.close": "Close",
519
"action.commentsfeed": "Comment Feed",
@@ -45,8 +59,6 @@
4559
"error.pagenotfound.title": "Page not found",
4660
"error.unauthorized.text": "You need to sign in before accessing this page.",
4761
"error.unauthorized.title": "Unauthorized",
48-
"header.nav.feedback": "All Feedback",
49-
"header.nav.roadmap": "Roadmap",
5062
"home.filter.label": "Filter",
5163
"home.filter.search.label": "Search in filters...",
5264
"home.form.defaultinvitation": "Enter your suggestion here...",
@@ -101,7 +113,6 @@
101113
"linkmodal.title": "Insert Link",
102114
"linkmodal.url.label": "URL",
103115
"linkmodal.url.placeholder": "https://example.com",
104-
"listposts.label.showmore": "Show {0} more",
105116
"menu.administration": "Administration",
106117
"menu.mysettings": "My Settings",
107118
"menu.signout": "Sign out",
@@ -188,13 +199,6 @@
188199
"pagination.prev": "Previous",
189200
"post.pending": "pending",
190201
"postdetails.backtoall": "Back to all suggestions",
191-
"postdetails.backtoroadmap": "Back to roadmap",
192-
"roadmap.blank.description": "Mark posts as planned or in progress and they'll show up here on the roadmap.",
193-
"roadmap.blank.title": "Your roadmap is waiting for its first update",
194-
"roadmap.column.showmore": "Show more",
195-
"roadmap.upsell.billing": "Upgrade to PRO",
196-
"roadmap.upsell.description": "Upgrade to Pro to unlock your Roadmap",
197-
"roadmap.upsell.title": "See what's happening in the Roadmap view",
198202
"showpost.comment.copylink.error": "Could not copy comment link, please copy page URL",
199203
"showpost.comment.copylink.success": "Successfully copied comment link to clipboard",
200204
"showpost.comment.unknownhighlighted": "Unknown comment ID #{id}",
@@ -230,7 +234,6 @@
230234
"signin.code.edit": "Edit",
231235
"signin.code.getnew": "Get a new code",
232236
"signin.code.instruction": "Please type in the code we just sent to <0>{email}</0>",
233-
"signin.code.locked": "Your code has expired due to too many incorrect attempts. Please request a new one.",
234237
"signin.code.placeholder": "Type in the code here",
235238
"signin.code.sent": "A new code has been sent to your email.",
236239
"signin.email.placeholder": "Email address",

0 commit comments

Comments
 (0)