Skip to content

Commit 0ab58d1

Browse files
fix: complete white-label branding across all surfaces (3.6.2) (#23)
Previously several surfaces showed "Sairo" regardless of APP_NAME, and APP_LOGO / PRIMARY_COLOR were fetched but never applied. Now: - APP_NAME follows through everywhere: browser tab title, og:title (link/social previews e.g. Slack unfurls), PWA manifest name, welcome modal, update banner, and the public share page. The title/og/description/theme-color and the manifest are injected SERVER-SIDE (serve_spa + /manifest.json) so a branded deployment is correct on first paint — no flash from "Sairo" while the client fetches branding. - APP_LOGO is rendered (login, both app headers, share page). - PRIMARY_COLOR is applied to the UI accent (--primary/hover/ring) and the tab theme-color. Injection refactored into pure functions (_apply_branding_html, _branded_manifest) with unit tests. Bump to 3.6.2 (Helm chart 1.4.2). Backend 68 tests pass; full browser e2e (login/welcome/color/logo + share page + no-flash HTML/manifest) verified against a branded instance.
1 parent ca4959b commit 0ab58d1

11 files changed

Lines changed: 177 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to Sairo are documented here. This project uses [Semantic Versioning](https://semver.org/).
44

5+
## [3.6.2] - 2026-07-02
6+
7+
White-label branding is now complete and seamless across every surface.
8+
9+
### Fixed
10+
11+
- **The app name follows `APP_NAME` everywhere.** Previously several surfaces showed "Sairo" regardless of `APP_NAME`: the browser tab title, the `og:title` used in link/social previews (e.g. Slack unfurls), the PWA manifest name, the first-run welcome modal, the update banner, and the public share-download page. The title/og/description/theme-color and the manifest are now injected **server-side**, so a branded deployment is correct the instant the page loads — no flash from "Sairo" while the client fetches branding.
12+
- **`APP_LOGO` is now rendered** (login screen, app headers, and the share page) — it was previously fetched but never displayed.
13+
- **`PRIMARY_COLOR` is now applied** to the UI accent (primary/hover/focus-ring and the tab theme-color) — previously read but never used.
14+
515
## [3.6.1] - 2026-07-02
616

717
Fixes for the activation metric and white-label branding.

backend/main.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from botocore.exceptions import ClientError
2626
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends, Cookie, Request
2727
from fastapi.staticfiles import StaticFiles
28-
from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse
28+
from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse, HTMLResponse
2929
import pyotp
3030
from passlib.hash import bcrypt
3131
from pydantic import BaseModel
@@ -294,7 +294,7 @@ async def s3_error_handler(request, exc):
294294

295295

296296
_app_start_time = time.time()
297-
SAIRO_VERSION = "3.6.1"
297+
SAIRO_VERSION = "3.6.2"
298298

299299

300300
def _version_gt(a: str, b: str) -> bool:
@@ -6889,15 +6889,69 @@ def bucket_info_compat(user: dict = Depends(get_current_user)):
68896889

68906890

68916891
# ── Serve React SPA ─────────────────────────────────────────────────────────
6892+
# ── White-label branding injection (pure, unit-testable) ─────────────────────
6893+
6894+
def _brand_name_color():
6895+
return (os.environ.get("APP_NAME", "Sairo"),
6896+
os.environ.get("PRIMARY_COLOR", "#3b82f6"))
6897+
6898+
def _apply_branding_html(doc: str, name: str, color: str) -> str:
6899+
"""Rewrite the brandable fields of index.html — tab title, og:title,
6900+
description, theme-color — from APP_NAME / PRIMARY_COLOR. Done server-side so
6901+
a white-label deployment is correct the instant the page loads (no client
6902+
flash from "Sairo", and correct og:title for link/social previews)."""
6903+
import re, html as _html
6904+
n = _html.escape(name, quote=True)
6905+
c = _html.escape(color, quote=True)
6906+
doc = re.sub(r"<title>.*?</title>", f"<title>{n}</title>", doc, count=1, flags=re.S)
6907+
doc = re.sub(r'(<meta property="og:title" content=")[^"]*(")', rf"\g<1>{n}\g<2>", doc)
6908+
doc = re.sub(r'(<meta name="description" content=")Sairo\b', rf"\g<1>{n}", doc)
6909+
doc = re.sub(r'(<meta name="theme-color" content=")[^"]*(")', rf"\g<1>{c}\g<2>", doc)
6910+
return doc
6911+
6912+
def _branded_manifest(m: dict, name: str, color: str) -> dict:
6913+
"""PWA manifest branded from APP_NAME / PRIMARY_COLOR."""
6914+
m = dict(m)
6915+
m["name"] = name
6916+
m["short_name"] = name
6917+
if color:
6918+
m["theme_color"] = color
6919+
return m
6920+
6921+
68926922
static_dir = os.path.join(os.path.dirname(__file__), "static")
68936923
if os.path.isdir(static_dir):
68946924
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
68956925

6926+
_spa_cache: dict = {}
6927+
6928+
def _spa_index_html():
6929+
if "html" not in _spa_cache:
6930+
try:
6931+
with open(os.path.join(static_dir, "index.html"), encoding="utf-8") as f:
6932+
_spa_cache["html"] = _apply_branding_html(f.read(), *_brand_name_color())
6933+
except Exception:
6934+
_spa_cache["html"] = None
6935+
return _spa_cache["html"]
6936+
6937+
@app.get("/manifest.json")
6938+
def serve_manifest():
6939+
try:
6940+
with open(os.path.join(static_dir, "manifest.json"), encoding="utf-8") as f:
6941+
m = json.load(f)
6942+
except Exception:
6943+
m = {"start_url": "/", "display": "standalone"}
6944+
return JSONResponse(_branded_manifest(m, *_brand_name_color()))
6945+
68966946
@app.get("/{path:path}")
68976947
def serve_spa(path: str):
68986948
file_path = os.path.realpath(os.path.join(static_dir, path))
68996949
if not file_path.startswith(os.path.realpath(static_dir)):
69006950
raise HTTPException(403, "Forbidden")
69016951
if os.path.isfile(file_path):
69026952
return FileResponse(file_path)
6953+
# SPA entry: serve index.html with the app name/colour baked in.
6954+
doc = _spa_index_html()
6955+
if doc is not None:
6956+
return HTMLResponse(doc)
69036957
return FileResponse(os.path.join(static_dir, "index.html"))

backend/test_main.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,52 @@ def test_first_search_records_even_when_index_not_ready(self, app, client, admin
667667
"first_search_at must record even when the search 503s during indexing"
668668

669669

670+
class TestBrandingInjection:
671+
"""3.6.2 white-label: APP_NAME / PRIMARY_COLOR are injected into the served
672+
HTML + manifest server-side, so a branded deployment never leaks "Sairo"
673+
(tab title, og:title for link previews, PWA name) and shows no load flash."""
674+
675+
SAMPLE_HTML = (
676+
'<title>Sairo</title>\n'
677+
'<meta property="og:title" content="Sairo" />\n'
678+
'<meta name="description" content="Sairo — S3-compatible object storage browser" />\n'
679+
'<meta name="theme-color" content="#3b82f6" />'
680+
)
681+
682+
def test_html_fields_follow_app_name_and_color(self):
683+
m = _main_module()
684+
out = m._apply_branding_html(self.SAMPLE_HTML, "Objex", "#e11d48")
685+
assert "<title>Objex</title>" in out
686+
assert 'property="og:title" content="Objex"' in out # link/social previews
687+
assert 'content="Objex — S3-compatible' in out # description
688+
assert 'name="theme-color" content="#e11d48"' in out # tab/PWA colour
689+
assert "Sairo" not in out # no leak anywhere
690+
691+
def test_html_default_keeps_sairo(self):
692+
m = _main_module()
693+
out = m._apply_branding_html(self.SAMPLE_HTML, "Sairo", "#3b82f6")
694+
assert "<title>Sairo</title>" in out # vanilla install unchanged
695+
696+
def test_html_escapes_app_name(self):
697+
m = _main_module()
698+
out = m._apply_branding_html("<title>Sairo</title>", "A&B<x>", "#000")
699+
assert "A&amp;B&lt;x&gt;" in out and "<title>A&B<x></title>" not in out
700+
701+
def test_manifest_branded(self):
702+
m = _main_module()
703+
out = m._branded_manifest(
704+
{"name": "Sairo", "short_name": "Sairo", "theme_color": "#3b82f6", "start_url": "/"},
705+
"Objex", "#e11d48")
706+
assert out["name"] == "Objex" and out["short_name"] == "Objex"
707+
assert out["theme_color"] == "#e11d48"
708+
assert out["start_url"] == "/" # untouched fields preserved
709+
710+
def test_manifest_default_keeps_sairo(self):
711+
m = _main_module()
712+
out = m._branded_manifest({"name": "Sairo", "short_name": "Sairo"}, "Sairo", "#3b82f6")
713+
assert out["name"] == "Sairo"
714+
715+
670716
# ── Health Check ─────────────────────────────────────────
671717

672718
class TestHealth:

charts/sairo/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: sairo-helm
33
description: S3-compatible object storage browser with search, versioning, and analytics
44
type: application
5-
version: 1.4.1
6-
appVersion: "3.6.1"
5+
version: 1.4.2
6+
appVersion: "3.6.2"
77
keywords:
88
- s3
99
- object-storage

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sairo",
3-
"version": "3.6.1",
3+
"version": "3.6.2",
44
"description": "S3-compatible object storage browser with search, versioning, and analytics",
55
"private": true,
66
"type": "module",

frontend/src/App.jsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,20 @@ function MainApp() {
173173
if (branding.app_name) document.title = branding.app_name;
174174
}, [branding.app_name]);
175175

176+
// Apply a white-label PRIMARY_COLOR to the accent CSS variables (main accent,
177+
// hover, focus ring) so a branded deployment is recoloured throughout the UI.
178+
useEffect(() => {
179+
const hex = branding.primary_color;
180+
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
181+
if (!m) return;
182+
const [r, g, b] = [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16));
183+
const dark = (v, f) => Math.max(0, Math.round(v * (1 - f)));
184+
const root = document.documentElement.style;
185+
root.setProperty("--primary", `#${m[1]}`);
186+
root.setProperty("--primary-hover", `rgb(${dark(r, .12)}, ${dark(g, .12)}, ${dark(b, .12)})`);
187+
root.setProperty("--primary-ring", `rgba(${r}, ${g}, ${b}, 0.3)`);
188+
}, [branding.primary_color]);
189+
176190
// Listen for session-expired events from api.js (replaces hard page reload)
177191
useEffect(() => {
178192
const handler = () => {
@@ -604,6 +618,14 @@ function MainApp() {
604618
}
605619

606620
const appName = branding.app_name || "Sairo";
621+
// White-label logo: use APP_LOGO when set, else the default mark.
622+
const logoMark = branding.app_logo
623+
? <img src={branding.app_logo} alt={appName} style={{ height: 22, width: "auto", display: "block" }} />
624+
: (
625+
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="22" height="22">
626+
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
627+
</svg>
628+
);
607629

608630
const userBadge = (
609631
<div className="user-badge">
@@ -621,9 +643,7 @@ function MainApp() {
621643
<header>
622644
<div className="header-left">
623645
<span className="header-logo-mark">
624-
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="22" height="22">
625-
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
626-
</svg>
646+
{logoMark}
627647
</span>
628648
<h1>{appName}</h1>
629649
<span className="bucket-name">Object Storage</span>
@@ -647,7 +667,7 @@ function MainApp() {
647667
</span>
648668
<div className="ub-body">
649669
<div className="ub-head">
650-
Sairo <strong>v{updateInfo.latest}</strong> is here <span className="ub-cur">· you're on v{updateInfo.current}</span>
670+
{appName} <strong>v{updateInfo.latest}</strong> is here <span className="ub-cur">· you're on v{updateInfo.current}</span>
651671
</div>
652672
<div className="ub-chips">
653673
{UPDATE_HIGHLIGHTS.map((h) => <span className="ub-chip" key={h}>{h}</span>)}
@@ -672,7 +692,7 @@ function MainApp() {
672692
<BucketList onSelect={navigateBucket} role={user.role} onDashboard={setDashboardBucket} />
673693
{showAuditLog && <AuditLog onClose={() => setShowAuditLog(false)} />}
674694
{dashboardBucket && <StorageDashboard bucket={dashboardBucket} onClose={() => setDashboardBucket(null)} onNavigate={(pfx) => { setDashboardBucket(null); setHash(dashboardBucket, pfx); }} />}
675-
{showWelcome && <Welcome onDismiss={() => setShowWelcome(false)} />}
695+
{showWelcome && <Welcome onDismiss={() => setShowWelcome(false)} appName={appName} />}
676696
{showTokenManager && <TokenManager onClose={() => setShowTokenManager(false)} />}
677697
{showLicense && <LicenseManager onClose={() => setShowLicense(false)} />}
678698
{showUserManager && <UserManager onClose={() => setShowUserManager(false)} currentUser={user} />}
@@ -696,9 +716,7 @@ function MainApp() {
696716
<header>
697717
<div className="header-left">
698718
<span className="header-logo-mark" style={{ cursor: "pointer" }} onClick={goHome}>
699-
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="20" height="20">
700-
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
701-
</svg>
719+
{logoMark}
702720
</span>
703721
<h1 style={{ cursor: "pointer" }} onClick={goHome}>{appName}</h1>
704722
<span className="bucket-name">{bucket}</span>

frontend/src/components/Login.jsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,17 @@ export default function Login({ onLogin, branding = {} }) {
166166
<form className="login-form" onSubmit={handleSubmit}>
167167
<div className="login-branding">
168168
<div className="login-logo-mark">
169-
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="32" height="32">
170-
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/>
171-
<path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/>
172-
<path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/>
173-
<line x1="12" y1="10" x2="12" y2="30"/>
174-
<line x1="28" y1="10" x2="28" y2="30"/>
175-
</svg>
169+
{branding.app_logo ? (
170+
<img src={branding.app_logo} alt={appName} style={{ height: 40, width: "auto" }} />
171+
) : (
172+
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="32" height="32">
173+
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/>
174+
<path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/>
175+
<path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/>
176+
<line x1="12" y1="10" x2="12" y2="30"/>
177+
<line x1="28" y1="10" x2="28" y2="30"/>
178+
</svg>
179+
)}
176180
</div>
177181
<h1>{appName}</h1>
178182
<p className="login-subtitle">Object storage, <span className="login-subtitle-fade">beautifully browsed.</span></p>

frontend/src/components/SharePage.jsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import React, { useState, useEffect } from "react";
2-
import { resolveShareLink } from "../api";
2+
import { resolveShareLink, getBranding } from "../api";
33

44
export default function SharePage({ token }) {
55
const [data, setData] = useState(null);
66
const [error, setError] = useState(null);
77
const [loading, setLoading] = useState(true);
88
const [password, setPassword] = useState("");
99
const [needsPassword, setNeedsPassword] = useState(false);
10+
const [brand, setBrand] = useState({ app_name: "Sairo" });
11+
12+
// Brand the public share page (name + accent) from the deployment's settings.
13+
useEffect(() => { getBranding().then(setBrand).catch(() => {}); }, []);
1014

1115
const fetchLink = async (pwd = "") => {
1216
setLoading(true);
@@ -41,10 +45,14 @@ export default function SharePage({ token }) {
4145
<div className="share-page">
4246
<div className="share-card">
4347
<div className="share-logo">
44-
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="28" height="28" style={{ color: "#3b82f6" }}>
45-
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
46-
</svg>
47-
Sairo
48+
{brand.app_logo ? (
49+
<img src={brand.app_logo} alt={brand.app_name || "Sairo"} style={{ height: 28, width: "auto" }} />
50+
) : (
51+
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="28" height="28" style={{ color: brand.primary_color || "#3b82f6" }}>
52+
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
53+
</svg>
54+
)}
55+
{brand.app_name || "Sairo"}
4856
</div>
4957
{loading ? (
5058
<div className="share-loading"><div className="spinner" /> Loading...</div>

frontend/src/components/Welcome.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const TIPS = [
77
{ icon: "\uD83D\uDCC4", title: "Preview", desc: "Click the eye icon to preview images, text, CSV, JSON, and Parquet schemas." },
88
];
99

10-
export default function Welcome({ onDismiss }) {
10+
export default function Welcome({ onDismiss, appName = "Sairo" }) {
1111
const handleDismiss = () => {
1212
localStorage.setItem("sairo-onboarded", "1");
1313
onDismiss();
@@ -16,7 +16,7 @@ export default function Welcome({ onDismiss }) {
1616
return (
1717
<div className="modal-overlay" onClick={handleDismiss}>
1818
<div className="modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
19-
<h2>Welcome to Sairo</h2>
19+
<h2>Welcome to {appName}</h2>
2020
<p style={{ color: "var(--text-muted)", marginBottom: 16 }}>Here are a few tips to get you started:</p>
2121
<div className="welcome-tips">
2222
{TIPS.map((t, i) => (

0 commit comments

Comments
 (0)