@korso/shepherd-ui 0.2.2 → 0.3.0
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/dist/ShepherdRoot.js +9 -3
- package/dist/client.d.ts +10 -1
- package/dist/client.js +13 -1
- package/dist/components/Dashboard.js +2 -1
- package/dist/components/FeedbackWidget.d.ts +5 -0
- package/dist/components/FeedbackWidget.js +50 -0
- package/dist/config/ConnectAgent.js +17 -2
- package/dist/config/Workspaces.d.ts +4 -1
- package/dist/config/Workspaces.js +29 -9
- package/dist/lib/{Dashboard-B7nioe5N.js → Dashboard-Bgi50wCo.js} +473 -363
- package/dist/lib/index.d.ts +55 -4
- package/dist/lib/index.js +272 -226
- package/dist/lib/selfhost.js +1 -1
- package/dist/lib/styles.css +74 -18
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +89 -0
- package/dist/selfhost/assets/index-D0C3m6st.js +40 -0
- package/dist/selfhost/assets/{index-CcGvL65Q.css → index-rNR1Acph.css} +1 -1
- package/dist/selfhost/index.html +2 -2
- package/dist/test/mockClient.js +6 -1
- package/package.json +1 -1
- package/dist/selfhost/assets/index-COMbJ-a4.js +0 -40
package/dist/ShepherdRoot.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useEffect, useState } from "react";
|
|
3
3
|
import { Dashboard } from "./components/Dashboard.js";
|
|
4
4
|
import { Workspaces } from "./config/Workspaces.js";
|
|
5
|
+
import { Members } from "./config/Members.js";
|
|
5
6
|
import { ConnectAgent } from "./config/ConnectAgent.js";
|
|
6
7
|
import { useShepherdClient } from "./context.js";
|
|
7
8
|
import { describeError } from "./client.js";
|
|
@@ -9,6 +10,9 @@ export function ShepherdRoot({ hubUrl }) {
|
|
|
9
10
|
const client = useShepherdClient();
|
|
10
11
|
const [load, setLoad] = useState({ status: "loading" });
|
|
11
12
|
const [selectedId, setSelectedId] = useState(null);
|
|
13
|
+
// Bumped whenever a mutation may have changed the selected workspace's
|
|
14
|
+
// roster (an invite created, a code redeemed), so <Members> refetches.
|
|
15
|
+
const [membersRefreshKey, setMembersRefreshKey] = useState(0);
|
|
12
16
|
// Loads the workspace list and keeps the selection valid: a still-present
|
|
13
17
|
// selection is preserved (so a create/join refresh never yanks the user off
|
|
14
18
|
// their current workspace), otherwise it falls back to the first workspace.
|
|
@@ -37,10 +41,12 @@ export function ShepherdRoot({ hubUrl }) {
|
|
|
37
41
|
}
|
|
38
42
|
const hasWorkspace = workspaces.length > 0;
|
|
39
43
|
// The Config tab's content. Rendered by <Dashboard> in its `config` panel.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
44
|
+
// Members is a peer section (visible to every member, not just admins) so
|
|
45
|
+
// its refresh key lives here, bumped by Workspaces after a roster-affecting
|
|
46
|
+
// mutation (invite created, code redeemed). ConnectAgent only appears once a
|
|
47
|
+
// workspace is selected (nothing to connect an agent to otherwise).
|
|
42
48
|
const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), _jsx(Workspaces, { workspaces: workspaces, selected: selected, onChanged: () => {
|
|
43
49
|
void fetchWorkspaces();
|
|
44
|
-
}, onSelect: setSelectedId }), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
|
|
50
|
+
}, onSelect: setSelectedId, onMembersChanged: () => setMembersRefreshKey((k) => k + 1) }), selected && (_jsx(Members, { workspaceId: selected.id, refreshKey: membersRefreshKey, canRemove: selected.role === "admin", onLeft: () => void fetchWorkspaces() })), selected && _jsx(ConnectAgent, { workspaceId: selected.id, hubUrl: hubUrl })] }));
|
|
45
51
|
return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, hasWorkspace: hasWorkspace }) }));
|
|
46
52
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT } from "@shepherd/shared";
|
|
1
|
+
import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, InviteByEmailResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT, FeedbackRequestT, FeedbackResponseT } from "@shepherd/shared";
|
|
2
2
|
/**
|
|
3
3
|
* The single error type surfaced by the client. A missing `status` means a
|
|
4
4
|
* transport failure (network error or request timeout); a present `status` is
|
|
@@ -51,6 +51,8 @@ export interface ShepherdClient {
|
|
|
51
51
|
revokeToken(workspaceId: string, tokenId: string): Promise<void>;
|
|
52
52
|
/** POST a new invite code for the workspace (admin only). */
|
|
53
53
|
createInvite(workspaceId: string, body: CreateInviteRequestT): Promise<InviteResponseT>;
|
|
54
|
+
/** POST a one-time-use invite emailed directly to an address (admin only). */
|
|
55
|
+
inviteByEmail(workspaceId: string, email: string): Promise<InviteByEmailResponseT>;
|
|
54
56
|
/** POST to revoke an invite code (admin only). */
|
|
55
57
|
revokeInvite(workspaceId: string, code: string): Promise<void>;
|
|
56
58
|
/** POST to redeem an invite code, joining its workspace as a member. */
|
|
@@ -65,6 +67,13 @@ export interface ShepherdClient {
|
|
|
65
67
|
landscape(workspaceId: string): Promise<WorkspaceLandscapeResponseT>;
|
|
66
68
|
/** POST an operator announcement to a specific workspace. */
|
|
67
69
|
announceTo(workspaceId: string, body: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
|
|
70
|
+
/**
|
|
71
|
+
* POST a bug/suggestion/other note from the feedback widget. Hits
|
|
72
|
+
* `/workspaces/:id/feedback` when `workspaceId` is given (the normal hosted
|
|
73
|
+
* case — a validated workspace to attach), or the flat `/feedback` otherwise
|
|
74
|
+
* (self-host, or a hosted caller with no workspace selected yet).
|
|
75
|
+
*/
|
|
76
|
+
submitFeedback(body: FeedbackRequestT, workspaceId?: string): Promise<FeedbackResponseT>;
|
|
68
77
|
/** GET the unfiltered whole-workspace view (agents, tasks, announcements). */
|
|
69
78
|
getLandscape(): Promise<WorkspaceLandscapeResponseT>;
|
|
70
79
|
/** POST an operator announcement (broadcast or @targeted) to the workspace. */
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, } from "@shepherd/shared";
|
|
1
|
+
import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, InviteByEmailResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, FeedbackResponse, } from "@shepherd/shared";
|
|
2
2
|
/** Per-request abort fires after this many ms when no timeoutMs is configured. */
|
|
3
3
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
4
4
|
/**
|
|
@@ -183,6 +183,12 @@ export function createShepherdClient(config) {
|
|
|
183
183
|
schema: InviteResponse,
|
|
184
184
|
});
|
|
185
185
|
},
|
|
186
|
+
inviteByEmail(workspaceId, email) {
|
|
187
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/invites/email`, {
|
|
188
|
+
body: { email },
|
|
189
|
+
schema: InviteByEmailResponse,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
186
192
|
revokeInvite(workspaceId, code) {
|
|
187
193
|
return request("POST", `/workspaces/${enc(workspaceId)}/invites/${enc(code)}/revoke`);
|
|
188
194
|
},
|
|
@@ -218,6 +224,12 @@ export function createShepherdClient(config) {
|
|
|
218
224
|
schema: WorkspaceAnnounceResponse,
|
|
219
225
|
});
|
|
220
226
|
},
|
|
227
|
+
submitFeedback(body, workspaceId) {
|
|
228
|
+
const path = workspaceId !== undefined
|
|
229
|
+
? `/workspaces/${enc(workspaceId)}/feedback`
|
|
230
|
+
: "/feedback";
|
|
231
|
+
return request("POST", path, { body, schema: FeedbackResponse });
|
|
232
|
+
},
|
|
221
233
|
// --- self-host singular aliases (implicit single workspace) -------------
|
|
222
234
|
getLandscape() {
|
|
223
235
|
// See landscape() above: this schema's inferred type doesn't structurally
|
|
@@ -9,6 +9,7 @@ import { DoneList } from "./DoneList.js";
|
|
|
9
9
|
import { Chat } from "./Chat.js";
|
|
10
10
|
import { Composer } from "./Composer.js";
|
|
11
11
|
import { EmptyState } from "../config/EmptyState.js";
|
|
12
|
+
import { FeedbackWidget } from "./FeedbackWidget.js";
|
|
12
13
|
/**
|
|
13
14
|
* localStorage keys + page size, ported verbatim from the state block of
|
|
14
15
|
* packages/hub/public/app.js (POLL_MS lives in the hook).
|
|
@@ -276,5 +277,5 @@ export function Dashboard({ workspaceId, config, hasWorkspace, } = {}) {
|
|
|
276
277
|
const showBoardChrome = !noWorkspace && activeTab !== "config";
|
|
277
278
|
return (_jsxs("div", { id: "board", children: [_jsxs("header", { children: [_jsx("h1", { className: "brand", style: { margin: 0, font: "inherit" }, children: "Shepherd" }), showBoardChrome && (_jsxs(_Fragment, { children: [_jsx(RepoSelect, { repos: repos, counts: counts, selected: effectiveRepo, onSelect: onSelectRepo }), _jsxs("span", { className: "vitals", children: [_jsx("b", { id: "vitals-online", children: online }), " online · ", _jsx("b", { id: "vitals-active", children: active }), " active"] })] })), _jsx("span", { className: "grow" }), showBoardChrome && (_jsxs(_Fragment, { children: [_jsx("span", { id: "status", className: "status" + (statusView.kind ? ` status--${statusView.kind}` : ""), children: statusView.text }), _jsx("span", { id: "freshness", className: "freshness", children: freshness })] })), _jsx("nav", { className: "tabs", role: "tablist", "aria-label": "Shepherd views", children: tabs.map(({ id, label }, index) => (_jsx("button", { ref: (el) => {
|
|
278
279
|
tabRefs.current[index] = el;
|
|
279
|
-
}, className: "tab" + (activeTab === id ? " tab--active" : ""), "data-tab": id, type: "button", role: "tab", id: `tab-${id}`, "aria-controls": `panel-${id}`, "aria-selected": activeTab === id, tabIndex: activeTab === id ? 0 : -1, onClick: () => onTab(id), onKeyDown: (e) => onTabKeyDown(e, index), children: label }, id))) })] }), _jsx("section", { id: "panel-tasks", role: "tabpanel", "aria-labelledby": "tab-tasks", hidden: activeTab !== "tasks", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs(_Fragment, { children: [_jsx(Crew, { agents: agents, tasks: tasks, selectedRepo: effectiveRepo }), _jsxs("div", { className: "board", children: [_jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Active" }), _jsx("span", { className: "n", id: "active-count", children: activeCount })] }), _jsx(ActiveList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo })] }), _jsx("div", { className: "board__rule" }), _jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Done" }), _jsx("span", { className: "n", id: "done-count", children: doneCount })] }), _jsx(DoneList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo, doneShown: doneShown, onLoadMore: onLoadMore })] })] })] })) }), _jsx("section", { id: "panel-chat", role: "tabpanel", "aria-labelledby": "tab-chat", hidden: activeTab !== "chat", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs("div", { className: "chat-wrap", children: [_jsx(Chat, { announcements: announcements, selectedRepo: effectiveRepo, nowMs: nowMs }), _jsx(Composer, { agents: agents, selectedRepo: effectiveRepo, workspaceId: workspaceId, onSent: refresh })] })) }), hasConfig && (_jsx("section", { id: "panel-config", role: "tabpanel", "aria-labelledby": "tab-config", hidden: activeTab !== "config", children: config }))] }));
|
|
280
|
+
}, className: "tab" + (activeTab === id ? " tab--active" : ""), "data-tab": id, type: "button", role: "tab", id: `tab-${id}`, "aria-controls": `panel-${id}`, "aria-selected": activeTab === id, tabIndex: activeTab === id ? 0 : -1, onClick: () => onTab(id), onKeyDown: (e) => onTabKeyDown(e, index), children: label }, id))) })] }), _jsx("section", { id: "panel-tasks", role: "tabpanel", "aria-labelledby": "tab-tasks", hidden: activeTab !== "tasks", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs(_Fragment, { children: [_jsx(Crew, { agents: agents, tasks: tasks, selectedRepo: effectiveRepo }), _jsxs("div", { className: "board", children: [_jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Active" }), _jsx("span", { className: "n", id: "active-count", children: activeCount })] }), _jsx(ActiveList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo })] }), _jsx("div", { className: "board__rule" }), _jsxs("div", { className: "col", children: [_jsxs("div", { className: "colhead", children: [_jsx("h2", { children: "Done" }), _jsx("span", { className: "n", id: "done-count", children: doneCount })] }), _jsx(DoneList, { tasks: tasks, nowMs: nowMs, selectedRepo: effectiveRepo, doneShown: doneShown, onLoadMore: onLoadMore })] })] })] })) }), _jsx("section", { id: "panel-chat", role: "tabpanel", "aria-labelledby": "tab-chat", hidden: activeTab !== "chat", children: noWorkspace ? (_jsx(EmptyState, { onGetStarted: () => onTab("config") })) : (_jsxs("div", { className: "chat-wrap", children: [_jsx(Chat, { announcements: announcements, selectedRepo: effectiveRepo, nowMs: nowMs }), _jsx(Composer, { agents: agents, selectedRepo: effectiveRepo, workspaceId: workspaceId, onSent: refresh })] })) }), hasConfig && (_jsx("section", { id: "panel-config", role: "tabpanel", "aria-labelledby": "tab-config", hidden: activeTab !== "config", children: config })), _jsx(FeedbackWidget, { workspaceId: workspaceId })] }));
|
|
280
281
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from "react";
|
|
3
|
+
import { useShepherdClient } from "../context.js";
|
|
4
|
+
import { describeError } from "../client.js";
|
|
5
|
+
const TYPES = [
|
|
6
|
+
{ id: "bug", label: "Bug" },
|
|
7
|
+
{ id: "suggestion", label: "Suggestion" },
|
|
8
|
+
{ id: "other", label: "Other" },
|
|
9
|
+
];
|
|
10
|
+
/** How long the "Thanks!" confirmation shows before the popover auto-closes. */
|
|
11
|
+
const CONFIRMATION_MS = 1500;
|
|
12
|
+
export function FeedbackWidget({ workspaceId }) {
|
|
13
|
+
const client = useShepherdClient();
|
|
14
|
+
const headingId = useId();
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [type, setType] = useState("bug");
|
|
17
|
+
const [body, setBody] = useState("");
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
const [sent, setSent] = useState(false);
|
|
21
|
+
function toggle() {
|
|
22
|
+
setOpen((o) => !o);
|
|
23
|
+
}
|
|
24
|
+
function reset() {
|
|
25
|
+
setType("bug");
|
|
26
|
+
setBody("");
|
|
27
|
+
setError(null);
|
|
28
|
+
setSent(false);
|
|
29
|
+
}
|
|
30
|
+
async function submit() {
|
|
31
|
+
setBusy(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
await client.submitFeedback({ type, body: body.trim() }, workspaceId);
|
|
35
|
+
setSent(true);
|
|
36
|
+
setBody("");
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
setOpen(false);
|
|
39
|
+
reset();
|
|
40
|
+
}, CONFIRMATION_MS);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
setError(describeError(err));
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return (_jsxs("div", { className: "shepherd-feedback", children: [_jsx("button", { type: "button", className: "shepherd-feedback__trigger", onClick: toggle, "aria-expanded": open, children: "Feedback" }), open && (_jsxs("section", { className: "shepherd-feedback__panel", "aria-labelledby": headingId, children: [_jsx("h3", { id: headingId, children: "Give feedback" }), sent ? (_jsx("p", { role: "status", children: "Thanks! Your feedback was sent." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "shepherd-feedback__types", children: TYPES.map((t) => (_jsx("button", { type: "button", "aria-pressed": type === t.id, onClick: () => setType(t.id), children: t.label }, t.id))) }), _jsx("textarea", { "aria-label": "Feedback", placeholder: "What's on your mind?", value: body, onChange: (e) => setBody(e.target.value) }), error && _jsx("p", { role: "alert", children: error }), _jsx("button", { type: "button", onClick: () => void submit(), disabled: body.trim() === "" || busy, children: "Submit" })] }))] }))] }));
|
|
50
|
+
}
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useId, useState } from "react";
|
|
3
3
|
import { useShepherdClient } from "../context.js";
|
|
4
4
|
import { describeError } from "../client.js";
|
|
5
|
+
import { formatRelative } from "../logic.js";
|
|
5
6
|
const TOOLS = [
|
|
6
7
|
{ id: "claude", label: "Claude Code" },
|
|
7
8
|
{ id: "codex", label: "Codex" },
|
|
@@ -18,6 +19,13 @@ const CLI_PREFIX = {
|
|
|
18
19
|
codex: "codex mcp add shepherd \\",
|
|
19
20
|
pi: "pi mcp add shepherd \\",
|
|
20
21
|
};
|
|
22
|
+
// "created 3d ago · never used" / "created 3d ago · last used 2h ago" — helps
|
|
23
|
+
// an operator tell which tokens are still active before revoking one.
|
|
24
|
+
function tokenMeta(token, nowMs) {
|
|
25
|
+
const created = `created ${formatRelative(token.createdAt, nowMs)}`;
|
|
26
|
+
const used = token.lastUsedAt ? `last used ${formatRelative(token.lastUsedAt, nowMs)}` : "never used";
|
|
27
|
+
return `${created} · ${used}`;
|
|
28
|
+
}
|
|
21
29
|
function installCommand(tool, hubUrl, token) {
|
|
22
30
|
if (tool === "generic") {
|
|
23
31
|
return JSON.stringify({
|
|
@@ -49,6 +57,8 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
|
|
|
49
57
|
const [error, setError] = useState(null);
|
|
50
58
|
const [status, setStatus] = useState(null);
|
|
51
59
|
const [busy, setBusy] = useState(false);
|
|
60
|
+
// True for a couple seconds after a successful copy, to flash the button label.
|
|
61
|
+
const [copied, setCopied] = useState(false);
|
|
52
62
|
// True until the first loadTokens() resolves, so the management list shows a
|
|
53
63
|
// loading placeholder rather than the "No tokens yet." empty state.
|
|
54
64
|
const [loading, setLoading] = useState(true);
|
|
@@ -112,8 +122,13 @@ export function ConnectAgent({ workspaceId, hubUrl }) {
|
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
const command = installCommand(tool, directHubUrl, rawToken ?? TOKEN_PLACEHOLDER);
|
|
115
|
-
|
|
125
|
+
async function copyCommand() {
|
|
126
|
+
await navigator.clipboard.writeText(command);
|
|
127
|
+
setCopied(true);
|
|
128
|
+
setTimeout(() => setCopied(false), 2000);
|
|
129
|
+
}
|
|
130
|
+
return (_jsxs("section", { className: "shepherd-connect-agent", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Connect your agent" }), _jsx("p", { className: "card-sub", children: "Generate a token, then paste the command into your coding tool." })] }), _jsxs("div", { className: "card-body", children: [_jsxs("div", { className: "field", children: [_jsx("label", { htmlFor: "connect-tool", children: "Tool" }), _jsx("select", { id: "connect-tool", value: tool, onChange: (e) => setTool(e.target.value), children: TOOLS.map((t) => (_jsx("option", { value: t.id, children: t.label }, t.id))) })] }), _jsxs("div", { className: "field generate", children: [_jsx("label", { htmlFor: "token-name", children: "Token name (optional)" }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "token-name", type: "text", value: name, onChange: (e) => setName(e.target.value), placeholder: "e.g. laptop" }), _jsx("button", { type: "button", onClick: () => void generate(), disabled: busy, children: "Generate token" })] })] }), error && _jsx("p", { role: "alert", children: error }), status && _jsx("p", { role: "status", children: status }), rawToken && (_jsx("p", { className: "token-once", role: "status", children: "Copy this token now \u2014 it won't be shown again." })), _jsxs("div", { className: "install-command", children: [_jsx("pre", { "data-testid": "install-command", children: command }), _jsx("button", { type: "button", className: "install-command__copy", onClick: () => void copyCommand(), children: copied ? "Copied" : "Copy" })] }), _jsx("h4", { children: "Existing tokens" }), loading ? (_jsx("p", { role: "status", children: "Loading\u2026" })) : tokens.length === 0 ? (_jsx("p", { children: "No tokens yet." })) : (_jsx("ul", { children: tokens.map((t) => {
|
|
116
131
|
const name = t.name ?? t.id;
|
|
117
|
-
return (_jsxs("li", { children: [_jsx("span", { children: name }), t.revokedAt ? (_jsx("span", { className: "revoked", children: "revoked" })) : (_jsx("button", { type: "button", "aria-label": `Revoke token ${name}`, onClick: () => void revoke(t.id), disabled: revokingId === t.id, children: "Revoke" }))] }, t.id));
|
|
132
|
+
return (_jsxs("li", { children: [_jsx("span", { children: name }), _jsx("span", { className: "token-meta", children: tokenMeta(t, Date.now()) }), t.revokedAt ? (_jsx("span", { className: "revoked", children: "revoked" })) : (_jsx("button", { type: "button", "aria-label": `Revoke token ${name}`, onClick: () => void revoke(t.id), disabled: revokingId === t.id, children: "Revoke" }))] }, t.id));
|
|
118
133
|
}) }))] })] }));
|
|
119
134
|
}
|
|
@@ -8,5 +8,8 @@ export interface WorkspacesProps {
|
|
|
8
8
|
onChanged: () => void;
|
|
9
9
|
/** Switch the active workspace (optional simple switcher). */
|
|
10
10
|
onSelect?: (workspaceId: string) => void;
|
|
11
|
+
/** Called after a mutation that may change the selected workspace's roster
|
|
12
|
+
* (an invite created, a code redeemed), so the sibling <Members> refetches. */
|
|
13
|
+
onMembersChanged?: () => void;
|
|
11
14
|
}
|
|
12
|
-
export declare function Workspaces({ workspaces, selected, onChanged, onSelect }: WorkspacesProps): import("react").JSX.Element;
|
|
15
|
+
export declare function Workspaces({ workspaces, selected, onChanged, onSelect, onMembersChanged, }: WorkspacesProps): import("react").JSX.Element;
|
|
@@ -2,8 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useId, useState } from "react";
|
|
3
3
|
import { useShepherdClient } from "../context.js";
|
|
4
4
|
import { describeError } from "../client.js";
|
|
5
|
-
|
|
6
|
-
export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
5
|
+
export function Workspaces({ workspaces, selected, onChanged, onSelect, onMembersChanged, }) {
|
|
7
6
|
const client = useShepherdClient();
|
|
8
7
|
const headingId = useId();
|
|
9
8
|
const [name, setName] = useState("");
|
|
@@ -11,9 +10,9 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
|
11
10
|
const [invite, setInvite] = useState(null);
|
|
12
11
|
const [error, setError] = useState(null);
|
|
13
12
|
const [busy, setBusy] = useState(false);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const [
|
|
13
|
+
const [emailAddress, setEmailAddress] = useState("");
|
|
14
|
+
const [emailStatus, setEmailStatus] = useState(null);
|
|
15
|
+
const [emailBusy, setEmailBusy] = useState(false);
|
|
17
16
|
const isAdmin = selected?.role === "admin";
|
|
18
17
|
async function create() {
|
|
19
18
|
if (!name.trim() || busy)
|
|
@@ -41,7 +40,7 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
|
41
40
|
await client.redeemInvite(code.trim());
|
|
42
41
|
setCode("");
|
|
43
42
|
onChanged();
|
|
44
|
-
|
|
43
|
+
onMembersChanged?.();
|
|
45
44
|
}
|
|
46
45
|
catch (err) {
|
|
47
46
|
setError(describeError(err));
|
|
@@ -58,7 +57,7 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
|
58
57
|
try {
|
|
59
58
|
const res = await client.createInvite(selected.id, {});
|
|
60
59
|
setInvite(res);
|
|
61
|
-
|
|
60
|
+
onMembersChanged?.();
|
|
62
61
|
}
|
|
63
62
|
catch (err) {
|
|
64
63
|
setError(describeError(err));
|
|
@@ -67,6 +66,24 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
|
67
66
|
setBusy(false);
|
|
68
67
|
}
|
|
69
68
|
}
|
|
69
|
+
async function sendEmailInvite() {
|
|
70
|
+
if (!selected || !emailAddress.trim() || emailBusy)
|
|
71
|
+
return;
|
|
72
|
+
setEmailBusy(true);
|
|
73
|
+
setEmailStatus(null);
|
|
74
|
+
try {
|
|
75
|
+
const res = await client.inviteByEmail(selected.id, emailAddress.trim());
|
|
76
|
+
setEmailStatus(`Invite sent to ${res.email}.`);
|
|
77
|
+
setEmailAddress("");
|
|
78
|
+
onMembersChanged?.();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
setEmailStatus(describeError(err));
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
setEmailBusy(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
70
87
|
async function revokeInvite() {
|
|
71
88
|
if (!selected || !invite || busy)
|
|
72
89
|
return;
|
|
@@ -83,14 +100,17 @@ export function Workspaces({ workspaces, selected, onChanged, onSelect }) {
|
|
|
83
100
|
setBusy(false);
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
|
-
// Clear any displayed invite when the selected workspace changes.
|
|
103
|
+
// Clear any displayed invite/email status when the selected workspace changes.
|
|
87
104
|
useEffect(() => {
|
|
88
105
|
setInvite(null);
|
|
106
|
+
setEmailStatus(null);
|
|
89
107
|
}, [selected?.id]);
|
|
90
108
|
// InviteResponse has no `link`; build the shareable join URL from the code.
|
|
91
109
|
// Encode the code for path-safety (parity with client.ts path encoding).
|
|
92
110
|
const joinLink = invite
|
|
93
111
|
? `${window.location.origin}/join/${encodeURIComponent(invite.code)}`
|
|
94
112
|
: null;
|
|
95
|
-
return (_jsxs("section", { className: "shepherd-workspaces", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Workspace management" }), _jsx("p", { className: "card-sub", children: "Create a workspace to get started, or join an existing one with an invite code." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), workspaces.length > 1 && onSelect && (_jsxs("div", { className: "field switcher", children: [_jsx("label", { htmlFor: "ws-switcher", children: "Active workspace" }), _jsx("select", { id: "ws-switcher", value: selected?.id ?? "", onChange: (e) => onSelect(e.target.value), children: workspaces.map((w) => (_jsx("option", { value: w.id, children: w.name }, w.id))) })] })), _jsxs("div", { className: "field create", children: [_jsx("label", { htmlFor: "ws-name", children: "Workspace name" }), _jsx("p", { className: "helper", children: "Start a fresh workspace \u2014 you'll be its admin." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "ws-name", type: "text", placeholder: "e.g. Acme Engineering", value: name, onChange: (e) => setName(e.target.value) }), _jsx("button", { type: "button", onClick: () => void create(), disabled: busy, children: "Create workspace" })] })] }), _jsxs("div", { className: "field join", children: [_jsx("label", { htmlFor: "invite-code", children: "Invite code" }), _jsx("p", { className: "helper", children: "Paste an invite code someone shared to join their workspace." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-code", type: "text", placeholder: "Invite code", value: code, onChange: (e) => setCode(e.target.value) }), _jsx("button", { type: "button", onClick: () => void join(), disabled: busy, children: "Join" })] })] }), isAdmin && selected && (_jsxs("div", { className: "admin", children: [_jsxs("div", { className: "invites", children: [_jsx("button", { type: "button", onClick: () => void createInvite(), disabled: busy, children: "Create invite" }), invite && (_jsxs("div", { className: "invite-result", children: [
|
|
113
|
+
return (_jsxs("section", { className: "shepherd-workspaces", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "Workspace management" }), _jsx("p", { className: "card-sub", children: "Create a workspace to get started, or join an existing one with an invite code." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), workspaces.length > 1 && onSelect && (_jsxs("div", { className: "field switcher", children: [_jsx("label", { htmlFor: "ws-switcher", children: "Active workspace" }), _jsx("select", { id: "ws-switcher", value: selected?.id ?? "", onChange: (e) => onSelect(e.target.value), children: workspaces.map((w) => (_jsx("option", { value: w.id, children: w.name }, w.id))) })] })), _jsxs("div", { className: "field create", children: [_jsx("label", { htmlFor: "ws-name", children: "Workspace name" }), _jsx("p", { className: "helper", children: "Start a fresh workspace \u2014 you'll be its admin." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "ws-name", type: "text", placeholder: "e.g. Acme Engineering", value: name, onChange: (e) => setName(e.target.value) }), _jsx("button", { type: "button", onClick: () => void create(), disabled: busy, children: "Create workspace" })] })] }), _jsxs("div", { className: "field join", children: [_jsx("label", { htmlFor: "invite-code", children: "Invite code" }), _jsx("p", { className: "helper", children: "Paste an invite code someone shared to join their workspace." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-code", type: "text", placeholder: "Invite code", value: code, onChange: (e) => setCode(e.target.value) }), _jsx("button", { type: "button", onClick: () => void join(), disabled: busy, children: "Join" })] })] }), isAdmin && selected && (_jsxs("div", { className: "admin", children: [_jsxs("div", { className: "invites", children: [_jsx("button", { type: "button", onClick: () => void createInvite(), disabled: busy, children: "Create invite" }), invite && (_jsxs("div", { className: "invite-result", children: [_jsxs("div", { className: "invite-result__row", children: [_jsx("code", { children: invite.code }), _jsx("span", { className: "invite-uses", children: invite.maxUses === null
|
|
114
|
+
? `${invite.useCount} uses`
|
|
115
|
+
: `${invite.useCount} / ${invite.maxUses} uses` }), _jsx("button", { type: "button", onClick: () => void revokeInvite(), disabled: busy, children: "Revoke invite" })] }), _jsx("a", { href: joinLink ?? undefined, children: joinLink })] }))] }), _jsxs("div", { className: "field email-invite", children: [_jsx("label", { htmlFor: "invite-email", children: "Email invite" }), _jsx("p", { className: "helper", children: "Send a one-time-use join link directly to someone's inbox." }), _jsxs("div", { className: "field__row", children: [_jsx("input", { id: "invite-email", type: "email", placeholder: "name@example.com", value: emailAddress, onChange: (e) => setEmailAddress(e.target.value) }), _jsx("button", { type: "button", onClick: () => void sendEmailInvite(), disabled: emailBusy, children: "Send invite" })] }), emailStatus && _jsx("p", { className: "email-invite__status", children: emailStatus })] })] }))] })] }));
|
|
96
116
|
}
|