@sentry/junior-dashboard 0.257.0 → 0.259.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 +3 -3
- package/dist/app.js +12 -40
- package/dist/client.js +35 -35
- package/dist/index.d.ts +36 -41
- package/dist/index.js +12 -40
- package/package.json +6 -5
- package/src/api/schema.ts +1 -2
- package/src/app.ts +10 -56
- package/src/auth.ts +8 -5
- package/src/authenticated-routes.ts +1 -2
- package/src/client/conversations/TranscriptActivityGroup.tsx +3 -2
- package/src/client/conversations/transcriptFailure.ts +0 -4
- package/src/client/conversations/transcriptMarkdownLinks.ts +3 -0
- package/src/client/pages/admin/AdminPage.tsx +14 -5
- package/src/client/pages/admin/ServiceAccountsSection.tsx +231 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +4 -4
- package/src/client/pages/automations/automationDraft.ts +1 -1
- package/src/client/pages/system/PluginBarChart.tsx +8 -8
- package/src/client.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.259.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"react-router": "^7.18.2",
|
|
34
34
|
"shiki": "4.1.0",
|
|
35
35
|
"zod": "^4.5.4",
|
|
36
|
-
"@sentry/junior": "0.
|
|
37
|
-
"@sentry/junior-plugin-api": "0.
|
|
36
|
+
"@sentry/junior": "0.259.0",
|
|
37
|
+
"@sentry/junior-plugin-api": "0.259.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@oxlint/plugins": "1.79.0",
|
|
@@ -47,15 +47,16 @@
|
|
|
47
47
|
"eslint": "^10.8.0",
|
|
48
48
|
"oxlint": "^1.79.0",
|
|
49
49
|
"tailwindcss": "^4.3.0",
|
|
50
|
+
"tsdown": "0.23.1",
|
|
50
51
|
"tsup": "^8.5.1",
|
|
51
|
-
"typescript": "^
|
|
52
|
+
"typescript": "^7.0.2",
|
|
52
53
|
"vitest": "^4.1.7"
|
|
53
54
|
},
|
|
54
55
|
"scripts": {
|
|
55
56
|
"build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
|
|
56
57
|
"build:client": "tsup --config tsup.client.config.ts",
|
|
57
58
|
"build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify",
|
|
58
|
-
"build:server": "tsup --config tsup.config.ts",
|
|
59
|
+
"build:server": "tsup --config tsup.config.ts && tsdown --config tsdown.dts.config.ts",
|
|
59
60
|
"lint": "oxlint --config .oxlintrc.json --deny-warnings src",
|
|
60
61
|
"test": "vitest run -c vitest.config.ts",
|
|
61
62
|
"typecheck": "tsc --noEmit",
|
package/src/api/schema.ts
CHANGED
|
@@ -33,8 +33,7 @@ export const dashboardProfileUpdateSchema = z
|
|
|
33
33
|
|
|
34
34
|
export const dashboardConfigSchema = z
|
|
35
35
|
.object({
|
|
36
|
-
|
|
37
|
-
allowedGoogleDomainCount: z.number(),
|
|
36
|
+
allowedDomainCount: z.number(),
|
|
38
37
|
authRequired: z.boolean(),
|
|
39
38
|
authPath: z.string(),
|
|
40
39
|
basePath: z.string(),
|
package/src/app.ts
CHANGED
|
@@ -78,14 +78,14 @@ export interface JuniorDashboardOptions {
|
|
|
78
78
|
baseURL?: string;
|
|
79
79
|
authPath?: string;
|
|
80
80
|
authRequired?: boolean;
|
|
81
|
-
allowedGoogleDomains?: string[];
|
|
82
|
-
allowedEmails?: string[];
|
|
83
81
|
auth?: DashboardAuth;
|
|
84
82
|
componentGallery?: boolean;
|
|
85
83
|
mockConversations?: boolean;
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
interface DashboardRuntimeOptions extends JuniorDashboardOptions {
|
|
87
|
+
/** The app's `allowedDomains`, from core. Auth requires at least one. */
|
|
88
|
+
allowedDomains?: string[];
|
|
89
89
|
authenticatedRoutes?: readonly AuthenticatedRoute[];
|
|
90
90
|
/** The queue `createApp()` uses, so web input reaches the same worker. */
|
|
91
91
|
conversationWorkQueue?: ConversationWorkQueue;
|
|
@@ -113,37 +113,6 @@ function normalizeValues(values: string[] | undefined): string[] {
|
|
|
113
113
|
];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/** Read dashboard list env vars as comma-separated strings or JSON arrays. */
|
|
117
|
-
function readEnvList(name: string): string[] | undefined {
|
|
118
|
-
const value = process.env[name];
|
|
119
|
-
if (!value?.trim()) {
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (value.trim().startsWith("[")) {
|
|
124
|
-
let parsed: unknown;
|
|
125
|
-
try {
|
|
126
|
-
parsed = JSON.parse(value);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
throw new Error(`${name} must be a JSON string array`, {
|
|
129
|
-
cause: error,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
if (
|
|
133
|
-
!Array.isArray(parsed) ||
|
|
134
|
-
parsed.some((item) => typeof item !== "string")
|
|
135
|
-
) {
|
|
136
|
-
throw new Error(`${name} must be a JSON string array`);
|
|
137
|
-
}
|
|
138
|
-
return parsed;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return value
|
|
142
|
-
.split(",")
|
|
143
|
-
.map((item) => item.trim())
|
|
144
|
-
.filter(Boolean);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
116
|
/** Read dashboard boolean env vars; only explicit true/false values apply. */
|
|
148
117
|
function readEnvFlag(name: string): boolean | undefined {
|
|
149
118
|
const value = process.env[name]?.trim();
|
|
@@ -160,11 +129,6 @@ function resolveDashboardOptions(
|
|
|
160
129
|
...options,
|
|
161
130
|
authRequired:
|
|
162
131
|
options.authRequired ?? readEnvFlag("JUNIOR_DASHBOARD_AUTH_REQUIRED"),
|
|
163
|
-
allowedGoogleDomains:
|
|
164
|
-
options.allowedGoogleDomains ??
|
|
165
|
-
readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"),
|
|
166
|
-
allowedEmails:
|
|
167
|
-
options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"),
|
|
168
132
|
componentGallery:
|
|
169
133
|
options.componentGallery ??
|
|
170
134
|
readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"),
|
|
@@ -404,8 +368,7 @@ export function createDashboardApp(
|
|
|
404
368
|
options.authPath ?? DEFAULT_AUTH_PATH,
|
|
405
369
|
DEFAULT_AUTH_PATH,
|
|
406
370
|
);
|
|
407
|
-
const allowedDomains = normalizeValues(options.
|
|
408
|
-
const allowedEmails = normalizeValues(options.allowedEmails);
|
|
371
|
+
const allowedDomains = normalizeValues(options.allowedDomains);
|
|
409
372
|
const agentName = options.agentName?.trim() || "Junior";
|
|
410
373
|
|
|
411
374
|
const authRequired = options.authRequired !== false;
|
|
@@ -415,17 +378,11 @@ export function createDashboardApp(
|
|
|
415
378
|
canonicalBaseURL = resolveDashboardBaseURL({ baseURL: configuredBaseURL });
|
|
416
379
|
}
|
|
417
380
|
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
allowedDomains.length === 0 &&
|
|
421
|
-
allowedEmails.length === 0
|
|
422
|
-
) {
|
|
423
|
-
throw new Error(
|
|
424
|
-
"Junior dashboard auth requires allowedGoogleDomains or allowedEmails",
|
|
425
|
-
);
|
|
381
|
+
if (authRequired && allowedDomains.length === 0) {
|
|
382
|
+
throw new Error("Junior dashboard auth requires allowedDomains");
|
|
426
383
|
}
|
|
427
384
|
|
|
428
|
-
// Core owns the Google sign-in session
|
|
385
|
+
// Core owns the Google sign-in session.
|
|
429
386
|
const auth = options.auth;
|
|
430
387
|
if (authRequired && !auth) {
|
|
431
388
|
throw new Error("Junior dashboard auth requires the core sign-in session");
|
|
@@ -448,10 +405,7 @@ export function createDashboardApp(
|
|
|
448
405
|
return Response.redirect(returnUrl, 302);
|
|
449
406
|
}
|
|
450
407
|
const session = await auth.getSession(c.req.raw);
|
|
451
|
-
if (
|
|
452
|
-
session &&
|
|
453
|
-
dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)
|
|
454
|
-
) {
|
|
408
|
+
if (session && dashboardSessionIsAuthorized(session, allowedDomains)) {
|
|
455
409
|
return Response.redirect(returnUrl, 302);
|
|
456
410
|
}
|
|
457
411
|
return auth.signInWithGoogle(c.req.raw, returnUrl);
|
|
@@ -540,7 +494,7 @@ export function createDashboardApp(
|
|
|
540
494
|
componentGallery: options.componentGallery,
|
|
541
495
|
});
|
|
542
496
|
}
|
|
543
|
-
if (!dashboardSessionIsAuthorized(session, allowedDomains
|
|
497
|
+
if (!dashboardSessionIsAuthorized(session, allowedDomains)) {
|
|
544
498
|
return forbidden(c.req.raw, agentName);
|
|
545
499
|
}
|
|
546
500
|
const sanitizedSession = sanitizeDashboardSession(session);
|
|
@@ -563,6 +517,7 @@ export function createDashboardApp(
|
|
|
563
517
|
c.set("viewer", viewer);
|
|
564
518
|
}
|
|
565
519
|
await next();
|
|
520
|
+
return undefined;
|
|
566
521
|
};
|
|
567
522
|
|
|
568
523
|
app.use("*", requireAuth);
|
|
@@ -621,8 +576,7 @@ export function createDashboardApp(
|
|
|
621
576
|
);
|
|
622
577
|
app.get("/api/config", () => {
|
|
623
578
|
return jsonResponse(dashboardConfigSchema, {
|
|
624
|
-
|
|
625
|
-
allowedGoogleDomainCount: allowedDomains.length,
|
|
579
|
+
allowedDomainCount: allowedDomains.length,
|
|
626
580
|
authRequired,
|
|
627
581
|
authPath,
|
|
628
582
|
basePath,
|
package/src/auth.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { DashboardSession } from "./api/schema";
|
|
|
4
4
|
export type DashboardUser = DashboardSession["user"];
|
|
5
5
|
export type { DashboardSession };
|
|
6
6
|
|
|
7
|
-
/** Google sign-in session that core
|
|
7
|
+
/** Google sign-in session that core owns. */
|
|
8
8
|
export interface DashboardAuth {
|
|
9
9
|
handler(request: Request): Promise<Response>;
|
|
10
10
|
getSession(request: Request): Promise<DashboardSession | null>;
|
|
@@ -25,19 +25,22 @@ export function dashboardBearerSession(email: string): DashboardSession {
|
|
|
25
25
|
return { user: { email, emailVerified: true } };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Check the allowed domains against one verified session. Core accepts a
|
|
30
|
+
* Google sign-in only for a Workspace account on an allowed domain. This
|
|
31
|
+
* check also applies a changed list to existing sessions and personal tokens.
|
|
32
|
+
*/
|
|
29
33
|
export function dashboardSessionIsAuthorized(
|
|
30
34
|
session: DashboardSession,
|
|
31
35
|
allowedDomains: string[],
|
|
32
|
-
allowedEmails: string[],
|
|
33
36
|
): boolean {
|
|
34
37
|
const email = session.user.email.toLowerCase();
|
|
35
38
|
const separator = email.lastIndexOf("@");
|
|
36
39
|
const domain = separator > 0 ? email.slice(separator + 1) : undefined;
|
|
37
40
|
return Boolean(
|
|
38
41
|
session.user.emailVerified &&
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
domain !== undefined &&
|
|
43
|
+
allowedDomains.includes(domain),
|
|
41
44
|
);
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -19,8 +19,7 @@ export function isAuthPath(
|
|
|
19
19
|
const requestSegments = segments(pathname);
|
|
20
20
|
return routes.some((route) => {
|
|
21
21
|
const routeSegments = segments(route.path);
|
|
22
|
-
for (
|
|
23
|
-
const pattern = routeSegments[index];
|
|
22
|
+
for (const [index, pattern] of routeSegments.entries()) {
|
|
24
23
|
if (pattern === "*") return true;
|
|
25
24
|
const value = requestSegments[index];
|
|
26
25
|
if (!value || (pattern !== value && !pattern.startsWith(":"))) {
|
|
@@ -101,9 +101,10 @@ export function activityGroupSummary(
|
|
|
101
101
|
(entry) => entry.kind === "attachments_delivered",
|
|
102
102
|
).length;
|
|
103
103
|
const eventCount = entries.filter((entry) => entry.kind === "message").length;
|
|
104
|
+
const [onlyHandoff] = handoffs;
|
|
104
105
|
const handoffSummary =
|
|
105
|
-
handoffs.length === 1
|
|
106
|
-
? `model handoff to ${
|
|
106
|
+
handoffs.length === 1 && onlyHandoff
|
|
107
|
+
? `model handoff to ${onlyHandoff.modelId} (${onlyHandoff.modelProfile})`
|
|
107
108
|
: handoffs.length > 1
|
|
108
109
|
? countLabel(handoffs.length, "model handoff", "model handoffs")
|
|
109
110
|
: undefined;
|
|
@@ -55,8 +55,6 @@ export function transcriptFailureTitle(
|
|
|
55
55
|
case "agent_run_failed":
|
|
56
56
|
return "Internal error";
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
return "Internal error";
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
/** Short description for one failed turn in the transcript. */
|
|
@@ -111,8 +109,6 @@ export function transcriptFailureDescription(
|
|
|
111
109
|
case "agent_run_failed":
|
|
112
110
|
return `${agentName} hit an internal error during this turn.`;
|
|
113
111
|
}
|
|
114
|
-
|
|
115
|
-
return `${agentName} hit an internal error during this turn.`;
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
/** Search text for one failed turn in the transcript. */
|
|
@@ -88,6 +88,7 @@ function findNextMarkdownLink(
|
|
|
88
88
|
start: linkStart,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
return undefined;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function findMarkdownLinkSyntaxRanges(
|
|
@@ -148,6 +149,7 @@ function readMarkdownDestination(
|
|
|
148
149
|
const href = text.slice(start, index);
|
|
149
150
|
return href ? { end: index + 1, href } : undefined;
|
|
150
151
|
}
|
|
152
|
+
return undefined;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
function findCommonMarkAutolinks(
|
|
@@ -199,6 +201,7 @@ function findNextBareLink(
|
|
|
199
201
|
start: match.index,
|
|
200
202
|
};
|
|
201
203
|
}
|
|
204
|
+
return undefined;
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
function findBareLinks(
|
|
@@ -6,15 +6,23 @@ import { Card } from "../../components/layout/Card";
|
|
|
6
6
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
7
7
|
import { dashboardContainerClass } from "../../styles";
|
|
8
8
|
import { pluginUserPagePath } from "../user/PluginUserPage";
|
|
9
|
+
import {
|
|
10
|
+
ServiceAccountsSection,
|
|
11
|
+
useServiceAccounts,
|
|
12
|
+
} from "./ServiceAccountsSection";
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
15
|
+
* Show service accounts and admin-only plugin pages to a Junior admin.
|
|
12
16
|
*
|
|
13
|
-
* The server hides admin pages from other viewers and
|
|
14
|
-
* so this page only arranges
|
|
17
|
+
* The server hides admin pages and service accounts from other viewers and
|
|
18
|
+
* refuses their requests, so this page only arranges them.
|
|
15
19
|
*/
|
|
16
20
|
export function AdminPage(props: { pages: PluginUserPageLink[] }) {
|
|
17
21
|
const adminPages = props.pages.filter((page) => page.navigation === "admin");
|
|
22
|
+
const serviceAccounts = useServiceAccounts();
|
|
23
|
+
const nothingToSet =
|
|
24
|
+
adminPages.length === 0 &&
|
|
25
|
+
serviceAccounts.data?.serviceAccounts.length === 0;
|
|
18
26
|
return (
|
|
19
27
|
<div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
|
|
20
28
|
<section className="mx-auto grid w-full max-w-3xl gap-6">
|
|
@@ -28,7 +36,8 @@ export function AdminPage(props: { pages: PluginUserPageLink[] }) {
|
|
|
28
36
|
}
|
|
29
37
|
title="Admin"
|
|
30
38
|
/>
|
|
31
|
-
{
|
|
39
|
+
<ServiceAccountsSection accountsQuery={serviceAccounts} />
|
|
40
|
+
{nothingToSet ? (
|
|
32
41
|
<Card padding="md">
|
|
33
42
|
<div className="flex items-center gap-4">
|
|
34
43
|
<div className="grid size-10 shrink-0 place-items-center rounded-md border border-dashboard-border bg-dashboard-fill-soft text-dashboard-text-muted">
|
|
@@ -39,7 +48,7 @@ export function AdminPage(props: { pages: PluginUserPageLink[] }) {
|
|
|
39
48
|
</p>
|
|
40
49
|
</div>
|
|
41
50
|
</Card>
|
|
42
|
-
) : (
|
|
51
|
+
) : adminPages.length === 0 ? null : (
|
|
43
52
|
<div className="grid gap-3">
|
|
44
53
|
{adminPages.map((page) => (
|
|
45
54
|
<Link
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMutation,
|
|
3
|
+
useQuery,
|
|
4
|
+
useQueryClient,
|
|
5
|
+
type UseQueryResult,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import { KeyRound } from "lucide-react";
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
import {
|
|
10
|
+
disconnectServiceAccountResponseSchema,
|
|
11
|
+
serviceAccountListSchema,
|
|
12
|
+
startServiceAccountSignInResponseSchema,
|
|
13
|
+
type serviceAccountSchema,
|
|
14
|
+
} from "@sentry/junior/api/schema";
|
|
15
|
+
import { getDashboardAgentName } from "../../agentName";
|
|
16
|
+
import { Button } from "../../components/Button";
|
|
17
|
+
import { InlineError } from "../../components/InlineError";
|
|
18
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
19
|
+
import { Card } from "../../components/layout/Card";
|
|
20
|
+
import { formatRelativeTime } from "../../format";
|
|
21
|
+
import { del, fetchDashboardJson, post } from "../../http";
|
|
22
|
+
|
|
23
|
+
type ServiceAccount = z.infer<typeof serviceAccountSchema>;
|
|
24
|
+
|
|
25
|
+
const serviceAccountsQueryKey = ["dashboard", "service-accounts"] as const;
|
|
26
|
+
|
|
27
|
+
function serviceAccountPath(provider: string): string {
|
|
28
|
+
return `/api/service-accounts/${encodeURIComponent(provider)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Load the plugins that support a service account, with their status. */
|
|
32
|
+
export function useServiceAccounts() {
|
|
33
|
+
return useQuery({
|
|
34
|
+
queryKey: serviceAccountsQueryKey,
|
|
35
|
+
queryFn: ({ signal }) =>
|
|
36
|
+
fetchDashboardJson(
|
|
37
|
+
serviceAccountListSchema,
|
|
38
|
+
"/api/service-accounts",
|
|
39
|
+
signal,
|
|
40
|
+
),
|
|
41
|
+
// OAuth finishes in another tab. Refresh when the admin returns.
|
|
42
|
+
refetchOnWindowFocus: true,
|
|
43
|
+
retry: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function statusText(account: ServiceAccount): string {
|
|
48
|
+
const usage = !account.userOAuth
|
|
49
|
+
? `Everyone uses this account. People cannot connect their own ${account.displayName} account.`
|
|
50
|
+
: `People who have not connected their own ${account.displayName} account use this account.`;
|
|
51
|
+
if (!account.connected) {
|
|
52
|
+
const blocked = !account.userOAuth
|
|
53
|
+
? ` ${account.displayName} is not available until you connect it.`
|
|
54
|
+
: "";
|
|
55
|
+
return `Not connected.${blocked} ${usage} Sign in as the shared account, not as yourself.`;
|
|
56
|
+
}
|
|
57
|
+
const by = account.connectedBy ?? "the CLI";
|
|
58
|
+
return `Connected by ${by} ${formatRelativeTime(account.connectedAt)}. ${usage}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Connect and disconnect the shared provider accounts that Junior uses for
|
|
63
|
+
* people who have not connected their own account.
|
|
64
|
+
*/
|
|
65
|
+
export function ServiceAccountsSection(props: {
|
|
66
|
+
accountsQuery: UseQueryResult<z.infer<typeof serviceAccountListSchema>>;
|
|
67
|
+
}) {
|
|
68
|
+
const { accountsQuery } = props;
|
|
69
|
+
const queryClient = useQueryClient();
|
|
70
|
+
const connectMutation = useMutation({
|
|
71
|
+
mutationFn: (input: { provider: string; popup: Window | null }) =>
|
|
72
|
+
post(
|
|
73
|
+
startServiceAccountSignInResponseSchema,
|
|
74
|
+
serviceAccountPath(input.provider),
|
|
75
|
+
{},
|
|
76
|
+
),
|
|
77
|
+
onSuccess: ({ authorizationUrl }, { popup }) => {
|
|
78
|
+
// A blocked popup returns null, so use this tab. A tab the admin
|
|
79
|
+
// closed means they cancelled; keep them on the dashboard.
|
|
80
|
+
if (!popup) {
|
|
81
|
+
window.location.assign(authorizationUrl);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (popup.closed) return;
|
|
85
|
+
popup.opener = null;
|
|
86
|
+
popup.location.href = authorizationUrl;
|
|
87
|
+
},
|
|
88
|
+
onError: (_error, { popup }) => {
|
|
89
|
+
popup?.close();
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const disconnectMutation = useMutation({
|
|
93
|
+
mutationFn: (account: ServiceAccount) =>
|
|
94
|
+
del(
|
|
95
|
+
disconnectServiceAccountResponseSchema,
|
|
96
|
+
serviceAccountPath(account.provider),
|
|
97
|
+
),
|
|
98
|
+
onSuccess: () =>
|
|
99
|
+
queryClient.invalidateQueries({ queryKey: serviceAccountsQueryKey }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const accounts = accountsQuery.data?.serviceAccounts ?? [];
|
|
103
|
+
if (accountsQuery.isSuccess && accounts.length === 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const busy = connectMutation.isPending || disconnectMutation.isPending;
|
|
107
|
+
const pendingProvider = connectMutation.isPending
|
|
108
|
+
? connectMutation.variables?.provider
|
|
109
|
+
: disconnectMutation.isPending
|
|
110
|
+
? disconnectMutation.variables?.provider
|
|
111
|
+
: undefined;
|
|
112
|
+
const listFailed = !accountsQuery.data && accountsQuery.isError;
|
|
113
|
+
const displayedError = connectMutation.isError
|
|
114
|
+
? "Could not start the sign-in. Try again."
|
|
115
|
+
: disconnectMutation.isError
|
|
116
|
+
? "Could not disconnect the account. Try again."
|
|
117
|
+
: listFailed
|
|
118
|
+
? "Could not load service accounts. Try again."
|
|
119
|
+
: undefined;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Card as="section" padding="md">
|
|
123
|
+
<h2 className="m-0 font-display text-base font-medium text-dashboard-text">
|
|
124
|
+
Service accounts
|
|
125
|
+
</h2>
|
|
126
|
+
<p className="mt-1 mb-4 text-sm text-dashboard-text-muted">
|
|
127
|
+
A shared account that Junior uses for a plugin. It can do only what its
|
|
128
|
+
plugin allows, usually read. Everyone who can talk to{" "}
|
|
129
|
+
{getDashboardAgentName()} can use it, so give it only access that all of
|
|
130
|
+
them may have.
|
|
131
|
+
</p>
|
|
132
|
+
{displayedError ? (
|
|
133
|
+
<InlineError className={listFailed ? undefined : "mb-4"}>
|
|
134
|
+
{displayedError}
|
|
135
|
+
</InlineError>
|
|
136
|
+
) : null}
|
|
137
|
+
{listFailed ? null : accountsQuery.isPending ? (
|
|
138
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
139
|
+
Loading service accounts…
|
|
140
|
+
</p>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="space-y-2">
|
|
143
|
+
{accounts.map((account) => (
|
|
144
|
+
<div
|
|
145
|
+
className="flex items-center gap-3 rounded-md border border-dashboard-border-strong p-3"
|
|
146
|
+
key={account.provider}
|
|
147
|
+
>
|
|
148
|
+
<KeyRound
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
className="shrink-0 text-dashboard-accent"
|
|
151
|
+
size={16}
|
|
152
|
+
/>
|
|
153
|
+
<div className="min-w-0 flex-1">
|
|
154
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
155
|
+
<p className="m-0 truncate text-sm font-semibold">
|
|
156
|
+
{account.displayName}
|
|
157
|
+
</p>
|
|
158
|
+
<StatusChip
|
|
159
|
+
size="compact"
|
|
160
|
+
tone={
|
|
161
|
+
account.connected
|
|
162
|
+
? "success"
|
|
163
|
+
: !account.userOAuth
|
|
164
|
+
? "warning"
|
|
165
|
+
: "neutral"
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
{account.connected ? "connected" : "not connected"}
|
|
169
|
+
</StatusChip>
|
|
170
|
+
{!account.userOAuth ? (
|
|
171
|
+
<StatusChip size="compact" tone="neutral">
|
|
172
|
+
shared only
|
|
173
|
+
</StatusChip>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
<p className="mt-1 mb-0 break-words text-xs text-dashboard-text-muted">
|
|
177
|
+
{statusText(account)}
|
|
178
|
+
</p>
|
|
179
|
+
<p className="mt-1 mb-0 break-words font-mono text-xs text-dashboard-text-faint">
|
|
180
|
+
{account.tools.join(", ")}
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
{account.connected ? (
|
|
184
|
+
<Button
|
|
185
|
+
aria-busy={
|
|
186
|
+
disconnectMutation.isPending &&
|
|
187
|
+
pendingProvider === account.provider
|
|
188
|
+
}
|
|
189
|
+
aria-label={`Disconnect the ${account.displayName} service account`}
|
|
190
|
+
disabled={busy}
|
|
191
|
+
onClick={() => {
|
|
192
|
+
connectMutation.reset();
|
|
193
|
+
disconnectMutation.mutate(account);
|
|
194
|
+
}}
|
|
195
|
+
tone="danger"
|
|
196
|
+
>
|
|
197
|
+
Disconnect
|
|
198
|
+
</Button>
|
|
199
|
+
) : null}
|
|
200
|
+
<Button
|
|
201
|
+
aria-busy={
|
|
202
|
+
connectMutation.isPending &&
|
|
203
|
+
pendingProvider === account.provider
|
|
204
|
+
}
|
|
205
|
+
aria-label={`${account.connected ? "Reconnect" : "Connect"} the ${account.displayName} service account`}
|
|
206
|
+
disabled={busy}
|
|
207
|
+
onClick={() => {
|
|
208
|
+
disconnectMutation.reset();
|
|
209
|
+
// Open the tab during the click so popup blockers allow
|
|
210
|
+
// it, then point it at the provider.
|
|
211
|
+
const popup = window.open("about:blank", "_blank");
|
|
212
|
+
connectMutation.mutate({
|
|
213
|
+
provider: account.provider,
|
|
214
|
+
popup,
|
|
215
|
+
});
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{connectMutation.isPending &&
|
|
219
|
+
pendingProvider === account.provider
|
|
220
|
+
? "Connecting…"
|
|
221
|
+
: account.connected
|
|
222
|
+
? "Reconnect"
|
|
223
|
+
: "Connect"}
|
|
224
|
+
</Button>
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</Card>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -141,10 +141,10 @@ export function AutomationOutcomeFields(props: {
|
|
|
141
141
|
disabled={index === 0}
|
|
142
142
|
onClick={() => {
|
|
143
143
|
const next = [...props.value];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
];
|
|
144
|
+
const previous = next[index - 1];
|
|
145
|
+
if (!previous) return;
|
|
146
|
+
next[index - 1] = outcome;
|
|
147
|
+
next[index] = previous;
|
|
148
148
|
props.onChange(next);
|
|
149
149
|
}}
|
|
150
150
|
>
|
|
@@ -53,7 +53,7 @@ export function scheduleDraft(
|
|
|
53
53
|
...fields,
|
|
54
54
|
timezone: value.schedule.timezone,
|
|
55
55
|
time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
|
|
56
|
-
weekdays: days?.
|
|
56
|
+
weekdays: days?.flatMap((day) => weekdays[day] ?? []),
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
if (value.nextRunAtMs) {
|
|
@@ -23,6 +23,12 @@ const toneColors = {
|
|
|
23
23
|
warning: "#fbbf24",
|
|
24
24
|
} as const;
|
|
25
25
|
|
|
26
|
+
function seriesColor(series: Widget["series"][number], index: number): string {
|
|
27
|
+
return series.tone
|
|
28
|
+
? toneColors[series.tone]
|
|
29
|
+
: (colors[index % colors.length] ?? colors[0]);
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/** Render a validated plugin-owned categorical bar chart. */
|
|
27
33
|
export function PluginBarChart(props: {
|
|
28
34
|
range?: TimeRangeDays;
|
|
@@ -69,9 +75,7 @@ export function PluginBarChart(props: {
|
|
|
69
75
|
<ChartLegend
|
|
70
76
|
ariaLabel="Chart legend"
|
|
71
77
|
items={widget.series.map((series, index) => ({
|
|
72
|
-
color: series
|
|
73
|
-
? toneColors[series.tone]
|
|
74
|
-
: colors[index % colors.length],
|
|
78
|
+
color: seriesColor(series, index),
|
|
75
79
|
key: series.key,
|
|
76
80
|
label: series.label,
|
|
77
81
|
}))}
|
|
@@ -122,11 +126,7 @@ export function PluginBarChart(props: {
|
|
|
122
126
|
>
|
|
123
127
|
<rect
|
|
124
128
|
aria-label={`${formatActivityCategoryTooltipLabel(category.label)}, ${series.label}: ${formatted}`}
|
|
125
|
-
fill={
|
|
126
|
-
series.tone
|
|
127
|
-
? toneColors[series.tone]
|
|
128
|
-
: colors[seriesIndex % colors.length]
|
|
129
|
-
}
|
|
129
|
+
fill={seriesColor(series, seriesIndex)}
|
|
130
130
|
height={renderedHeight}
|
|
131
131
|
opacity={value ? 0.82 : 0.1}
|
|
132
132
|
rx="1.5"
|
package/src/client.tsx
CHANGED
|
@@ -22,18 +22,18 @@ class DashboardErrorBoundary extends Component<
|
|
|
22
22
|
{ children: ReactNode },
|
|
23
23
|
ErrorBoundaryState
|
|
24
24
|
> {
|
|
25
|
-
state: ErrorBoundaryState = { error: null };
|
|
25
|
+
override state: ErrorBoundaryState = { error: null };
|
|
26
26
|
|
|
27
27
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
28
28
|
return { error };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
31
|
+
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
32
32
|
const stack = error.stack ?? errorInfo.componentStack;
|
|
33
33
|
window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
render() {
|
|
36
|
+
override render() {
|
|
37
37
|
if (this.state.error) {
|
|
38
38
|
return <DashboardErrorPanel error={this.state.error} />;
|
|
39
39
|
}
|