@growthub/cli 0.9.12 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +22 -5
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +689 -6
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +22 -3
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +19 -4
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +186 -1
  17. package/dist/index.js +3 -1
  18. package/package.json +1 -1
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import { Eye, EyeOff, Link as LinkIcon, Plus, X } from "lucide-react";
4
+ import { useState } from "react";
5
+
6
+ const PAGE_SIZE = 10;
7
+
8
+ function blankRef(kind) {
9
+ return {
10
+ id: kind === "webhook" ? "webhook-ref" : "api-ref",
11
+ kind,
12
+ endpointRef: "",
13
+ value: "",
14
+ url: "",
15
+ showValue: false,
16
+ showUrl: false,
17
+ hasSecret: false,
18
+ status: "not-configured"
19
+ };
20
+ }
21
+
22
+ function normalizeRef(item, index) {
23
+ const kind = item.kind === "webhook" ? "webhook" : "api";
24
+ return {
25
+ id: item.id || `${kind}-ref-${index + 1}`,
26
+ kind,
27
+ endpointRef: item.endpointRef || "",
28
+ value: "",
29
+ url: item.url || "",
30
+ showValue: false,
31
+ showUrl: Boolean(item.url),
32
+ hasSecret: item.hasSecret === true,
33
+ status: item.status || "not-configured"
34
+ };
35
+ }
36
+
37
+ function displayKind(kind) {
38
+ return kind === "webhook" ? "Webhook" : "API";
39
+ }
40
+
41
+ function SecretValue({ item, onChange, onToggle }) {
42
+ const hiddenValue = item.hasSecret && !item.showValue;
43
+ return <div className="workspace-secret-field">
44
+ <input
45
+ aria-label={`${displayKind(item.kind)} value`}
46
+ autoComplete="off"
47
+ placeholder={item.hasSecret ? "••••••••••••" : "Value"}
48
+ type={hiddenValue ? "password" : "text"}
49
+ value={hiddenValue ? "************" : item.value}
50
+ onChange={(event) => onChange(event.target.value)}
51
+ onFocus={() => {
52
+ if (hiddenValue) onToggle(true);
53
+ }}
54
+ />
55
+ <button type="button" aria-label={item.showValue ? "Hide value" : "Show value"} onClick={() => onToggle(!item.showValue)}>
56
+ {item.showValue ? <EyeOff size={15} /> : <Eye size={15} />}
57
+ </button>
58
+ </div>;
59
+ }
60
+
61
+ function ConfigRow({ item, index, copiedKey, onCopyKey, onRemove, onUpdate }) {
62
+ const configured = item.hasSecret || item.status === "configured";
63
+ const keyText = item.endpointRef || "";
64
+ return <article className="workspace-secret-row">
65
+ <h3>{displayKind(item.kind)}</h3>
66
+ <div className="workspace-secret-row-main">
67
+ {configured && keyText ? <button
68
+ type="button"
69
+ className="workspace-key-field"
70
+ data-tooltip={copiedKey === item.id ? "Copied" : "Click to copy"}
71
+ onClick={() => onCopyKey(item)}
72
+ >{keyText}</button>
73
+ : <input
74
+ aria-label={`${displayKind(item.kind)} key`}
75
+ value={item.endpointRef}
76
+ onChange={(event) => onUpdate(index, { endpointRef: event.target.value })}
77
+ placeholder="Key"
78
+ />}
79
+ <SecretValue
80
+ item={item}
81
+ onChange={(value) => onUpdate(index, { value, hasSecret: Boolean(value) || item.hasSecret, showValue: true })}
82
+ onToggle={(showValue) => onUpdate(index, { showValue })}
83
+ />
84
+ <button type="button" className="workspace-icon-button" aria-label={`Remove ${displayKind(item.kind)} ref`} onClick={() => onRemove(index)}>
85
+ <X size={15} />
86
+ </button>
87
+ </div>
88
+ {item.showUrl || item.url ? <label className="workspace-url-field">
89
+ <span>{displayKind(item.kind)} URL</span>
90
+ <input
91
+ value={item.url}
92
+ onChange={(event) => onUpdate(index, { url: event.target.value, showUrl: true })}
93
+ placeholder={item.kind === "webhook" ? "Webhook URL" : "API URL"}
94
+ />
95
+ </label> : <button type="button" className="workspace-link-button" onClick={() => onUpdate(index, { showUrl: true })}>
96
+ <LinkIcon size={13} aria-hidden="true" />
97
+ <span>Add optional {displayKind(item.kind)} URL</span>
98
+ </button>}
99
+ </article>;
100
+ }
101
+
102
+ function ApisWebhooksForm({ persistence, refs }) {
103
+ const initialRefs = refs.length ? refs.map(normalizeRef) : [blankRef("api"), blankRef("webhook")];
104
+ const [items, setItems] = useState(initialRefs);
105
+ const [message, setMessage] = useState("");
106
+ const [saving, setSaving] = useState(false);
107
+ const [copiedKey, setCopiedKey] = useState("");
108
+ const [page, setPage] = useState(0);
109
+ const canSave = persistence?.canSave !== false;
110
+ const pageCount = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
111
+ const activePage = Math.min(page, pageCount - 1);
112
+ const visibleItems = items.slice(activePage * PAGE_SIZE, activePage * PAGE_SIZE + PAGE_SIZE);
113
+
114
+ function updateItem(index, patch) {
115
+ setItems((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
116
+ }
117
+
118
+ function removeItem(index) {
119
+ setItems((current) => current.filter((_, itemIndex) => itemIndex !== index));
120
+ }
121
+
122
+ async function copyKey(item) {
123
+ const value = item.endpointRef || "";
124
+ if (!value) return;
125
+ await navigator.clipboard?.writeText(value);
126
+ setCopiedKey(item.id);
127
+ window.setTimeout(() => setCopiedKey(""), 1400);
128
+ }
129
+
130
+ async function save(event) {
131
+ event.preventDefault();
132
+ if (!canSave || saving) return;
133
+ setSaving(true);
134
+ setMessage("");
135
+ try {
136
+ const response = await fetch("/api/settings/apis-webhooks", {
137
+ method: "PATCH",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify({
140
+ refs: items.map((item) => ({
141
+ id: item.id,
142
+ kind: item.kind,
143
+ endpointRef: item.endpointRef,
144
+ url: item.url,
145
+ status: item.endpointRef || item.value ? "configured" : "not-configured",
146
+ hasSecret: Boolean(item.value) || item.hasSecret
147
+ }))
148
+ })
149
+ });
150
+ const payload = await response.json();
151
+ if (!response.ok) throw new Error(payload.guidance || payload.error || "Failed to save API/Webhook refs");
152
+ setItems(payload.refs.length ? payload.refs.map(normalizeRef) : [blankRef("api"), blankRef("webhook")]);
153
+ setMessage("Saved.");
154
+ } catch (error) {
155
+ setMessage(error.message || "Failed to save.");
156
+ } finally {
157
+ setSaving(false);
158
+ }
159
+ }
160
+
161
+ return <form className="workspace-settings-card" onSubmit={save}>
162
+ <div className="workspace-settings-card-heading">
163
+ <div>
164
+ <h2>APIs & Webhooks</h2>
165
+ <p>Save secret references for this workspace. Values are hidden after save.</p>
166
+ </div>
167
+ <div className="workspace-settings-top-actions">
168
+ <button type="button" className="workspace-secondary-button" onClick={() => setItems((current) => [...current, blankRef("api")])}>
169
+ <Plus size={13} aria-hidden="true" />
170
+ <span>Add API key</span>
171
+ </button>
172
+ <button type="button" className="workspace-secondary-button" onClick={() => setItems((current) => [...current, blankRef("webhook")])}>
173
+ <Plus size={13} aria-hidden="true" />
174
+ <span>Add webhook key</span>
175
+ </button>
176
+ <button type="submit" disabled={!canSave || saving}>{saving ? "Saving..." : "Save"}</button>
177
+ </div>
178
+ </div>
179
+
180
+ <section className="workspace-settings-section">
181
+ <div className="workspace-secret-list">
182
+ {visibleItems.map((item, visibleIndex) => {
183
+ const index = activePage * PAGE_SIZE + visibleIndex;
184
+ return <ConfigRow
185
+ copiedKey={copiedKey}
186
+ index={index}
187
+ item={item}
188
+ key={`${item.id}:${index}`}
189
+ onCopyKey={copyKey}
190
+ onRemove={removeItem}
191
+ onUpdate={updateItem}
192
+ />;
193
+ })}
194
+ </div>
195
+ {pageCount > 1 ? <div className="workspace-pagination">
196
+ <button type="button" disabled={activePage === 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>Previous</button>
197
+ <span>{activePage + 1} / {pageCount}</span>
198
+ <button type="button" disabled={activePage >= pageCount - 1} onClick={() => setPage((value) => Math.min(pageCount - 1, value + 1))}>Next</button>
199
+ </div> : null}
200
+ </section>
201
+
202
+ {message ? <p className="workspace-settings-message">{message}</p> : null}
203
+ </form>;
204
+ }
205
+
206
+ export {
207
+ ApisWebhooksForm
208
+ };
@@ -0,0 +1,19 @@
1
+ import { SettingsShell } from "../settings-shell.jsx";
2
+ import { readAdapterConfig } from "@/lib/adapters/env";
3
+ import { describePersistenceMode, readWorkspaceConfig } from "@/lib/workspace-config";
4
+ import { ApisWebhooksForm } from "./apis-webhooks-form.jsx";
5
+
6
+ async function ApisWebhooksSettingsPage() {
7
+ const config = readAdapterConfig();
8
+ const workspaceConfig = await readWorkspaceConfig();
9
+ const integrations = Array.isArray(workspaceConfig.integrations) ? workspaceConfig.integrations : [];
10
+ const refs = integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
11
+
12
+ return <SettingsShell active="/settings/apis-webhooks" eyebrow="Settings" title="APIs & Webhooks">
13
+ <ApisWebhooksForm adapter={config.integrationAdapter} persistence={describePersistenceMode()} refs={refs} />
14
+ </SettingsShell>;
15
+ }
16
+
17
+ export {
18
+ ApisWebhooksSettingsPage as default
19
+ };
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ const PAGE_SIZE = 10;
6
+
7
+ function AppsList({ apps }) {
8
+ const [page, setPage] = useState(0);
9
+ const pageCount = Math.max(1, Math.ceil(apps.length / PAGE_SIZE));
10
+ const activePage = Math.min(page, pageCount - 1);
11
+ const visibleApps = apps.slice(activePage * PAGE_SIZE, activePage * PAGE_SIZE + PAGE_SIZE);
12
+
13
+ if (!apps.length) {
14
+ return <p className="workspace-settings-empty">No workspace apps are declared on the active config.</p>;
15
+ }
16
+
17
+ return <div className="workspace-paginated-list">
18
+ <div className="workspace-app-list bounded">
19
+ {visibleApps.map((item, index) => <article className="workspace-app-row" key={item.id || index}>
20
+ <span className="workspace-provider-mark">{item.icon || item.label?.slice(0, 1) || item.name?.slice(0, 1) || "A"}</span>
21
+ <div>
22
+ <strong>{item.label || item.name || item.id}</strong>
23
+ <p>{item.description || "Workspace app metadata from the active config."}</p>
24
+ <div className="workspace-integration-meta">
25
+ <span>{item.provider || "workspace"}</span>
26
+ <span>{item.source || "config"}</span>
27
+ <span>{item.authority || "read-only"}</span>
28
+ </div>
29
+ </div>
30
+ <span className={`workspace-integration-status ${item.status || "available"}`}>{item.status || "available"}</span>
31
+ </article>)}
32
+ </div>
33
+ {pageCount > 1 ? <div className="workspace-pagination">
34
+ <button type="button" disabled={activePage === 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>Previous</button>
35
+ <span>{activePage + 1} / {pageCount}</span>
36
+ <button type="button" disabled={activePage >= pageCount - 1} onClick={() => setPage((value) => Math.min(pageCount - 1, value + 1))}>Next</button>
37
+ </div> : null}
38
+ </div>;
39
+ }
40
+
41
+ export {
42
+ AppsList
43
+ };
@@ -0,0 +1,109 @@
1
+ import { SettingsShell } from "../settings-shell.jsx";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { readWorkspaceConfig } from "@/lib/workspace-config";
5
+ import { AppsList } from "./apps-list.jsx";
6
+
7
+ async function readForkMetadata() {
8
+ try {
9
+ const forkPath = path.resolve(process.cwd(), "../..", ".growthub-fork", "fork.json");
10
+ return JSON.parse(await fs.readFile(forkPath, "utf8"));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ async function pathExists(filePath) {
17
+ try {
18
+ await fs.access(filePath);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function readWorkspaceDirectoryApps() {
26
+ const appsRoot = path.dirname(process.cwd());
27
+ let entries = [];
28
+ try {
29
+ entries = await fs.readdir(appsRoot, { withFileTypes: true });
30
+ } catch {
31
+ return [];
32
+ }
33
+
34
+ const apps = [];
35
+ for (const entry of entries) {
36
+ if (!entry.isDirectory()) continue;
37
+ const appPath = path.join(appsRoot, entry.name);
38
+ const [hasPackage, hasNext, hasVite] = await Promise.all([
39
+ pathExists(path.join(appPath, "package.json")),
40
+ pathExists(path.join(appPath, "next.config.js")),
41
+ pathExists(path.join(appPath, "vite.config.js"))
42
+ ]);
43
+ if (!hasPackage && !hasNext && !hasVite) continue;
44
+ let packageName = "";
45
+ if (hasPackage) {
46
+ try {
47
+ const pkg = JSON.parse(await fs.readFile(path.join(appPath, "package.json"), "utf8"));
48
+ packageName = pkg.name || "";
49
+ } catch {
50
+ packageName = "";
51
+ }
52
+ }
53
+ apps.push({
54
+ id: entry.name,
55
+ name: entry.name === "workspace" ? "Workspace" : entry.name,
56
+ description: entry.name === "workspace" ? "Default Growthub workspace app." : "Workspace app discovered from the apps directory.",
57
+ provider: packageName || "local",
58
+ source: `apps/${entry.name}`,
59
+ authority: "directory",
60
+ status: "available"
61
+ });
62
+ }
63
+ return apps.sort((a, b) => a.id.localeCompare(b.id));
64
+ }
65
+
66
+ async function AppsSettingsPage() {
67
+ const workspaceConfig = await readWorkspaceConfig();
68
+ const fork = await readForkMetadata();
69
+ const directoryApps = await readWorkspaceDirectoryApps();
70
+ const configApps = Array.isArray(workspaceConfig.apps) ? workspaceConfig.apps : [];
71
+ const appsById = new Map(directoryApps.map((item) => [item.id, item]));
72
+ for (const item of configApps) {
73
+ const id = item.id || item.name;
74
+ if (!id) continue;
75
+ appsById.set(id, { ...(appsById.get(id) || {}), ...item });
76
+ }
77
+ const apps = Array.from(appsById.values());
78
+ const bridge = workspaceConfig.bridge && typeof workspaceConfig.bridge === "object" ? workspaceConfig.bridge : null;
79
+
80
+ return <SettingsShell active="/settings/apps" eyebrow="Settings" title="Apps">
81
+ <section className="workspace-settings-card workspace-apps-card">
82
+ <div className="workspace-settings-card-heading">
83
+ <div>
84
+ <h2>Apps</h2>
85
+ <p>Read-only workspace app, bridge, and fork metadata already available to this workspace.</p>
86
+ </div>
87
+ </div>
88
+
89
+ <section className="workspace-settings-section workspace-apps-linkage-section">
90
+ <h3>Workspace Linkage</h3>
91
+ <div className="workspace-settings-kv">
92
+ <span>Workspace</span><code>{workspaceConfig.id || "workspace-builder-default"}</code>
93
+ <span>Fork</span><code>{fork?.forkId || "local fork metadata unavailable"}</code>
94
+ <span>Kit</span><code>{fork?.kitId || workspaceConfig.provenance?.mirrors || "growthub-custom-workspace-starter-v1"}</code>
95
+ <span>Bridge</span><code>{bridge?.status || bridge?.id || "not connected"}</code>
96
+ </div>
97
+ </section>
98
+
99
+ <section className="workspace-settings-section workspace-apps-list-section">
100
+ <h3>Workspace Apps</h3>
101
+ <AppsList apps={apps} />
102
+ </section>
103
+ </section>
104
+ </SettingsShell>;
105
+ }
106
+
107
+ export {
108
+ AppsSettingsPage as default
109
+ };
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Upload, X } from "lucide-react";
5
+
6
+ function textColorForAccent(accent) {
7
+ const hex = String(accent || "").replace("#", "");
8
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
9
+ const red = parseInt(hex.slice(0, 2), 16);
10
+ const green = parseInt(hex.slice(2, 4), 16);
11
+ const blue = parseInt(hex.slice(4, 6), 16);
12
+ const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
13
+ return luminance > 0.62 ? "#252525" : "#ffffff";
14
+ }
15
+
16
+ function GeneralSettingsForm({ workspace, persistence }) {
17
+ const initialBranding = workspace.branding || {};
18
+ const [name, setName] = useState(workspace.name || "Growthub Workspace");
19
+ const [logoUrl, setLogoUrl] = useState(initialBranding.logoUrl || "");
20
+ const [accent, setAccent] = useState(initialBranding.accent || "#3f68ff");
21
+ const [saving, setSaving] = useState(false);
22
+ const [message, setMessage] = useState("");
23
+
24
+ const canSave = persistence?.canSave !== false;
25
+ const previewInitial = useMemo(() => (name || "G").trim().slice(0, 1).toUpperCase(), [name]);
26
+
27
+ function uploadLogo(event) {
28
+ const file = event.target.files?.[0];
29
+ if (!file) return;
30
+ const reader = new FileReader();
31
+ reader.onload = () => {
32
+ if (typeof reader.result === "string") setLogoUrl(reader.result);
33
+ };
34
+ reader.readAsDataURL(file);
35
+ }
36
+
37
+ async function saveSettings(event) {
38
+ event.preventDefault();
39
+ if (!canSave || saving) return;
40
+ setSaving(true);
41
+ setMessage("");
42
+ try {
43
+ const response = await fetch("/api/settings/workspace", {
44
+ method: "PATCH",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify({
47
+ name,
48
+ branding: {
49
+ name,
50
+ logoUrl,
51
+ accent
52
+ }
53
+ })
54
+ });
55
+ const payload = await response.json();
56
+ if (!response.ok) {
57
+ throw new Error(payload.guidance || payload.error || "Failed to save settings");
58
+ }
59
+ setName(payload.workspace?.name || name);
60
+ setLogoUrl(payload.workspace?.branding?.logoUrl || "");
61
+ setAccent(payload.workspace?.branding?.accent || accent);
62
+ setMessage("Saved workspace identity.");
63
+ } catch (error) {
64
+ setMessage(error.message || "Failed to save settings.");
65
+ } finally {
66
+ setSaving(false);
67
+ }
68
+ }
69
+
70
+ return <form className="workspace-settings-card" onSubmit={saveSettings}>
71
+ <div className="workspace-settings-card-heading">
72
+ <div>
73
+ <h2>General</h2>
74
+ <p>Workspace-scoped identity for this governed workspace only.</p>
75
+ </div>
76
+ </div>
77
+
78
+ <section className="workspace-settings-section">
79
+ <h3>Workspace Identity</h3>
80
+ <div className="workspace-logo-controls">
81
+ <div className="workspace-logo-preview">
82
+ <span className="workspace-logo-placeholder" aria-hidden="true" style={{
83
+ backgroundColor: logoUrl ? undefined : accent,
84
+ color: logoUrl ? undefined : textColorForAccent(accent)
85
+ }}>
86
+ {logoUrl ? <img src={logoUrl} alt="" /> : previewInitial}
87
+ </span>
88
+ </div>
89
+ <div className="workspace-logo-actions">
90
+ <div>
91
+ <label className="workspace-file-button">
92
+ <Upload size={14} aria-hidden="true" />
93
+ <span>Upload</span>
94
+ <input accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" type="file" onChange={uploadLogo} />
95
+ </label>
96
+ <button className="workspace-remove-button" type="button" onClick={() => setLogoUrl("")} disabled={!logoUrl}>
97
+ <X size={14} aria-hidden="true" />
98
+ <span>Remove</span>
99
+ </button>
100
+ </div>
101
+ <p>Square PNGs, JPEGs, GIFs, WEBPs, or SVGs.</p>
102
+ </div>
103
+ </div>
104
+ <div className="workspace-settings-grid">
105
+ <label>
106
+ <span>Workspace name</span>
107
+ <input value={name} onChange={(event) => setName(event.target.value)} />
108
+ </label>
109
+ </div>
110
+ <div className="workspace-settings-grid logo-grid">
111
+ <label>
112
+ <span>Logo reference</span>
113
+ <input value={logoUrl} onChange={(event) => setLogoUrl(event.target.value)} placeholder="Upload a file or paste a safe URL" />
114
+ </label>
115
+ <label>
116
+ <span>Color</span>
117
+ <span className="workspace-color-field">
118
+ <input aria-label="Workspace color" type="color" value={accent} onChange={(event) => setAccent(event.target.value)} />
119
+ <input value={accent} onChange={(event) => setAccent(event.target.value)} placeholder="#3f68ff" />
120
+ </span>
121
+ </label>
122
+ </div>
123
+ </section>
124
+
125
+ <div className="workspace-settings-actions">
126
+ {message ? <p>{message}</p> : <p>{canSave ? "" : persistence?.guidance}</p>}
127
+ <button className="workspace-save-button" type="submit" disabled={!canSave || saving}>{saving ? "Saving..." : "Save"}</button>
128
+ </div>
129
+ </form>;
130
+ }
131
+
132
+ export {
133
+ GeneralSettingsForm
134
+ };
@@ -0,0 +1,25 @@
1
+ import { SettingsShell } from "../settings-shell.jsx";
2
+ import { GeneralSettingsForm } from "./general-settings-form.jsx";
3
+ import {
4
+ describePersistenceMode,
5
+ readWorkspaceConfig
6
+ } from "@/lib/workspace-config";
7
+
8
+ async function GeneralSettingsPage() {
9
+ const workspaceConfig = await readWorkspaceConfig();
10
+ const persistence = describePersistenceMode();
11
+ return <SettingsShell active="/settings/general" eyebrow="Settings" title="General">
12
+ <GeneralSettingsForm
13
+ workspace={{
14
+ id: workspaceConfig.id,
15
+ name: workspaceConfig.name,
16
+ branding: workspaceConfig.branding || {}
17
+ }}
18
+ persistence={persistence}
19
+ />
20
+ </SettingsShell>;
21
+ }
22
+
23
+ export {
24
+ GeneralSettingsPage as default
25
+ };
@@ -2,6 +2,7 @@ import Link from "next/link";
2
2
  import { readAdapterConfig } from "@/lib/adapters/env";
3
3
  import { describeIntegrationAdapter, listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
4
4
  import { groupIntegrationsByLane } from "@/lib/domain/integrations";
5
+ import { readWorkspaceConfig } from "@/lib/workspace-config";
5
6
 
6
7
  function countConnected(rows) {
7
8
  return rows.filter((item) => item.isConnected || item.status === "connected").length;
@@ -36,24 +37,42 @@ function IntegrationRow({ item }) {
36
37
  </article>;
37
38
  }
38
39
 
40
+ function textColorForAccent(accent) {
41
+ const hex = String(accent || "").replace("#", "");
42
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff";
43
+ const red = parseInt(hex.slice(0, 2), 16);
44
+ const green = parseInt(hex.slice(2, 4), 16);
45
+ const blue = parseInt(hex.slice(4, 6), 16);
46
+ const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
47
+ return luminance > 0.62 ? "#252525" : "#ffffff";
48
+ }
49
+
39
50
  async function IntegrationsSettingsPage() {
40
51
  const config = readAdapterConfig();
41
52
  const adapter = describeIntegrationAdapter();
53
+ const workspaceConfig = await readWorkspaceConfig();
54
+ const branding = workspaceConfig.branding || {};
55
+ const workspaceName = branding.name || workspaceConfig.name || "Growthub Workspace";
42
56
  const grouped = groupIntegrationsByLane(await listGovernedWorkspaceIntegrations());
43
57
  const allRows = [...grouped.dataSources, ...grouped.workspaceIntegrations];
44
58
 
45
59
  return <main className="workspace-builder workspace-settings-page">
46
60
  <aside className="workspace-rail" aria-label="Workspace navigation">
47
61
  <div className="workspace-brand">
48
- <span className="workspace-mark">G</span>
49
- <span>Growthub Workspace</span>
62
+ <span className="workspace-mark" style={{
63
+ background: branding.logoUrl ? undefined : branding.accent || undefined,
64
+ color: branding.logoUrl ? undefined : textColorForAccent(branding.accent)
65
+ }}>
66
+ {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
67
+ </span>
68
+ <span>{workspaceName}</span>
50
69
  </div>
51
70
  <nav className="workspace-nav">
52
71
  <Link href="/">Dashboards</Link>
53
72
  <Link href="/data-model">Data Model</Link>
54
73
  <Link className="active" href="/settings/integrations">Integrations</Link>
55
- <span className="workspace-nav-static">Workspace Settings</span>
56
74
  <span className="workspace-nav-static">Management</span>
75
+ <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
57
76
  </nav>
58
77
  <div className="workspace-rail-status">
59
78
  <span className="status-dot" />
@@ -0,0 +1,25 @@
1
+ import { SettingsShell } from "./settings-shell.jsx";
2
+ import { GeneralSettingsForm } from "./general/general-settings-form.jsx";
3
+ import {
4
+ describePersistenceMode,
5
+ readWorkspaceConfig
6
+ } from "@/lib/workspace-config";
7
+
8
+ async function SettingsIndexPage() {
9
+ const workspaceConfig = await readWorkspaceConfig();
10
+ const persistence = describePersistenceMode();
11
+ return <SettingsShell active="/settings/general" eyebrow="Settings" title="General">
12
+ <GeneralSettingsForm
13
+ workspace={{
14
+ id: workspaceConfig.id,
15
+ name: workspaceConfig.name,
16
+ branding: workspaceConfig.branding || {}
17
+ }}
18
+ persistence={persistence}
19
+ />
20
+ </SettingsShell>;
21
+ }
22
+
23
+ export {
24
+ SettingsIndexPage as default
25
+ };
@@ -0,0 +1,33 @@
1
+ import Link from "next/link";
2
+ import { X } from "lucide-react";
3
+
4
+ const SETTINGS_TABS = [
5
+ { href: "/settings/general", label: "General" },
6
+ { href: "/settings/apis-webhooks", label: "APIs & Webhooks" },
7
+ { href: "/settings/apps", label: "Apps" }
8
+ ];
9
+
10
+ function SettingsShell({ active, eyebrow, title, children, aside }) {
11
+ return <main className="workspace-settings-shell">
12
+ <header className="workspace-settings-topbar">
13
+ <Link className="workspace-settings-exit" href="/" aria-label="Exit settings" title="Exit Settings">
14
+ <X size={16} aria-hidden="true" />
15
+ </Link>
16
+ <nav className="workspace-settings-tabs" aria-label="Settings navigation">
17
+ {SETTINGS_TABS.map((tab) => <Link
18
+ className={active === tab.href ? "active" : undefined}
19
+ href={tab.href}
20
+ key={tab.href}
21
+ >{tab.label}</Link>)}
22
+ </nav>
23
+ {aside ? <div className="workspace-settings-aside">{aside}</div> : null}
24
+ </header>
25
+ <section className="workspace-settings-main">
26
+ {children}
27
+ </section>
28
+ </main>;
29
+ }
30
+
31
+ export {
32
+ SettingsShell
33
+ };