@forwardreach/saas-mcp 0.1.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/README.md +7 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/management.d.ts +80 -0
- package/dist/client/management.js +78 -0
- package/dist/core/http.d.ts +27 -0
- package/dist/core/http.js +29 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/redaction.d.ts +11 -0
- package/dist/core/redaction.js +43 -0
- package/dist/core/scopes.d.ts +13 -0
- package/dist/core/scopes.js +35 -0
- package/dist/core/types.d.ts +256 -0
- package/dist/core/types.js +1 -0
- package/dist/core/urls.d.ts +69 -0
- package/dist/core/urls.js +85 -0
- package/dist/hono/index.d.ts +1 -0
- package/dist/hono/index.js +1 -0
- package/dist/hono/routes.d.ts +212 -0
- package/dist/hono/routes.js +535 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/better-auth.d.ts +84 -0
- package/dist/node/better-auth.js +77 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +2 -0
- package/dist/node/oauth.d.ts +18 -0
- package/dist/node/oauth.js +39 -0
- package/dist/react/McpActivity.d.ts +32 -0
- package/dist/react/McpActivity.js +182 -0
- package/dist/react/McpConnectedClients.d.ts +27 -0
- package/dist/react/McpConnectedClients.js +34 -0
- package/dist/react/McpConnectorOverview.d.ts +21 -0
- package/dist/react/McpConnectorOverview.js +12 -0
- package/dist/react/McpConsentPanel.d.ts +47 -0
- package/dist/react/McpConsentPanel.js +18 -0
- package/dist/react/McpDeveloperTokens.d.ts +27 -0
- package/dist/react/McpDeveloperTokens.js +46 -0
- package/dist/react/McpToolReference.d.ts +17 -0
- package/dist/react/McpToolReference.js +19 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +6 -0
- package/dist/react/mcp-settings-shared.d.ts +29 -0
- package/dist/react/mcp-settings-shared.js +37 -0
- package/package.json +98 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function dateMs(value) {
|
|
2
|
+
if (!value)
|
|
3
|
+
return null;
|
|
4
|
+
const parsed = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
5
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
6
|
+
}
|
|
7
|
+
/** Return whether a BetterAuth session satisfies a fresh-auth age window. */
|
|
8
|
+
export function isFreshBetterAuthSession(session, maxAgeMs = 15 * 60 * 1000, now = Date.now()) {
|
|
9
|
+
if (!session)
|
|
10
|
+
return false;
|
|
11
|
+
if (session.fresh === true)
|
|
12
|
+
return true;
|
|
13
|
+
const updatedAt = dateMs(session.updatedAt) ?? dateMs(session.createdAt);
|
|
14
|
+
return updatedAt !== null && now - updatedAt <= maxAgeMs;
|
|
15
|
+
}
|
|
16
|
+
/** Normalize BetterAuth session data into the shared MCP user shape. */
|
|
17
|
+
export function normalizeBetterAuthSessionIdentity(input) {
|
|
18
|
+
const { user, session = null } = input.result;
|
|
19
|
+
return {
|
|
20
|
+
user: {
|
|
21
|
+
id: user.id,
|
|
22
|
+
authProviderKey: input.providerKey ?? "better-auth",
|
|
23
|
+
authIssuer: input.issuer ?? null,
|
|
24
|
+
authSubject: user.id,
|
|
25
|
+
email: user.email ?? null,
|
|
26
|
+
emailVerified: user.emailVerified ?? null,
|
|
27
|
+
displayName: user.displayName ?? user.name ?? null,
|
|
28
|
+
imageUrl: user.image ?? null
|
|
29
|
+
},
|
|
30
|
+
sessionFresh: isFreshBetterAuthSession(session, input.freshSessionMaxAgeMs),
|
|
31
|
+
rawSession: session
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Normalize verified JWT claims into the shared MCP user shape. */
|
|
35
|
+
export function normalizeBetterAuthJwtIdentity(input) {
|
|
36
|
+
return {
|
|
37
|
+
user: {
|
|
38
|
+
id: input.claims.sub ?? "",
|
|
39
|
+
authProviderKey: input.providerKey ?? "better-auth-jwt",
|
|
40
|
+
authIssuer: input.claims.iss ?? input.issuer ?? null,
|
|
41
|
+
authSubject: input.claims.sub ?? null,
|
|
42
|
+
email: input.claims.email ?? null,
|
|
43
|
+
emailVerified: input.claims.email_verified ?? null,
|
|
44
|
+
displayName: input.claims.name ?? null,
|
|
45
|
+
imageUrl: input.claims.picture ?? null
|
|
46
|
+
},
|
|
47
|
+
sessionFresh: false,
|
|
48
|
+
rawClaims: input.claims
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Resolve human identity from a BetterAuth session first, then optional JWT. */
|
|
52
|
+
export async function resolveBetterAuthMcpIdentity(headers, adapter) {
|
|
53
|
+
const session = adapter.resolveSession ? await adapter.resolveSession(headers) : null;
|
|
54
|
+
if (session) {
|
|
55
|
+
return normalizeBetterAuthSessionIdentity({
|
|
56
|
+
result: session,
|
|
57
|
+
providerKey: adapter.providerKey,
|
|
58
|
+
issuer: adapter.issuer,
|
|
59
|
+
freshSessionMaxAgeMs: adapter.freshSessionMaxAgeMs
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const authorization = headers.get("authorization");
|
|
63
|
+
const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
64
|
+
if (!bearer || !adapter.verifyJwt)
|
|
65
|
+
return null;
|
|
66
|
+
const claims = await adapter.verifyJwt(bearer);
|
|
67
|
+
return claims ? normalizeBetterAuthJwtIdentity({ claims, providerKey: adapter.providerKey, issuer: adapter.issuer }) : null;
|
|
68
|
+
}
|
|
69
|
+
/** Combine normalized identity with product workspace membership context. */
|
|
70
|
+
export function createMcpHumanIdentityContext(input) {
|
|
71
|
+
return {
|
|
72
|
+
user: input.identity.user,
|
|
73
|
+
workspace: input.workspace,
|
|
74
|
+
membership: input.membership,
|
|
75
|
+
sessionFresh: input.identity.sessionFresh
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata } from "../core/urls.js";
|
|
2
|
+
export { buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata };
|
|
3
|
+
/** Encode bytes for OAuth/PKCE token material without padding. */
|
|
4
|
+
export declare function base64Url(value: Buffer): string;
|
|
5
|
+
/** Generate a random MCP token with a product-provided prefix. */
|
|
6
|
+
export declare function randomMcpToken(prefix: string, byteLength?: number): string;
|
|
7
|
+
/** Hash raw OAuth/developer tokens before persistence. */
|
|
8
|
+
export declare function hashMcpToken(token: string): string;
|
|
9
|
+
/** Compute an OAuth PKCE S256 code challenge. */
|
|
10
|
+
export declare function pkceS256Challenge(verifier: string): string;
|
|
11
|
+
/** Timing-safe verification of an OAuth PKCE S256 verifier/challenge pair. */
|
|
12
|
+
export declare function verifyPkceS256(verifier: string, challenge: string): boolean;
|
|
13
|
+
/** Add seconds to a Date and return an ISO timestamp. */
|
|
14
|
+
export declare function addSeconds(date: Date, seconds: number): string;
|
|
15
|
+
/** Parse a bearer token from a Request authorization header. */
|
|
16
|
+
export declare function parseBearerToken(request: Request): string | null;
|
|
17
|
+
/** Heuristic used to reject app-session tokens on MCP bearer endpoints. */
|
|
18
|
+
export declare function looksLikeBetterAuthAppToken(token: string): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata } from "../core/urls.js";
|
|
3
|
+
export { buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata };
|
|
4
|
+
/** Encode bytes for OAuth/PKCE token material without padding. */
|
|
5
|
+
export function base64Url(value) {
|
|
6
|
+
return value.toString("base64url");
|
|
7
|
+
}
|
|
8
|
+
/** Generate a random MCP token with a product-provided prefix. */
|
|
9
|
+
export function randomMcpToken(prefix, byteLength = 32) {
|
|
10
|
+
return `${prefix}_${base64Url(randomBytes(byteLength))}`;
|
|
11
|
+
}
|
|
12
|
+
/** Hash raw OAuth/developer tokens before persistence. */
|
|
13
|
+
export function hashMcpToken(token) {
|
|
14
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
15
|
+
}
|
|
16
|
+
/** Compute an OAuth PKCE S256 code challenge. */
|
|
17
|
+
export function pkceS256Challenge(verifier) {
|
|
18
|
+
return base64Url(createHash("sha256").update(verifier).digest());
|
|
19
|
+
}
|
|
20
|
+
/** Timing-safe verification of an OAuth PKCE S256 verifier/challenge pair. */
|
|
21
|
+
export function verifyPkceS256(verifier, challenge) {
|
|
22
|
+
const computed = Buffer.from(pkceS256Challenge(verifier));
|
|
23
|
+
const expected = Buffer.from(challenge);
|
|
24
|
+
return computed.length === expected.length && timingSafeEqual(computed, expected);
|
|
25
|
+
}
|
|
26
|
+
/** Add seconds to a Date and return an ISO timestamp. */
|
|
27
|
+
export function addSeconds(date, seconds) {
|
|
28
|
+
return new Date(date.getTime() + seconds * 1000).toISOString();
|
|
29
|
+
}
|
|
30
|
+
/** Parse a bearer token from a Request authorization header. */
|
|
31
|
+
export function parseBearerToken(request) {
|
|
32
|
+
const authorization = request.headers.get("authorization");
|
|
33
|
+
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
|
34
|
+
return match?.[1]?.trim() ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** Heuristic used to reject app-session tokens on MCP bearer endpoints. */
|
|
37
|
+
export function looksLikeBetterAuthAppToken(token) {
|
|
38
|
+
return token.startsWith("better-auth.") || token.startsWith("ba_") || token.split(".").length === 3;
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { McpManagementClient } from "../client/management.js";
|
|
3
|
+
import type { McpAuditEvent, McpConnectionSummary, McpDeveloperTokenSummary, McpScopeValue } from "../core/types.js";
|
|
4
|
+
/** Props for the MCP activity section. */
|
|
5
|
+
export interface McpActivityProps<S extends McpScopeValue = McpScopeValue> {
|
|
6
|
+
/** Workspace whose MCP activity is reviewed. */
|
|
7
|
+
workspace: {
|
|
8
|
+
id: string;
|
|
9
|
+
displayName?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Current signed-in user. */
|
|
12
|
+
user: {
|
|
13
|
+
id: string;
|
|
14
|
+
};
|
|
15
|
+
/** Server-provided initial audit events for the workspace. */
|
|
16
|
+
initialAuditEvents: McpAuditEvent[];
|
|
17
|
+
/** Connections used to resolve client names and populate the source filter (optional). */
|
|
18
|
+
connections?: McpConnectionSummary<S>[];
|
|
19
|
+
/** Developer/access tokens used to resolve token names and populate the source filter (optional). */
|
|
20
|
+
developerTokens?: McpDeveloperTokenSummary<S>[];
|
|
21
|
+
/** Optional management client; defaults to `createMcpManagementClient`. */
|
|
22
|
+
managementClient?: McpManagementClient<S>;
|
|
23
|
+
/** Optional product renderer for links to affected business records. */
|
|
24
|
+
renderRecordLink?: (recordId: string) => React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* MCP activity: a filtered slice of workspace activity (source = MCP). This is a
|
|
28
|
+
* thin MCP adapter over the presentational `AuditLog` from `@forwardreach/saas-ui`:
|
|
29
|
+
* it owns MCP-specific data fetching and filters, resolves client/token/user ids
|
|
30
|
+
* to display labels, and maps `McpAuditEvent`s onto generic `AuditLogEntry`s.
|
|
31
|
+
*/
|
|
32
|
+
export declare function McpActivity<S extends McpScopeValue = McpScopeValue>({ workspace, user, initialAuditEvents, connections, developerTokens, managementClient, renderRecordLink, }: McpActivityProps<S>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Activity, ExternalLink } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { AuditLog } from "@forwardreach/saas-ui";
|
|
6
|
+
import { formatDate, useMcpManagementClient } from "./mcp-settings-shared.js";
|
|
7
|
+
const outcomeOptions = ["created", "updated", "matched", "rejected", "revoked", "error", "partial_success"];
|
|
8
|
+
const activityOptions = ["read", "write", "management"];
|
|
9
|
+
const sourceLabels = { oauth: "OAuth", personal_access_token: "Access token" };
|
|
10
|
+
/**
|
|
11
|
+
* MCP activity: a filtered slice of workspace activity (source = MCP). This is a
|
|
12
|
+
* thin MCP adapter over the presentational `AuditLog` from `@forwardreach/saas-ui`:
|
|
13
|
+
* it owns MCP-specific data fetching and filters, resolves client/token/user ids
|
|
14
|
+
* to display labels, and maps `McpAuditEvent`s onto generic `AuditLogEntry`s.
|
|
15
|
+
*/
|
|
16
|
+
export function McpActivity({ workspace, user, initialAuditEvents, connections = [], developerTokens = [], managementClient, renderRecordLink, }) {
|
|
17
|
+
const client = useMcpManagementClient(workspace.id, managementClient);
|
|
18
|
+
const [auditEvents, setAuditEvents] = React.useState(initialAuditEvents);
|
|
19
|
+
const [sourceFilter, setSourceFilter] = React.useState("all");
|
|
20
|
+
const [clientFilter, setClientFilter] = React.useState("all");
|
|
21
|
+
const [toolFilter, setToolFilter] = React.useState("all");
|
|
22
|
+
const [outcomeFilter, setOutcomeFilter] = React.useState("all");
|
|
23
|
+
const [activityFilter, setActivityFilter] = React.useState("all");
|
|
24
|
+
const [auditLoading, setAuditLoading] = React.useState(false);
|
|
25
|
+
const [auditError, setAuditError] = React.useState(null);
|
|
26
|
+
const isFirstAuditRun = React.useRef(true);
|
|
27
|
+
const sourceOptions = React.useMemo(() => {
|
|
28
|
+
const oauth = connections.map((connection) => ({ id: connection.clientId, label: connection.clientName, filterKey: "clientId" }));
|
|
29
|
+
const tokens = developerTokens.map((token) => ({ id: token.tokenId, label: token.name, filterKey: "accessTokenId" }));
|
|
30
|
+
return [...oauth, ...tokens];
|
|
31
|
+
}, [connections, developerTokens]);
|
|
32
|
+
// id -> display label maps for resolving raw ids to human-readable names (RF3).
|
|
33
|
+
const clientLabels = React.useMemo(() => new Map(connections.map((connection) => [connection.clientId, connection.clientName])), [connections]);
|
|
34
|
+
const tokenLabels = React.useMemo(() => new Map(developerTokens.map((token) => [token.tokenId, token.name])), [developerTokens]);
|
|
35
|
+
// Resolve users from both OAuth connections and access tokens so access-token
|
|
36
|
+
// rows show the user's email even when they have no OAuth connection (RF3).
|
|
37
|
+
const userLabels = React.useMemo(() => {
|
|
38
|
+
const labels = new Map();
|
|
39
|
+
for (const token of developerTokens)
|
|
40
|
+
if (token.userEmail)
|
|
41
|
+
labels.set(token.userId, token.userEmail);
|
|
42
|
+
for (const connection of connections)
|
|
43
|
+
if (connection.userEmail)
|
|
44
|
+
labels.set(connection.userId, connection.userEmail);
|
|
45
|
+
return labels;
|
|
46
|
+
}, [connections, developerTokens]);
|
|
47
|
+
const auditFilters = React.useMemo(() => {
|
|
48
|
+
const filters = {};
|
|
49
|
+
const selectedSource = sourceOptions.find((option) => option.id === clientFilter);
|
|
50
|
+
if (sourceFilter !== "all")
|
|
51
|
+
filters.sourceKind = sourceFilter;
|
|
52
|
+
if (clientFilter !== "all") {
|
|
53
|
+
if (selectedSource?.filterKey === "accessTokenId")
|
|
54
|
+
filters.accessTokenId = clientFilter;
|
|
55
|
+
else
|
|
56
|
+
filters.clientId = clientFilter;
|
|
57
|
+
}
|
|
58
|
+
if (toolFilter !== "all")
|
|
59
|
+
filters.toolName = toolFilter;
|
|
60
|
+
if (outcomeFilter !== "all")
|
|
61
|
+
filters.outcome = outcomeFilter;
|
|
62
|
+
if (activityFilter !== "all")
|
|
63
|
+
filters.activityKind = activityFilter;
|
|
64
|
+
return filters;
|
|
65
|
+
}, [activityFilter, clientFilter, outcomeFilter, sourceFilter, sourceOptions, toolFilter]);
|
|
66
|
+
const filteredEvents = auditEvents.filter((event) => {
|
|
67
|
+
if (sourceFilter !== "all" && event.sourceKind !== sourceFilter)
|
|
68
|
+
return false;
|
|
69
|
+
if (clientFilter !== "all" && event.clientId !== clientFilter && event.accessTokenId !== clientFilter)
|
|
70
|
+
return false;
|
|
71
|
+
if (toolFilter !== "all" && event.toolName !== toolFilter)
|
|
72
|
+
return false;
|
|
73
|
+
if (outcomeFilter !== "all" && event.outcome !== outcomeFilter)
|
|
74
|
+
return false;
|
|
75
|
+
if (activityFilter !== "all" && event.activityKind !== activityFilter)
|
|
76
|
+
return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
// Key the effect on the filter *values*, not the object identity. `auditFilters`
|
|
80
|
+
// gets a fresh identity every render (its inputs, e.g. default arrays, are not
|
|
81
|
+
// referentially stable), so depending on it directly re-runs the effect each
|
|
82
|
+
// render — the cleanup then cancels the in-flight fetch before it resolves and
|
|
83
|
+
// the loading state never clears. A stable string key re-runs the effect only
|
|
84
|
+
// when a filter actually changes.
|
|
85
|
+
const auditFilterKey = JSON.stringify(auditFilters);
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
// Don't fetch on mount; server-provided initial events are already shown.
|
|
88
|
+
if (isFirstAuditRun.current) {
|
|
89
|
+
isFirstAuditRun.current = false;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
setAuditLoading(true);
|
|
94
|
+
setAuditError(null);
|
|
95
|
+
void client
|
|
96
|
+
.listAuditEvents(auditFilters)
|
|
97
|
+
.then((response) => {
|
|
98
|
+
if (!cancelled)
|
|
99
|
+
setAuditEvents(response.events);
|
|
100
|
+
})
|
|
101
|
+
.catch((error) => {
|
|
102
|
+
if (!cancelled)
|
|
103
|
+
setAuditError(error instanceof Error ? error.message : "Audit events could not be loaded.");
|
|
104
|
+
})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
if (!cancelled)
|
|
107
|
+
setAuditLoading(false);
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
cancelled = true;
|
|
111
|
+
};
|
|
112
|
+
// `auditFilters` is intentionally read via the stable `auditFilterKey`.
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
}, [auditFilterKey, client]);
|
|
115
|
+
/** Resolve the acting client/token id to a display name (falls back to the raw id). */
|
|
116
|
+
function actorLabelFor(event) {
|
|
117
|
+
if (event.accessTokenId && tokenLabels.has(event.accessTokenId))
|
|
118
|
+
return tokenLabels.get(event.accessTokenId);
|
|
119
|
+
return clientLabels.get(event.clientId) ?? event.clientId;
|
|
120
|
+
}
|
|
121
|
+
const entries = React.useMemo(() => filteredEvents.map((event) => ({
|
|
122
|
+
id: event.id,
|
|
123
|
+
timestamp: event.createdAt,
|
|
124
|
+
title: event.toolName,
|
|
125
|
+
actorLabel: actorLabelFor(event),
|
|
126
|
+
badges: [sourceLabels[event.sourceKind] ?? event.sourceKind, event.activityKind],
|
|
127
|
+
outcome: event.outcome,
|
|
128
|
+
targetIds: event.targetRecordIds,
|
|
129
|
+
details: [
|
|
130
|
+
{ label: "User", value: userLabels.get(event.userId) ?? event.userId },
|
|
131
|
+
{ label: "Client", value: actorLabelFor(event) },
|
|
132
|
+
{ label: "Workspace", value: event.workspaceId },
|
|
133
|
+
],
|
|
134
|
+
metadata: event.metadata,
|
|
135
|
+
})),
|
|
136
|
+
// actorLabelFor depends on the label maps captured above.
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
+
[filteredEvents, clientLabels, tokenLabels, userLabels]);
|
|
139
|
+
const filters = [
|
|
140
|
+
{
|
|
141
|
+
id: "source",
|
|
142
|
+
ariaLabel: "Source",
|
|
143
|
+
value: sourceFilter,
|
|
144
|
+
onChange: (value) => setSourceFilter(value),
|
|
145
|
+
options: [
|
|
146
|
+
{ value: "all", label: "All sources" },
|
|
147
|
+
{ value: "oauth", label: "OAuth" },
|
|
148
|
+
{ value: "personal_access_token", label: "Access token" },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "client",
|
|
153
|
+
ariaLabel: "Client",
|
|
154
|
+
value: clientFilter,
|
|
155
|
+
onChange: setClientFilter,
|
|
156
|
+
options: [{ value: "all", label: "All clients" }, ...sourceOptions.map((option) => ({ value: option.id, label: option.label }))],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "tool",
|
|
160
|
+
ariaLabel: "Tool",
|
|
161
|
+
value: toolFilter,
|
|
162
|
+
onChange: setToolFilter,
|
|
163
|
+
options: [{ value: "all", label: "All tools" }, ...[...new Set(auditEvents.map((event) => event.toolName))].map((toolName) => ({ value: toolName, label: toolName }))],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "outcome",
|
|
167
|
+
ariaLabel: "Outcome",
|
|
168
|
+
value: outcomeFilter,
|
|
169
|
+
onChange: (value) => setOutcomeFilter(value),
|
|
170
|
+
options: [{ value: "all", label: "All outcomes" }, ...outcomeOptions.map((outcome) => ({ value: outcome, label: outcome }))],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "activity",
|
|
174
|
+
ariaLabel: "Activity",
|
|
175
|
+
value: activityFilter,
|
|
176
|
+
onChange: (value) => setActivityFilter(value),
|
|
177
|
+
options: [{ value: "all", label: "All activity" }, ...activityOptions.map((activity) => ({ value: activity, label: activity }))],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
return (_jsx(AuditLog, { description: "A filtered slice of workspace activity (source = MCP): externally authorized MCP tool calls.", emptyState: "No external MCP activity has been recorded.", entries: entries, error: auditError, filters: filters, formatTimestamp: formatDate, icon: _jsx(Activity, {}), loading: auditLoading, renderTargetLink: renderRecordLink ??
|
|
181
|
+
(() => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-md bg-[color:var(--ssui-surface-muted)] px-1.5 py-0.5 text-xs text-[color:var(--ssui-text-muted)]", children: ["Record ", _jsx(ExternalLink, { className: "h-3 w-3" })] }))), title: "MCP activity" }));
|
|
182
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { McpManagementClient } from "../client/management.js";
|
|
2
|
+
import type { McpConnectionSummary, McpScopeValue, McpToolDescriptor } from "../core/types.js";
|
|
3
|
+
/** Props for the connected-clients section. */
|
|
4
|
+
export interface McpConnectedClientsProps<S extends McpScopeValue = McpScopeValue> {
|
|
5
|
+
/** Workspace whose connections are listed. */
|
|
6
|
+
workspace: {
|
|
7
|
+
id: string;
|
|
8
|
+
displayName?: string;
|
|
9
|
+
};
|
|
10
|
+
/** Current signed-in user managing MCP access. */
|
|
11
|
+
user: {
|
|
12
|
+
id: string;
|
|
13
|
+
};
|
|
14
|
+
/** Product-owned MCP tool descriptors used to show granted tools per connection. */
|
|
15
|
+
tools: Array<McpToolDescriptor<S> & {
|
|
16
|
+
activityKind?: "read" | "write";
|
|
17
|
+
}>;
|
|
18
|
+
/** Server-provided initial OAuth connections for the workspace. */
|
|
19
|
+
initialConnections: McpConnectionSummary<S>[];
|
|
20
|
+
/** Optional management client; defaults to `createMcpManagementClient`. */
|
|
21
|
+
managementClient?: McpManagementClient<S>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Connected clients: lists active and revoked MCP OAuth connections with an
|
|
25
|
+
* expandable detail view and per-connection revoke.
|
|
26
|
+
*/
|
|
27
|
+
export declare function McpConnectedClients<S extends McpScopeValue = McpScopeValue>({ workspace, user, tools, initialConnections, managementClient, }: McpConnectedClientsProps<S>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ShieldCheck, Trash2 } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { Button, SettingsSection } from "@forwardreach/saas-ui";
|
|
6
|
+
import { hasRequiredMcpScopes } from "../core/scopes.js";
|
|
7
|
+
import { cx, formatDate, ScopePills, statusLabel, useMcpManagementClient } from "./mcp-settings-shared.js";
|
|
8
|
+
/**
|
|
9
|
+
* Connected clients: lists active and revoked MCP OAuth connections with an
|
|
10
|
+
* expandable detail view and per-connection revoke.
|
|
11
|
+
*/
|
|
12
|
+
export function McpConnectedClients({ workspace, user, tools, initialConnections, managementClient, }) {
|
|
13
|
+
const client = useMcpManagementClient(workspace.id, managementClient);
|
|
14
|
+
const [connections, setConnections] = React.useState(initialConnections);
|
|
15
|
+
const [pendingId, setPendingId] = React.useState(null);
|
|
16
|
+
async function revokeConnection(grantId) {
|
|
17
|
+
setPendingId(grantId);
|
|
18
|
+
try {
|
|
19
|
+
const response = await client.revokeConnection(grantId, { userId: user.id });
|
|
20
|
+
setConnections((current) => current.map((connection) => (connection.grantId === grantId ? response.connection : connection)));
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
setPendingId(null);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return (_jsx(SettingsSection, { action: _jsxs("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: [connections.length, " grants"] }), description: "AI clients connected to this workspace through interactive OAuth.", icon: _jsx(ShieldCheck, {}), title: "Connected clients", children: _jsxs("div", { className: "divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [connections.length === 0 && (_jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: "No MCP clients are connected." })), connections.map((connection) => {
|
|
27
|
+
const status = statusLabel({ revokedAt: connection.revokedAt });
|
|
28
|
+
const grantedTools = tools.filter((tool) => hasRequiredMcpScopes(connection.scopes, tool.requiredScopes));
|
|
29
|
+
return (_jsxs("details", { className: "group", children: [_jsxs("summary", { className: "flex cursor-pointer list-none items-start justify-between gap-3 px-3 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "font-medium", children: connection.clientName }), _jsxs("span", { className: cx("inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs", status.className), children: [_jsx(status.Icon, { className: "h-3 w-3" }), status.label] })] }), _jsx("div", { className: "mt-1 break-all text-xs text-[color:var(--ssui-text-muted)]", children: connection.redirectOrigin ?? "Unknown origin" }), _jsx("div", { className: "mt-2", children: _jsx(ScopePills, { scopes: connection.scopes }) })] }), _jsxs(Button, { disabled: Boolean(connection.revokedAt) || pendingId === connection.grantId, onClick: (event) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
void revokeConnection(connection.grantId);
|
|
32
|
+
}, title: "Revoke connection", type: "button", variant: "ghost", children: [_jsx(Trash2, { className: "h-4 w-4" }), "Revoke"] })] }), _jsxs("div", { className: "grid gap-3 border-t border-[color:var(--ssui-border)] px-3 py-3 text-sm md:grid-cols-2", children: [_jsxs("div", { className: "space-y-1 text-xs text-[color:var(--ssui-text-muted)]", children: [_jsxs("div", { children: ["Created: ", formatDate(connection.createdAt)] }), _jsxs("div", { children: ["Last used: ", formatDate(connection.lastUsedAt)] }), _jsxs("div", { children: ["Expires: ", formatDate(connection.expiresAt)] }), _jsxs("div", { children: ["Active sessions: ", connection.activeSessionCount] }), _jsxs("div", { className: "break-all", children: ["Resource: ", connection.resource] })] }), _jsxs("div", { children: [_jsx("div", { className: "mb-2 text-xs font-medium uppercase text-[color:var(--ssui-text-muted)]", children: "Granted tools" }), grantedTools.length === 0 ? (_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: "No tools available for these scopes." })) : (_jsx("div", { className: "mb-4 space-y-1 text-xs text-[color:var(--ssui-text-muted)]", children: grantedTools.map((tool) => (_jsxs("div", { className: "flex justify-between gap-2", children: [_jsx("span", { children: tool.title }), _jsx("span", { className: "font-mono", children: tool.name })] }, tool.name))) })), _jsx("div", { className: "mb-2 text-xs font-medium uppercase text-[color:var(--ssui-text-muted)]", children: "Recent activity" }), connection.recentAuditEvents.length === 0 ? (_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: "No activity recorded for this connection." })) : (_jsx("div", { className: "space-y-1 text-xs text-[color:var(--ssui-text-muted)]", children: connection.recentAuditEvents.map((event) => (_jsxs("div", { className: "flex justify-between gap-2", children: [_jsx("span", { children: event.toolName }), _jsx("span", { children: event.outcome })] }, event.id))) }))] })] })] }, connection.grantId));
|
|
33
|
+
})] }) }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** Props for the connector-overview section. */
|
|
3
|
+
export interface McpConnectorOverviewProps {
|
|
4
|
+
/** Canonical MCP connector URL to copy into remote clients. */
|
|
5
|
+
connectorUrl: string;
|
|
6
|
+
/** Workspace whose connector is being configured. */
|
|
7
|
+
workspaceName: string;
|
|
8
|
+
/** Whether the connector URL is suitable for remote HTTPS clients. */
|
|
9
|
+
publicHttpsReady: boolean;
|
|
10
|
+
/** Public application base URL used for HTTPS readiness messaging. */
|
|
11
|
+
appBaseUrl: string;
|
|
12
|
+
/** Optional product-specific HTTPS warning copy. */
|
|
13
|
+
httpsWarningText?: string;
|
|
14
|
+
/** Optional description override for the section body. */
|
|
15
|
+
description?: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Connector overview: the copyable connector URL plus the public-HTTPS warning
|
|
19
|
+
* shown when the deployment base URL is not a public HTTPS origin.
|
|
20
|
+
*/
|
|
21
|
+
export declare function McpConnectorOverview({ connectorUrl, workspaceName, publicHttpsReady, appBaseUrl, httpsWarningText, description, }: McpConnectorOverviewProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { PlugZap } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { CopyField, InlineNotice, SettingsSection } from "@forwardreach/saas-ui";
|
|
6
|
+
/**
|
|
7
|
+
* Connector overview: the copyable connector URL plus the public-HTTPS warning
|
|
8
|
+
* shown when the deployment base URL is not a public HTTPS origin.
|
|
9
|
+
*/
|
|
10
|
+
export function McpConnectorOverview({ connectorUrl, workspaceName, publicHttpsReady, appBaseUrl, httpsWarningText, description, }) {
|
|
11
|
+
return (_jsxs("div", { className: "space-y-4", children: [!publicHttpsReady && (_jsxs(InlineNotice, { title: httpsWarningText ?? "Remote AI coworkers need a public HTTPS URL.", variant: "warning", children: ["Current base URL: ", appBaseUrl] })), _jsx(SettingsSection, { description: description ?? `Add this URL as a remote MCP connector in your AI client to connect ${workspaceName}.`, icon: _jsx(PlugZap, {}), title: "Connector URL", children: _jsx(CopyField, { label: "Connector URL", value: connectorUrl }) })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { McpScopeDefinition, McpScopeValue, McpToolDescriptor } from "../core/types.js";
|
|
2
|
+
/** Props for the shared MCP OAuth consent review surface. */
|
|
3
|
+
export interface McpConsentPanelProps<S extends McpScopeValue = McpScopeValue> {
|
|
4
|
+
/** Registered OAuth client requesting MCP access. */
|
|
5
|
+
client: {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
displayName?: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** Origin derived from the selected redirect URI. */
|
|
11
|
+
redirectOrigin: string;
|
|
12
|
+
/** Workspace the grant will be bound to. */
|
|
13
|
+
workspace: {
|
|
14
|
+
id: string;
|
|
15
|
+
displayName: string;
|
|
16
|
+
};
|
|
17
|
+
/** Signed-in user approving or canceling the grant. */
|
|
18
|
+
user: {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string | null;
|
|
21
|
+
displayName?: string | null;
|
|
22
|
+
};
|
|
23
|
+
/** Requested scopes after product validation/normalization. */
|
|
24
|
+
requestedScopes: readonly S[];
|
|
25
|
+
/** Product scope labels and descriptions keyed by scope value. */
|
|
26
|
+
scopeDefinitions: Record<S, Omit<McpScopeDefinition<S>, "scope">>;
|
|
27
|
+
/** Product tools implied by the requested scopes. */
|
|
28
|
+
allowedTools: Array<McpToolDescriptor<S>>;
|
|
29
|
+
/** MCP resource URI the resulting tokens will target. */
|
|
30
|
+
resource: string;
|
|
31
|
+
/** Product route that accepts the POSTed approve/cancel decision. */
|
|
32
|
+
formAction: string;
|
|
33
|
+
/** OAuth request fields that must be posted back with the decision. */
|
|
34
|
+
hiddenFields: Record<string, string>;
|
|
35
|
+
/** Optional label for the approve submit button. */
|
|
36
|
+
approveLabel?: string;
|
|
37
|
+
/** Optional label for the cancel submit button. */
|
|
38
|
+
cancelLabel?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Shared consent panel for first-party MCP OAuth authorization.
|
|
42
|
+
*
|
|
43
|
+
* The component displays the client, workspace, requested scopes, allowed tools,
|
|
44
|
+
* and resource URI, then posts an explicit approve/cancel decision to the
|
|
45
|
+
* product-owned authorization endpoint.
|
|
46
|
+
*/
|
|
47
|
+
export declare function McpConsentPanel<S extends McpScopeValue = McpScopeValue>({ client, redirectOrigin, workspace, user, requestedScopes, scopeDefinitions, allowedTools, resource, formAction, hiddenFields, approveLabel, cancelLabel }: McpConsentPanelProps<S>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { ShieldCheck, XCircle } from "lucide-react";
|
|
4
|
+
import { Button } from "@forwardreach/saas-ui";
|
|
5
|
+
/**
|
|
6
|
+
* Shared consent panel for first-party MCP OAuth authorization.
|
|
7
|
+
*
|
|
8
|
+
* The component displays the client, workspace, requested scopes, allowed tools,
|
|
9
|
+
* and resource URI, then posts an explicit approve/cancel decision to the
|
|
10
|
+
* product-owned authorization endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export function McpConsentPanel({ client, redirectOrigin, workspace, user, requestedScopes, scopeDefinitions, allowedTools, resource, formAction, hiddenFields, approveLabel = "Approve", cancelLabel = "Cancel" }) {
|
|
13
|
+
const clientName = client.displayName || client.name;
|
|
14
|
+
return (_jsxs("section", { className: "space-y-5", children: [_jsxs("header", { className: "flex items-start gap-3", children: [_jsx("div", { className: "grid h-10 w-10 shrink-0 place-items-center rounded-md border border-zinc-200 bg-zinc-50", children: _jsx(ShieldCheck, { className: "h-5 w-5" }) }), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-semibold tracking-normal md:text-2xl", children: ["Connect ", clientName] }), _jsxs("p", { className: "text-sm text-zinc-600", children: ["Authorize this MCP client for ", workspace.displayName, "."] })] })] }), _jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [_jsxs("div", { className: "rounded-md border border-zinc-200 bg-white p-4", children: [_jsx("div", { className: "text-xs font-medium uppercase text-zinc-500", children: "Client" }), _jsx("div", { className: "mt-1 text-sm font-medium", children: clientName }), _jsx("div", { className: "mt-1 break-all text-xs text-zinc-600", children: redirectOrigin })] }), _jsxs("div", { className: "rounded-md border border-zinc-200 bg-white p-4", children: [_jsx("div", { className: "text-xs font-medium uppercase text-zinc-500", children: "Workspace" }), _jsx("div", { className: "mt-1 text-sm font-medium", children: workspace.displayName }), _jsx("div", { className: "mt-1 text-xs text-zinc-600", children: user.email })] })] }), _jsxs("section", { className: "rounded-md border border-zinc-200 bg-white p-4", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Requested Access" }), _jsx("div", { className: "mt-3 grid gap-2", children: requestedScopes.map((scope) => {
|
|
15
|
+
const definition = scopeDefinitions[scope];
|
|
16
|
+
return (_jsxs("div", { className: "rounded-md border border-zinc-200 bg-zinc-50 p-3", children: [_jsx("div", { className: "text-sm font-medium", children: definition?.title ?? scope }), definition?.description && _jsx("div", { className: "mt-1 text-xs text-zinc-600", children: definition.description }), _jsx("div", { className: "mt-2 font-mono text-xs text-zinc-500", children: scope })] }, scope));
|
|
17
|
+
}) })] }), _jsxs("section", { className: "rounded-md border border-zinc-200 bg-white p-4", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Allowed MCP Actions" }), _jsx("div", { className: "mt-3 divide-y divide-zinc-200 rounded-md border border-zinc-200", children: allowedTools.map((tool) => (_jsxs("div", { className: "flex items-start justify-between gap-3 px-3 py-2.5", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: tool.title }), _jsx("div", { className: "mt-1 text-xs text-zinc-600", children: tool.description })] }), _jsx("span", { className: "shrink-0 rounded-md bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600", children: tool.annotations?.readOnlyHint ? "Read" : "Write" })] }, tool.name))) })] }), _jsxs("section", { className: "rounded-md border border-zinc-200 bg-white p-4", children: [_jsx("h2", { className: "text-sm font-semibold", children: "MCP Resource" }), _jsx("div", { className: "mt-2 break-all rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-600", children: resource })] }), _jsxs("form", { action: formAction, className: "flex flex-col gap-2 sm:flex-row", method: "post", children: [Object.entries(hiddenFields).map(([name, value]) => (_jsx("input", { name: name, type: "hidden", value: value }, name))), _jsxs(Button, { className: "sm:w-auto", name: "consent_action", type: "submit", value: "approve", children: [_jsx(ShieldCheck, { className: "h-4 w-4" }), approveLabel] }), _jsxs(Button, { className: "sm:w-auto", name: "consent_action", type: "submit", value: "cancel", variant: "secondary", children: [_jsx(XCircle, { className: "h-4 w-4" }), cancelLabel] })] })] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { McpManagementClient } from "../client/management.js";
|
|
2
|
+
import type { McpDeveloperTokenSummary, McpScopeDefinition, McpScopeValue } from "../core/types.js";
|
|
3
|
+
/** Props for the developer-tokens section. */
|
|
4
|
+
export interface McpDeveloperTokensProps<S extends McpScopeValue = McpScopeValue> {
|
|
5
|
+
/** Product name used in placeholder copy. */
|
|
6
|
+
productName: string;
|
|
7
|
+
/** Workspace whose developer tokens are managed. */
|
|
8
|
+
workspace: {
|
|
9
|
+
id: string;
|
|
10
|
+
displayName?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Current signed-in user creating and revoking tokens. */
|
|
13
|
+
user: {
|
|
14
|
+
id: string;
|
|
15
|
+
};
|
|
16
|
+
/** Product-supported MCP scopes offered during token creation. */
|
|
17
|
+
scopes: McpScopeDefinition<S>[];
|
|
18
|
+
/** Server-provided initial developer tokens for the workspace. */
|
|
19
|
+
initialDeveloperTokens: McpDeveloperTokenSummary<S>[];
|
|
20
|
+
/** Optional management client; defaults to `createMcpManagementClient`. */
|
|
21
|
+
managementClient?: McpManagementClient<S>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Developer tokens: manually copied MCP bearer tokens for local agents,
|
|
25
|
+
* scripts, and debugging — create form, one-time secret reveal, list, revoke.
|
|
26
|
+
*/
|
|
27
|
+
export declare function McpDeveloperTokens<S extends McpScopeValue = McpScopeValue>({ productName, workspace, user, scopes, initialDeveloperTokens, managementClient, }: McpDeveloperTokensProps<S>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { KeyRound, Trash2 } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { Button, CopyField, SettingsSection } from "@forwardreach/saas-ui";
|
|
6
|
+
import { cx, formatDate, ScopePills, statusLabel, useMcpManagementClient } from "./mcp-settings-shared.js";
|
|
7
|
+
/**
|
|
8
|
+
* Developer tokens: manually copied MCP bearer tokens for local agents,
|
|
9
|
+
* scripts, and debugging — create form, one-time secret reveal, list, revoke.
|
|
10
|
+
*/
|
|
11
|
+
export function McpDeveloperTokens({ productName, workspace, user, scopes, initialDeveloperTokens, managementClient, }) {
|
|
12
|
+
const client = useMcpManagementClient(workspace.id, managementClient);
|
|
13
|
+
const [developerTokens, setDeveloperTokens] = React.useState(initialDeveloperTokens);
|
|
14
|
+
const [createdSecret, setCreatedSecret] = React.useState(null);
|
|
15
|
+
const [pendingId, setPendingId] = React.useState(null);
|
|
16
|
+
async function revokeDeveloperToken(tokenId) {
|
|
17
|
+
setPendingId(tokenId);
|
|
18
|
+
try {
|
|
19
|
+
const response = await client.revokeDeveloperToken(tokenId, { userId: user.id });
|
|
20
|
+
setDeveloperTokens((current) => current.map((token) => (token.tokenId === tokenId ? response.token : token)));
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
setPendingId(null);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function createDeveloperToken(event) {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
const formElement = event.currentTarget;
|
|
29
|
+
const form = new FormData(formElement);
|
|
30
|
+
const selectedScopes = form.getAll("scopes").map(String);
|
|
31
|
+
const response = await client.createDeveloperToken({
|
|
32
|
+
userId: user.id,
|
|
33
|
+
name: String(form.get("name") ?? ""),
|
|
34
|
+
scopes: selectedScopes,
|
|
35
|
+
expiresInDays: Number(form.get("expiresInDays") ?? 30),
|
|
36
|
+
});
|
|
37
|
+
setDeveloperTokens((current) => [response.token, ...current.filter((token) => token.tokenId !== response.token.tokenId)]);
|
|
38
|
+
setCreatedSecret({ tokenId: response.token.tokenId, rawToken: response.rawToken });
|
|
39
|
+
formElement.reset();
|
|
40
|
+
}
|
|
41
|
+
const inputClass = "h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 text-sm text-[color:var(--ssui-text)]";
|
|
42
|
+
return (_jsxs(SettingsSection, { description: "Manually copied bearer tokens for local agents, scripts, and debugging.", icon: _jsx(KeyRound, {}), title: "Access tokens", children: [_jsxs("form", { className: "grid gap-3 border-b border-[color:var(--ssui-border)] pb-4 md:grid-cols-[minmax(0,1fr)_auto_auto]", onSubmit: createDeveloperToken, children: [_jsxs("label", { className: "block", children: [_jsx("span", { className: "mb-1.5 block text-xs font-medium text-[color:var(--ssui-text-muted)]", children: "Token name" }), _jsx("input", { className: inputClass, name: "name", placeholder: `Local ${productName} token`, required: true })] }), _jsxs("label", { className: "block", children: [_jsx("span", { className: "mb-1.5 block text-xs font-medium text-[color:var(--ssui-text-muted)]", children: "Expiration" }), _jsxs("select", { className: cx(inputClass, "min-w-32"), defaultValue: "30", name: "expiresInDays", children: [_jsx("option", { value: "7", children: "7 days" }), _jsx("option", { value: "30", children: "30 days" }), _jsx("option", { value: "90", children: "90 days" })] })] }), _jsxs("div", { children: [_jsx("span", { className: "mb-1.5 block text-xs font-medium text-[color:var(--ssui-text-muted)]", children: "Scopes" }), _jsx("div", { className: "flex min-h-10 flex-wrap items-center gap-2", children: scopes.map((scope, index) => (_jsxs("label", { className: "flex items-center gap-1.5 text-xs text-[color:var(--ssui-text-muted)]", children: [_jsx("input", { defaultChecked: index === 0, name: "scopes", type: "checkbox", value: scope.scope }), scope.title] }, scope.scope))) })] }), _jsx("div", { className: "md:col-span-3", children: _jsxs(Button, { type: "submit", children: [_jsx(KeyRound, { className: "h-4 w-4" }), "Create token"] }) })] }), createdSecret && (_jsxs("div", { className: "mt-4 space-y-2 rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-muted)] p-3", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: "New token secret" }), _jsx("div", { className: "mt-1 text-xs text-[color:var(--ssui-text-muted)]", children: "This secret is shown only now." })] }), _jsx(CopyField, { label: "Access token", value: createdSecret.rawToken })] })), _jsxs("div", { className: "mt-4 divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [developerTokens.length === 0 && (_jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: "No access tokens have been created." })), developerTokens.map((token) => {
|
|
43
|
+
const status = statusLabel(token);
|
|
44
|
+
return (_jsxs("div", { className: "grid gap-3 px-3 py-3 md:grid-cols-[minmax(0,1fr)_auto]", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "font-medium", children: token.name }), _jsxs("span", { className: cx("inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs", status.className), children: [_jsx(status.Icon, { className: "h-3 w-3" }), status.label] })] }), _jsx("div", { className: "mt-2", children: _jsx(ScopePills, { scopes: token.scopes }) }), _jsxs("div", { className: "mt-2 grid gap-1 text-xs text-[color:var(--ssui-text-muted)] md:grid-cols-3", children: [_jsxs("div", { children: ["Created: ", formatDate(token.createdAt)] }), _jsxs("div", { children: ["Expires: ", formatDate(token.expiresAt)] }), _jsxs("div", { children: ["Last used: ", formatDate(token.lastUsedAt)] })] })] }), _jsxs(Button, { disabled: Boolean(token.revokedAt) || pendingId === token.tokenId, onClick: () => void revokeDeveloperToken(token.tokenId), title: "Revoke access token", type: "button", variant: "ghost", children: [_jsx(Trash2, { className: "h-4 w-4" }), "Revoke"] })] }, token.tokenId));
|
|
45
|
+
})] })] }));
|
|
46
|
+
}
|