Skip to content

Commit 45b98df

Browse files
Paul Cwolfsoftwaresystemsltd
andcommitted
v24.17.0: login-time support nag for non-supporters
Operators not supporting WolfStack (no paid licence, no paying Patreon pledge, no GitHub Sponsor self-attest) now get a freely dismissible modal once per login asking them to sponsor on GitHub or buy a licence. Supporters never see it; the login-prompt coordinator shows the welcome modal XOR the nag, never both. - is_supporter() reuses the existing entitlement model (licence OR paying Patreon tier OR GitHub self-attest); GET /api/supporter/status drives it. Fail-safe: any error treats the user as a supporter, so a paying customer is never nagged. - "I'm supporting already" checkbox cancels permanently: instant local flag plus honour-system server self-attest via POST /api/sponsor/github. - Unit test for PatreonTier::is_paying (Free-exempt boundary). Co-Authored-By: CodeWolf <paul@wolf.uk.com> Co-Authored-By: Wolf Software Systems Ltd <paul@wolf.uk.com>
1 parent 674bb28 commit 45b98df

6 files changed

Lines changed: 213 additions & 15 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "24.16.0"
3+
version = "24.17.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28185,6 +28185,51 @@ fn beta_access_granted(patreon_tier: &crate::patreon::PatreonTier)
2818528185
beta_access_granted_full(patreon_tier, false)
2818628186
}
2818728187

28188+
/// Whether this installation supports WolfStack by ANY means — a paid
28189+
/// licence, a paying Patreon pledge (Basic+), or the GitHub Sponsor
28190+
/// self-attest flag. Deliberately broader than `beta_access_granted_full`:
28191+
/// the login-time support nag must exempt EVERYONE who gives us money,
28192+
/// including Patreon Basic backers who don't earn beta access. Nagging a
28193+
/// paying customer is a self-own.
28194+
///
28195+
/// Returns (is_supporter, reason) so the frontend can tailor copy if it wants.
28196+
fn is_supporter(
28197+
patreon_tier: &crate::patreon::PatreonTier,
28198+
github_sponsor: bool,
28199+
) -> (bool, &'static str) {
28200+
if crate::compat::platform_ready() {
28201+
return (true, "licence");
28202+
}
28203+
if patreon_tier.is_paying() {
28204+
return (true, "patreon");
28205+
}
28206+
if github_sponsor {
28207+
return (true, "github_sponsor");
28208+
}
28209+
(false, "none")
28210+
}
28211+
28212+
/// GET /api/supporter/status — drives the login-time support nag. Reports
28213+
/// whether this install supports WolfStack by any means (licence, paying
28214+
/// Patreon pledge, or GitHub Sponsor self-attest) so the frontend can decide
28215+
/// whether to show the nag. Operator session required.
28216+
async fn supporter_status(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
28217+
if let Err(resp) = require_auth(&req, &state) {
28218+
return resp;
28219+
}
28220+
// Copy the two inputs out and drop the guard BEFORE the (sync) licence
28221+
// check — never hold the RwLock guard longer than the read needs.
28222+
let (tier, github_sponsor) = {
28223+
let config = state.patreon.config.read().unwrap();
28224+
(config.tier.clone(), config.github_sponsor)
28225+
};
28226+
let (granted, reason) = is_supporter(&tier, github_sponsor);
28227+
HttpResponse::Ok().json(serde_json::json!({
28228+
"is_supporter": granted,
28229+
"reason": reason,
28230+
}))
28231+
}
28232+
2818828233
/// POST /api/patreon/disconnect — unlink Patreon account
2818928234
async fn patreon_disconnect(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
2819028235
if let Err(resp) = require_auth(&req, &state) {
@@ -33885,6 +33930,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
3388533930
// are independent grants); the persisted state happens to
3388633931
// share patreon.json for historical reasons.
3388733932
.route("/api/sponsor/github", web::post().to(set_github_sponsor))
33933+
.route("/api/supporter/status", web::get().to(supporter_status))
3388833934
.route("/api/patreon/disconnect", web::post().to(patreon_disconnect))
3388933935
// Icon Packs
3389033936
.route("/api/icon-packs", web::get().to(icon_packs_list))

src/patreon.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ impl PatreonTier {
6161
matches!(self, PatreonTier::Advanced | PatreonTier::Platinum | PatreonTier::Enterprise)
6262
}
6363

64+
/// Whether this tier reflects an actual paid pledge — excludes `None`
65+
/// (not linked) and `Free` (follows on Patreon but pledges nothing).
66+
/// Used by the login-time support nag to exempt anyone who is in fact
67+
/// paying us, even at a tier below beta access.
68+
pub fn is_paying(&self) -> bool {
69+
matches!(self, PatreonTier::Basic | PatreonTier::Advanced | PatreonTier::Platinum | PatreonTier::Enterprise)
70+
}
71+
6472
/// Determine tier from pledge amount in cents.
6573
pub fn from_cents(cents: i64) -> Self {
6674
if cents >= 9500 {
@@ -366,4 +374,17 @@ mod tests {
366374
assert!(PatreonTier::Platinum.has_beta_access());
367375
assert!(PatreonTier::Enterprise.has_beta_access());
368376
}
377+
378+
#[test]
379+
fn test_is_paying() {
380+
// The support nag must NOT fire for anyone actually paying. The
381+
// critical boundary is Free (follows, pledges nothing) vs Basic
382+
// (first paid tier).
383+
assert!(!PatreonTier::None.is_paying());
384+
assert!(!PatreonTier::Free.is_paying());
385+
assert!(PatreonTier::Basic.is_paying());
386+
assert!(PatreonTier::Advanced.is_paying());
387+
assert!(PatreonTier::Platinum.is_paying());
388+
assert!(PatreonTier::Enterprise.is_paying());
389+
}
369390
}

web/css/style.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4218,3 +4218,8 @@ body.learn-drawer-open #task-log-footer { right: var(--learn-drawer-w) !importan
42184218
.welcome-getstarted { width: 100%; }
42194219
.welcome-dontshow-lbl { justify-content: center; }
42204220
}
4221+
4222+
/* Support nag — reuses .welcome-modal / .welcome-card / .welcome-action /
4223+
.welcome-foot / .welcome-dontshow-lbl. The note is the only unique bit. */
4224+
.nag-once-note { margin: 12px 0 0; font-size: 12px; color: var(--text-muted); line-height: 1.45; text-align: center; }
4225+
.nag-once-note strong { color: var(--text-secondary); }

web/index.html

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9851,6 +9851,50 @@ <h2 id="welcome-modal-title">Welcome to WolfStack</h2>
98519851
</div>
98529852
</div>
98539853

9854+
<!-- Login-time support nag: shown once per login to operators who have dismissed
9855+
the welcome modal and are NOT supporting WolfStack (no licence, no paying
9856+
Patreon pledge, no GitHub Sponsor self-attest). Freely dismissible — never
9857+
blocks the dashboard. Reuses the .welcome-modal styles. See
9858+
maybeShowLoginPrompt() / showSupportNag() in app.js. -->
9859+
<div id="support-nag" class="welcome-modal" role="dialog" aria-modal="true" aria-labelledby="support-nag-title" style="display:none;">
9860+
<div class="welcome-backdrop" onclick="closeSupportNag()"></div>
9861+
<div class="welcome-card" role="document">
9862+
<button type="button" class="welcome-x" id="support-nag-close-btn" onclick="closeSupportNag()" aria-label="Close support dialog">&times;</button>
9863+
<div class="welcome-hero">
9864+
<span class="welcome-action-ico welcome-ico-heart" aria-hidden="true" style="margin:0 auto 10px;width:48px;height:48px;font-size:24px;">&#9829;</span>
9865+
<h2 id="support-nag-title">Help keep WolfStack free</h2>
9866+
<p class="welcome-sub">WolfStack is built by a tiny independent team &mdash; no VC money, no ads, no telemetry, and every feature ships free to everyone. If it's useful to you, please consider chipping in to keep it that way.</p>
9867+
</div>
9868+
<div class="welcome-actions">
9869+
<a class="welcome-action welcome-action-primary" href="https://github.com/sponsors/wolfsoftwaresystemsltd" target="_blank" rel="noopener noreferrer">
9870+
<span class="welcome-action-ico welcome-ico-heart">&#9829;</span>
9871+
<span class="welcome-action-txt">
9872+
<strong>Become a GitHub Sponsor</strong>
9873+
<span>Recurring support from a few dollars a month &mdash; cancel anytime.</span>
9874+
</span>
9875+
<span class="welcome-action-arrow">&#8599;</span>
9876+
</a>
9877+
<a class="welcome-action" href="https://wolfstack.org/enterprise.php" target="_blank" rel="noopener noreferrer">
9878+
<span class="welcome-action-ico" style="background:rgba(59,130,246,0.14);color:#60a5fa;">
9879+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
9880+
</span>
9881+
<span class="welcome-action-txt">
9882+
<strong>Buy a licence plan</strong>
9883+
<span>Homelab to Enterprise &mdash; dedicated support, and every licence funds development.</span>
9884+
</span>
9885+
<span class="welcome-action-arrow">&#8599;</span>
9886+
</a>
9887+
</div>
9888+
<div class="welcome-foot">
9889+
<label class="welcome-dontshow-lbl">
9890+
<input type="checkbox" id="nag-supporting"> I'm supporting already
9891+
</label>
9892+
<button type="button" class="welcome-getstarted" onclick="closeSupportNag()">Maybe later</button>
9893+
</div>
9894+
<p class="nag-once-note">We're asking for your support &mdash; this appears just once each time you sign in. Already a GitHub or Patreon supporter? Tick <strong>&ldquo;I'm supporting already&rdquo;</strong> and we'll stop asking for good &mdash; we can't auto-verify those, so it's on the honour system.</p>
9895+
</div>
9896+
</div>
9897+
98549898
</body>
98559899

98569900
</html>

web/js/app.js

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52072,24 +52072,106 @@ function dismissLearnBanner() {
5207252072
if (el) el.style.display = 'none';
5207352073
}
5207452074

52075-
// ── Login welcome modal ──────────────────────────────────────────────
52076-
// Shown once per login: login.html sets a per-login sessionStorage flag on a
52077-
// genuine sign-in (password or passkey), which we consume here on first show
52078-
// — so it does NOT reappear on refresh or in-app navigation within the same
52079-
// session, only on the next login. Ticking "Don't show again" sets a
52080-
// permanent localStorage flag that suppresses it for good. Promotes the
52081-
// Getting Started course, the Discord community, and sponsorship.
52075+
// ── Login-time prompt coordinator ────────────────────────────────────
52076+
// Runs once per fresh login: login.html sets a per-login sessionStorage flag
52077+
// on a genuine sign-in (password or passkey), which we consume here on first
52078+
// show — so neither prompt reappears on refresh or in-app navigation within
52079+
// the same session, only on the next login.
52080+
//
52081+
// Two prompts, never stacked:
52082+
// • Welcome modal — shown while the operator hasn't ticked "Don't show
52083+
// again" (permanent localStorage flag). Promotes the Getting Started
52084+
// course, Discord, and sponsorship.
52085+
// • Support nag — once the welcome is dismissed for good, non-supporters
52086+
// (no licence, no paying Patreon pledge, no GitHub Sponsor self-attest)
52087+
// get a focused, freely-dismissible ask to sponsor or buy a licence.
52088+
// Supporters see nothing further. Fail-safe: on ANY error we treat the
52089+
// user as a supporter, so we never nag someone who might be paying.
5208252090
const WELCOME_DISMISS_KEY = 'wolfstack_welcome_dismissed';
5208352091
const WELCOME_FRESH_KEY = 'wolfstack_fresh_login';
52092+
// Set when the operator ticks "I'm supporting already" — a permanent,
52093+
// offline-proof local guarantee that the support nag never shows again in
52094+
// this browser, complementing the server-side self-attest.
52095+
const NAG_DECLARED_KEY = 'wolfstack_support_declared';
5208452096

52085-
function maybeShowWelcomeModal() {
52086-
let permanent = false, fresh = false;
52087-
try { permanent = localStorage.getItem(WELCOME_DISMISS_KEY) === '1'; } catch (_) {}
52097+
async function maybeShowLoginPrompt() {
52098+
let fresh = false;
5208852099
try { fresh = sessionStorage.getItem(WELCOME_FRESH_KEY) === '1'; } catch (_) {}
52089-
if (permanent || !fresh) return;
52090-
// Consume the per-login flag so the modal shows once per login only.
52100+
if (!fresh) return; // refresh / in-app nav, not a login
52101+
// Consume the per-login flag so a prompt shows once per login only.
5209152102
try { sessionStorage.removeItem(WELCOME_FRESH_KEY); } catch (_) {}
52092-
showWelcomeModal();
52103+
52104+
let permanent = false;
52105+
try { permanent = localStorage.getItem(WELCOME_DISMISS_KEY) === '1'; } catch (_) {}
52106+
// Welcome still active → it shows (and already carries a sponsor ask); don't
52107+
// pile the nag on top.
52108+
if (!permanent) { showWelcomeModal(); return; }
52109+
52110+
// Welcome dismissed → focused support nag for non-supporters only.
52111+
// First honour a prior self-declaration ("I'm supporting already") without
52112+
// a round-trip — this never reappears once they've ticked the box.
52113+
let declared = false;
52114+
try { declared = localStorage.getItem(NAG_DECLARED_KEY) === '1'; } catch (_) {}
52115+
if (declared) return;
52116+
52117+
let supporter = true; // fail SAFE: never nag on error / unknown / older binary
52118+
try {
52119+
const r = await fetch('/api/supporter/status');
52120+
if (r.ok) {
52121+
const d = await r.json();
52122+
supporter = (d && d.is_supporter !== false);
52123+
}
52124+
} catch (_) { supporter = true; }
52125+
if (!supporter) showSupportNag();
52126+
}
52127+
52128+
// ── Support nag (non-supporters, once per login) ─────────────────────
52129+
function showSupportNag() {
52130+
const el = document.getElementById('support-nag');
52131+
if (!el) return;
52132+
el.style.display = 'flex';
52133+
requestAnimationFrame(() => el.classList.add('open'));
52134+
document.addEventListener('keydown', supportNagEscHandler);
52135+
const close = document.getElementById('support-nag-close-btn');
52136+
if (close) { try { close.focus(); } catch (_) {} }
52137+
}
52138+
52139+
function supportNagEscHandler(e) { if (e.key === 'Escape') closeSupportNag(); }
52140+
52141+
function closeSupportNag() {
52142+
const el = document.getElementById('support-nag');
52143+
if (!el) return;
52144+
// "I'm supporting already" → cancel permanently. Record locally for an
52145+
// instant, offline-proof guarantee, AND tell the server (honour-system
52146+
// self-attest) so it persists across browsers and counts everywhere.
52147+
const cb = document.getElementById('nag-supporting');
52148+
if (cb && cb.checked) {
52149+
try { localStorage.setItem(NAG_DECLARED_KEY, '1'); } catch (_) {}
52150+
nagDeclareSupport();
52151+
}
52152+
el.classList.remove('open');
52153+
document.removeEventListener('keydown', supportNagEscHandler);
52154+
setTimeout(() => { el.style.display = 'none'; }, 200);
52155+
}
52156+
52157+
// Tell the server this install supports WolfStack (honour-system; GitHub's
52158+
// API can't be queried to verify the org's sponsors) so the declaration
52159+
// persists across browsers and the operator counts as a supporter everywhere.
52160+
// Best-effort: NAG_DECLARED_KEY already stops the nag in this browser, so a
52161+
// failure here only means it didn't sync off-device.
52162+
async function nagDeclareSupport() {
52163+
try {
52164+
const r = await fetch('/api/sponsor/github', {
52165+
method: 'POST',
52166+
headers: { 'Content-Type': 'application/json' },
52167+
body: JSON.stringify({ enabled: true })
52168+
});
52169+
if (!r.ok) throw new Error('HTTP ' + r.status);
52170+
if (typeof showToast === 'function') showToast('Thanks for supporting WolfStack — we won’t ask again.', 'success');
52171+
} catch (_) {
52172+
// Warning toasts must not auto-dismiss — duration 0 keeps it until dismissed.
52173+
if (typeof showToast === 'function') showToast('Noted on this device — couldn’t sync to the server, so it may reappear on other browsers.', 'warning', 0);
52174+
}
5209352175
}
5209452176

5209552177
function showWelcomeModal() {
@@ -52124,7 +52206,7 @@ function welcomeStartCourse() {
5212452206
openLearnDrawer();
5212552207
}
5212652208

52127-
document.addEventListener('DOMContentLoaded', maybeShowWelcomeModal);
52209+
document.addEventListener('DOMContentLoaded', maybeShowLoginPrompt);
5212852210

5212952211
// Build (possibly nested) list HTML from a contiguous run of markdown list
5213052212
// lines starting at `start`. Nesting is by leading indentation, so a numbered

0 commit comments

Comments
 (0)