Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/flows/FlowHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
143 changes: 143 additions & 0 deletions apps/web/src/components/settings/CommunitySettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${line}` }}>
<span style={{ fontSize: 13, color: soft }}>{label}</span>
<span style={{ fontSize: 13, color: ink }}>{children}</span>
</div>
);
}

export function CommunitySettings(): JSX.Element {
const [cfg, setCfg] = useState<Config | null>(null);
const [err, setErr] = useState<string | null>(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<void> {
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 <div style={{ color: 'var(--status-warn, #ff7a8a)', fontSize: 13 }}>{err}</div>;
if (!cfg) return <div style={{ color: muted, fontSize: 14 }}>Loading…</div>;

return (
<div style={{ maxWidth: 560 }}>
<div style={{ padding: '4px 16px', borderRadius: 10, background: surface, border: `1px solid ${line}` }}>
<Row label="Community hub"><span>{cfg.enabled ? 'Enabled' : 'Disabled'}</span></Row>
<Row label="Hub URL"><span style={mono}>{cfg.hub_url}</span></Row>
<Row label="Publishing">
{cfg.can_publish ? (
<span style={{ color: 'var(--status-success, #6be39b)' }}>Available ✓</span>
) : (
<span style={{ color: 'var(--status-edit, #f0997b)' }}>Key not configured</span>
)}
</Row>
</div>

<p style={{ marginTop: 18, fontSize: 13, lineHeight: 1.6, color: soft }}>
Browsing and importing community flows works out of the box. To <strong>publish</strong> your own flows,
this instance needs a community-license key set as the <code style={mono}>COMMUNITY_HUB_KEY</code> environment
variable. To point at a different hub or disable the feature entirely, set{' '}
<code style={mono}>COMMUNITY_HUB_URL</code> / <code style={mono}>COMMUNITY_HUB_ENABLED</code>.
</p>

<p style={{ marginTop: 10, fontSize: 12.5, lineHeight: 1.6, color: muted }}>
Looking to unlock <strong>version history</strong> and <strong>document export</strong>? That's a separate,
per-workspace step — redeem a community-license key under{' '}
<a href="/app/settings/billing" style={{ color: 'var(--accent, #6366f1)' }}>Settings → Billing</a>.
</p>

{!cfg.can_publish && (
<div style={{ marginTop: 16, border: `1px solid ${line}`, borderRadius: 10, padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: ink, marginBottom: 4 }}>Get a free community key</div>
{keyState === 'sent' ? (
<p style={{ fontSize: 13, color: 'var(--status-success, #6be39b)', margin: 0, lineHeight: 1.6 }}>
Check your inbox for the signed key, then set it as <code style={mono}>COMMUNITY_HUB_KEY</code> in this
instance's environment and restart the API. Publishing turns on once it's set.
</p>
) : (
<>
<p style={{ fontSize: 12.5, color: muted, margin: '0 0 10px', lineHeight: 1.6 }}>
We'll email you a free signed community-license key. Set it as <code style={mono}>COMMUNITY_HUB_KEY</code> to
enable publishing from this instance.
</p>
<form onSubmit={requestKey} style={{ display: 'flex', gap: 8 }}>
<input
type="email" required value={email} placeholder="you@example.com"
onChange={(e) => setEmail(e.target.value)}
style={{ flex: 1, padding: '8px 10px', borderRadius: 8, border: `1px solid ${line}`, background: 'var(--bg, #1b1b1e)', color: 'inherit', fontSize: 14 }}
/>
<button
type="submit" disabled={keyState === 'sending'}
style={{ padding: '8px 14px', borderRadius: 8, border: 'none', background: 'var(--accent, #6366f1)', color: '#fff', fontSize: 14, fontWeight: 600, cursor: keyState === 'sending' ? 'default' : 'pointer' }}
>
{keyState === 'sending' ? 'Sending…' : 'Email me a key'}
</button>
</form>
{keyErr && <p style={{ fontSize: 12, color: 'var(--status-warn, #ff7a8a)', margin: '8px 0 0' }}>{keyErr}</p>}
</>
)}
</div>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/RedeemLicense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export function RedeemLicense(): JSX.Element {
return (
<div style={{ marginTop: 32, padding: 16, border: '0.5px solid var(--border, rgba(0,0,0,0.08))', borderRadius: 10, maxWidth: 460 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink)', marginBottom: 4 }}>Have a license key?</div>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>Enter it to activate your plan.</div>
<div style={{ fontSize: 12.5, color: 'var(--ink-soft)', marginBottom: 12 }}>Paste a plan key (MNEMA-…) or a community-license key to activate.</div>
<div style={{ display: 'flex', gap: 8 }}>
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="MNEMA-XXXX-XXXX-XXXX-XXXX"
<input value={key} onChange={(e) => 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)' }} />
<button onClick={redeem} disabled={state === 'busy' || !key.trim()}
style={{ padding: '7px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, color: '#fff', background: 'var(--accent, #6366f1)', border: 'none', cursor: 'pointer' }}>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/layouts/SettingsLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const NAV_SECTIONS = [
items: [
{ href: '/app/settings/workspace', label: 'Workspace' },
{ href: '/app/settings/api-keys', label: 'API Keys' },
{ href: '/app/settings/community', label: 'Community' },
{ href: '/app/settings/dev', label: 'Dev / AgentLens' },
],
},
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/pages/app/settings/community.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import SettingsLayout from '../../../layouts/SettingsLayout.astro';
import { CommunitySettings } from '../../../components/settings/CommunitySettings';

const auth = Astro.locals.auth;
if (!auth) {
return Astro.redirect('/auth/login');
}
---

<SettingsLayout title="Community">
<p style="font:500 11px/1 var(--mono);letter-spacing:0.08em;text-transform:uppercase;color:var(--ink-muted);margin:0 0 12px;">Settings</p>
<h1 style="margin:0;font:500 28px/1.15 var(--sans);letter-spacing:-0.02em;color:var(--ink);">Community</h1>
<p style="margin:6px 0 36px;font-size:14px;color:var(--ink-soft);max-width:36rem;">Browse and import community flows, and publish your own for others to use.</p>

<CommunitySettings client:load />
</SettingsLayout>
Loading