feat(config): HIDE_BILLING flag to hide the Subscription/billing UI (#116)

Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).

- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
  at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
  guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
  so the back button doesn't loop into the guard).
- .env.example + README documented.

Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.

Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.

Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-16 09:19:24 -05:00
parent 5b13254de3
commit 674a34ba45
6 changed files with 29 additions and 3 deletions

View file

@ -11,6 +11,12 @@
# instance never emits mail from a domain that isn't yours. # instance never emits mail from a domain that isn't yours.
SELF_HOSTED=true SELF_HOSTED=true
# Hide the Subscription/billing UI (nav item + pricing cards) and bounce #/billing to
# the dashboard. Opt-in; default off (billing shown). For instances that bill customers
# externally and don't sell plans through the app. UI-only — does not change SELF_HOSTED
# or disable any /api/subscription endpoints.
HIDE_BILLING=true
# Close public self-service registration — for instances where all accounts are # Close public self-service registration — for instances where all accounts are
# provisioned by your team (admin "Add user" / invites). When true, the public # provisioned by your team (admin "Add user" / invites). When true, the public
# signup route is blocked (OAuth auto-signup with it) AND the login page hides # signup route is blocked (OAuth auto-signup with it) AND the login page hides

View file

@ -109,6 +109,7 @@ Schema migrations run automatically on first boot — no manual migration comman
| `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` | | `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` |
| `NODE_ENV` | Runtime env (`production` enables Express production optimizations + stricter error handling) | _(none)_ | | `NODE_ENV` | Runtime env (`production` enables Express production optimizations + stricter error handling) | _(none)_ |
| `SELF_HOSTED` | First user gets all features unlocked | `false` | | `SELF_HOSTED` | First user gets all features unlocked | `false` |
| `HIDE_BILLING` | Hide the Subscription nav item + billing view; `#/billing` redirects to the dashboard (UI-only, opt-in) | `false` |
| `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` | | `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` |
| `DISABLE_HOMEPAGE` | Redirect `/` to `/app` instead of serving the marketing landing page. For internal-only self-hosted deployments. | `false` | | `DISABLE_HOMEPAGE` | Redirect `/` to `/app` instead of serving the marketing landing page. For internal-only self-hosted deployments. | `false` |
| `APP_URL` | Your public URL (used for Stripe callbacks and invite-accept URLs in emailed invites) | _(none)_ | | `APP_URL` | Your public URL (used for Stripe callbacks and invite-accept URLs in emailed invites) | _(none)_ |

View file

@ -132,7 +132,7 @@
</svg> </svg>
<span>Settings</span> <span>Settings</span>
</a></li> </a></li>
<li><a href="#/billing" class="nav-link" data-view="billing"> <li id="billingNavItem"><a href="#/billing" class="nav-link" data-view="billing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/> <rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/> <line x1="1" y1="10" x2="23" y2="10"/>

View file

@ -458,8 +458,17 @@ function route() {
currentView = settings; currentView = settings;
settings.render(app); settings.render(app);
} else if (hash === '#/billing') { } else if (hash === '#/billing') {
// #116: when HIDE_BILLING is set, a direct #/billing navigation is bounced to the
// dashboard. replaceState (not a hash assignment) so it doesn't add a history entry
// — the back button skips over it instead of looping back into the guard.
if (getCurrentUser()?.hide_billing) {
history.replaceState(null, '', window.location.pathname + '#/');
currentView = dashboard;
dashboard.render(app);
} else {
currentView = billing; currentView = billing;
billing.render(app); billing.render(app);
}
} else { } else {
currentView = dashboard; currentView = dashboard;
dashboard.render(app); dashboard.render(app);
@ -474,6 +483,11 @@ function updateSidebarUser() {
const adminNav = document.getElementById('adminNavItem'); const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none'; if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
// #116: hide the Subscription nav item when HIDE_BILLING is set (surfaced on /me).
// Runs at boot from the cached user (no flash on warm loads) and again after /me.
const billingNav = document.getElementById('billingNavItem');
if (billingNav) billingNav.style.display = user.hide_billing ? 'none' : '';
let userEl = document.getElementById('sidebarUser'); let userEl = document.getElementById('sidebarUser');
if (!userEl) { if (!userEl) {
const footer = document.querySelector('.sidebar-footer'); const footer = document.querySelector('.sidebar-footer');

View file

@ -74,6 +74,10 @@ module.exports = {
graphDevRestrictTo: process.env.GRAPH_DEV_RESTRICT_TO || '', graphDevRestrictTo: process.env.GRAPH_DEV_RESTRICT_TO || '',
// Self-hosted mode: if true, first user gets enterprise plan and no billing // Self-hosted mode: if true, first user gets enterprise plan and no billing
selfHosted: process.env.SELF_HOSTED === 'true', selfHosted: process.env.SELF_HOSTED === 'true',
// #116: opt-in UI gate. When true, hides the Subscription nav item + billing view
// and bounces #/billing to the dashboard. Default off, so existing deployments are
// unchanged. UI-only — /api/subscription/* stays in place (internal usage reads).
hideBilling: process.env.HIDE_BILLING === 'true',
// Disable public registration (OAuth auto-signup is also blocked when set). // Disable public registration (OAuth auto-signup is also blocked when set).
// First-user setup is still allowed so a fresh install can be initialized. // First-user setup is still allowed so a fresh install can be initialized.
disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()), disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()),

View file

@ -522,6 +522,7 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => {
res.json({ res.json({
...req.user, ...req.user,
hide_billing: config.hideBilling, // #116: client hides the Subscription nav + guards #/billing
current_workspace_id: req.workspaceId, current_workspace_id: req.workspaceId,
current_workspace: req.workspace ? { id: req.workspace.id, name: req.workspace.name, organization_id: req.workspace.organization_id } : null, current_workspace: req.workspace ? { id: req.workspace.id, name: req.workspace.name, organization_id: req.workspace.organization_id } : null,
current_organization: currentOrg, current_organization: currentOrg,