@growthub/cli 0.9.11 → 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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +406 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +767 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +23 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +139 -28
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +189 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +433 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +58 -7
- package/dist/index.js +3 -1
- 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,23 +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"
|
|
49
|
-
|
|
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>
|
|
72
|
+
<Link href="/data-model">Data Model</Link>
|
|
53
73
|
<Link className="active" href="/settings/integrations">Integrations</Link>
|
|
54
|
-
<span className="workspace-nav-static">Workspace Settings</span>
|
|
55
74
|
<span className="workspace-nav-static">Management</span>
|
|
75
|
+
<Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
|
|
56
76
|
</nav>
|
|
57
77
|
<div className="workspace-rail-status">
|
|
58
78
|
<span className="status-dot" />
|
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx
ADDED
|
@@ -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
|
+
};
|