diff --git a/apps/web/src/components/flows/FlowHeader.tsx b/apps/web/src/components/flows/FlowHeader.tsx index f7e510fcd..750b35671 100644 --- a/apps/web/src/components/flows/FlowHeader.tsx +++ b/apps/web/src/components/flows/FlowHeader.tsx @@ -259,7 +259,7 @@ function PublishToCommunityModal({ flow, onClose }: { flow: Flow; onClose: () => headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags }), }); - if (res.status === 400) { setErr('Publishing to the community hub is not configured on this instance.'); return; } + if (res.status === 400) { setErr('No community key configured. Get one in Settings → Community, then set COMMUNITY_HUB_KEY.'); return; } if (res.status === 409) { setErr('Publish a version of this flow first, then publish it to the community.'); return; } if (res.status === 403) { setErr('Only workspace owners, admins, and editors can publish.'); return; } if (!res.ok) { setErr('Publish failed. Try again.'); return; } diff --git a/apps/web/src/components/settings/CommunitySettings.tsx b/apps/web/src/components/settings/CommunitySettings.tsx new file mode 100644 index 000000000..5af6fac7f --- /dev/null +++ b/apps/web/src/components/settings/CommunitySettings.tsx @@ -0,0 +1,143 @@ +/** + * Community settings (Community Flows — Phase 3, P3-6). Self-host surface. + * + * Shows this instance's community-hub status. The publish credential + * (COMMUNITY_HUB_KEY) is an instance env var, so setting it stays out-of-band + * (env + restart) — there is no browser-writable secret here by design. But we + * DO surface a "get a free key" action: it emails a signed community-license key + * (via our hosted licensing service) that the operator then sets as + * COMMUNITY_HUB_KEY. This is the self-hoster's setup path for publishing. + */ +import { type JSX, useEffect, useState } from 'react'; + +const ink = 'var(--ink, #e7e7e9)'; +const soft = 'var(--ink-soft, #a1a1aa)'; +const muted = 'var(--ink-muted, #71717a)'; +const line = 'var(--line, rgba(255,255,255,0.1))'; +const surface = 'var(--surface, rgba(255,255,255,0.02))'; +const mono = { fontFamily: 'var(--mono, monospace)', fontSize: 12 } as const; + +// OUR hosted licensing service — the only outbound call, by explicit user action. +const LICENSE_SERVICE_URL = + (import.meta.env.PUBLIC_COMMUNITY_LICENSE_URL as string | undefined) ?? 'https://mnema.app/community-license'; + +interface Config { + enabled: boolean; + hub_url: string; + can_publish: boolean; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }): JSX.Element { + return ( +
+ {label} + {children} +
+ ); +} + +export function CommunitySettings(): JSX.Element { + const [cfg, setCfg] = useState(null); + const [err, setErr] = useState(null); + + // "Get a free key" email form. + const [email, setEmail] = useState(''); + const [keyState, setKeyState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [keyErr, setKeyErr] = useState(''); + + useEffect(() => { + void (async () => { + try { + const res = await fetch('/api/community/config', { credentials: 'include' }); + if (!res.ok) throw new Error(String(res.status)); + setCfg((await res.json()) as Config); + } catch { + setErr('Could not load community settings.'); + } + })(); + }, []); + + async function requestKey(e: React.FormEvent): Promise { + e.preventDefault(); + setKeyState('sending'); + setKeyErr(''); + try { + const r = await fetch(LICENSE_SERVICE_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: email.trim() }), + }); + if (r.ok) { setKeyState('sent'); return; } + setKeyErr(r.status === 429 ? 'Too many requests — try again later.' : 'Could not send. Check the email and try again.'); + setKeyState('error'); + } catch { + setKeyErr('Network error reaching the licensing service.'); + setKeyState('error'); + } + } + + if (err) return
{err}
; + if (!cfg) return
Loading…
; + + return ( +
+
+ {cfg.enabled ? 'Enabled' : 'Disabled'} + {cfg.hub_url} + + {cfg.can_publish ? ( + Available ✓ + ) : ( + Key not configured + )} + +
+ +

+ Browsing and importing community flows works out of the box. To publish your own flows, + this instance needs a community-license key set as the COMMUNITY_HUB_KEY environment + variable. To point at a different hub or disable the feature entirely, set{' '} + COMMUNITY_HUB_URL / COMMUNITY_HUB_ENABLED. +

+ +

+ Looking to unlock version history and document export? That's a separate, + per-workspace step — redeem a community-license key under{' '} + Settings → Billing. +

+ + {!cfg.can_publish && ( +
+
Get a free community key
+ {keyState === 'sent' ? ( +

+ Check your inbox for the signed key, then set it as COMMUNITY_HUB_KEY in this + instance's environment and restart the API. Publishing turns on once it's set. +

+ ) : ( + <> +

+ We'll email you a free signed community-license key. Set it as COMMUNITY_HUB_KEY to + enable publishing from this instance. +

+
+ setEmail(e.target.value)} + style={{ flex: 1, padding: '8px 10px', borderRadius: 8, border: `1px solid ${line}`, background: 'var(--bg, #1b1b1e)', color: 'inherit', fontSize: 14 }} + /> + +
+ {keyErr &&

{keyErr}

} + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/settings/RedeemLicense.tsx b/apps/web/src/components/settings/RedeemLicense.tsx index f9e8855e3..a72eeb112 100644 --- a/apps/web/src/components/settings/RedeemLicense.tsx +++ b/apps/web/src/components/settings/RedeemLicense.tsx @@ -24,9 +24,9 @@ export function RedeemLicense(): JSX.Element { return (
Have a license key?
-
Enter it to activate your plan.
+
Paste a plan key (MNEMA-…) or a community-license key to activate.
- setKey(e.target.value)} placeholder="MNEMA-XXXX-XXXX-XXXX-XXXX" + setKey(e.target.value)} placeholder="Paste your license or community key" style={{ flex: 1, padding: '7px 10px', borderRadius: 8, fontSize: 12.5, fontFamily: 'var(--mono, monospace)', border: '0.5px solid var(--border, rgba(0,0,0,0.08))', background: 'var(--surface, #fff)', color: 'var(--ink)' }} />