@sentry/junior-dashboard 0.258.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/dist/app.js +1 -1
- package/dist/client.js +35 -35
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/client/pages/admin/AdminPage.tsx +14 -5
- package/src/client/pages/admin/ServiceAccountsSection.tsx +231 -0
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
|
|
37
|
-
"@sentry/junior": "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",
|
|
@@ -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
|
+
}
|