@sentry/junior-dashboard 0.237.0 → 0.238.1
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 +24 -8
- package/dist/client.js +34 -33
- package/dist/index.d.ts +3 -3
- package/dist/index.js +24 -8
- package/package.json +3 -3
- package/src/api/schema.ts +19 -5
- package/src/app.ts +10 -1
- package/src/auth.ts +5 -5
- package/src/client/App.tsx +13 -0
- package/src/client/components/ProfileMenu.tsx +22 -3
- package/src/client/pages/admin/AdminPage.tsx +77 -0
- package/src/client/pages/user/PluginUserPage.tsx +33 -21
- package/src/client/primaryNav.ts +1 -0
- package/src/shell.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.238.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"react-router": "^7.18.2",
|
|
35
35
|
"shiki": "4.1.0",
|
|
36
36
|
"zod": "^4.5.4",
|
|
37
|
-
"@sentry/junior": "0.
|
|
38
|
-
"@sentry/junior
|
|
37
|
+
"@sentry/junior-plugin-api": "0.238.1",
|
|
38
|
+
"@sentry/junior": "0.238.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@oxlint/plugins": "1.79.0",
|
package/src/api/schema.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
const dashboardSessionUserSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
email: z.string().trim().email(),
|
|
6
|
+
emailVerified: z.boolean().optional(),
|
|
7
|
+
name: z.string().nullable().optional(),
|
|
8
|
+
})
|
|
9
|
+
.strict();
|
|
10
|
+
|
|
11
|
+
/** Sanitized sign-in session. It never carries Junior roles. */
|
|
12
|
+
export const dashboardSessionSchema = z
|
|
13
|
+
.object({ user: dashboardSessionUserSchema })
|
|
14
|
+
.strict();
|
|
15
|
+
|
|
16
|
+
/** Signed-in viewer returned by `/api/me`. */
|
|
3
17
|
export const dashboardIdentitySchema = z
|
|
4
18
|
.object({
|
|
5
|
-
user:
|
|
6
|
-
.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
name: z.string().nullable().optional(),
|
|
19
|
+
user: dashboardSessionUserSchema
|
|
20
|
+
.extend({
|
|
21
|
+
/** Junior-wide admin role. The server still checks it on every admin route. */
|
|
22
|
+
isAdmin: z.boolean(),
|
|
10
23
|
})
|
|
11
24
|
.strict(),
|
|
12
25
|
})
|
|
@@ -32,5 +45,6 @@ export const dashboardConfigSchema = z
|
|
|
32
45
|
})
|
|
33
46
|
.strict();
|
|
34
47
|
|
|
48
|
+
export type DashboardSession = z.infer<typeof dashboardSessionSchema>;
|
|
35
49
|
export type DashboardIdentity = z.infer<typeof dashboardIdentitySchema>;
|
|
36
50
|
export type DashboardConfig = z.infer<typeof dashboardConfigSchema>;
|
package/src/app.ts
CHANGED
|
@@ -376,6 +376,7 @@ function pluginRouteRequest(request: Request, prefix: string): Request {
|
|
|
376
376
|
function pluginRouteContext(
|
|
377
377
|
pluginName: string,
|
|
378
378
|
session: DashboardSession,
|
|
379
|
+
viewer: Variables["viewer"],
|
|
379
380
|
): PluginApiRouteRequestContext {
|
|
380
381
|
const { email, emailVerified, name } = session.user;
|
|
381
382
|
return {
|
|
@@ -383,6 +384,7 @@ function pluginRouteContext(
|
|
|
383
384
|
user: {
|
|
384
385
|
email,
|
|
385
386
|
emailVerified,
|
|
387
|
+
isAdmin: viewer?.isAdmin === true,
|
|
386
388
|
name,
|
|
387
389
|
},
|
|
388
390
|
},
|
|
@@ -612,7 +614,11 @@ export function createDashboardApp(
|
|
|
612
614
|
route.app.fetch(
|
|
613
615
|
pluginRouteRequest(c.req.raw, prefix),
|
|
614
616
|
pluginApiRouteRequestContextSchema.parse(
|
|
615
|
-
pluginRouteContext(
|
|
617
|
+
pluginRouteContext(
|
|
618
|
+
route.pluginName,
|
|
619
|
+
c.get("authSession"),
|
|
620
|
+
c.get("viewer"),
|
|
621
|
+
),
|
|
616
622
|
),
|
|
617
623
|
);
|
|
618
624
|
app.all(prefix, handler);
|
|
@@ -645,6 +651,7 @@ export function createDashboardApp(
|
|
|
645
651
|
user: {
|
|
646
652
|
email: session.user.email,
|
|
647
653
|
emailVerified: session.user.emailVerified,
|
|
654
|
+
isAdmin: viewer?.isAdmin === true,
|
|
648
655
|
name: viewer?.displayName ?? session.user.name,
|
|
649
656
|
},
|
|
650
657
|
});
|
|
@@ -684,6 +691,7 @@ export function createDashboardApp(
|
|
|
684
691
|
user: {
|
|
685
692
|
email: session.user.email,
|
|
686
693
|
emailVerified: session.user.emailVerified,
|
|
694
|
+
isAdmin: viewer.isAdmin === true,
|
|
687
695
|
name: parsed.data.displayName,
|
|
688
696
|
},
|
|
689
697
|
});
|
|
@@ -703,6 +711,7 @@ export function createDashboardApp(
|
|
|
703
711
|
user: {
|
|
704
712
|
email: session.user.email,
|
|
705
713
|
emailVerified: session.user.emailVerified,
|
|
714
|
+
isAdmin: updated.isAdmin === true,
|
|
706
715
|
name: updated.displayName,
|
|
707
716
|
},
|
|
708
717
|
});
|
package/src/auth.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { betterAuth } from "better-auth/minimal";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
2
|
+
import { dashboardSessionSchema } from "./api/schema";
|
|
3
|
+
import type { DashboardSession } from "./api/schema";
|
|
4
4
|
import { resolveDashboardBaseURL } from "./url";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
|
|
7
7
|
|
|
8
|
-
export type DashboardUser =
|
|
9
|
-
export type DashboardSession
|
|
8
|
+
export type DashboardUser = DashboardSession["user"];
|
|
9
|
+
export type { DashboardSession };
|
|
10
10
|
|
|
11
11
|
export interface DashboardAuthConfig {
|
|
12
12
|
agentName?: string;
|
|
@@ -70,7 +70,7 @@ export function sanitizeDashboardSession(
|
|
|
70
70
|
session: DashboardSession,
|
|
71
71
|
): DashboardSession {
|
|
72
72
|
const { email, emailVerified, name } = session.user;
|
|
73
|
-
return
|
|
73
|
+
return dashboardSessionSchema.parse({
|
|
74
74
|
user: {
|
|
75
75
|
email,
|
|
76
76
|
emailVerified,
|
package/src/client/App.tsx
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
MemoryPermalinkRoute,
|
|
61
61
|
PluginUserPageRoute,
|
|
62
62
|
} from "./pages/user/PluginUserPage";
|
|
63
|
+
import { AdminPage } from "./pages/admin/AdminPage";
|
|
63
64
|
import { buildPrimaryNavItems } from "./primaryNav";
|
|
64
65
|
import { dashboardShellBgClass } from "./styles";
|
|
65
66
|
import type { DashboardCoreData } from "./types";
|
|
@@ -600,6 +601,18 @@ export function DashboardShell() {
|
|
|
600
601
|
}
|
|
601
602
|
path="/plugins/:pluginName/:pageId/*"
|
|
602
603
|
/>
|
|
604
|
+
<Route
|
|
605
|
+
element={
|
|
606
|
+
loading || userPagesQuery.isPending ? (
|
|
607
|
+
<LoadingView label="Loading admin" />
|
|
608
|
+
) : loggedIn && data!.me.user.isAdmin && userPagesQuery.data ? (
|
|
609
|
+
<AdminPage pages={userPagesQuery.data} />
|
|
610
|
+
) : (
|
|
611
|
+
<Navigate replace to="/" />
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
path="/admin"
|
|
615
|
+
/>
|
|
603
616
|
<Route element={<Navigate replace to="/" />} path="*" />
|
|
604
617
|
</Routes>
|
|
605
618
|
<span
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
KeyRound,
|
|
5
5
|
LogOut,
|
|
6
6
|
Settings,
|
|
7
|
+
ShieldCheck,
|
|
7
8
|
UserRound,
|
|
8
9
|
} from "lucide-react";
|
|
9
10
|
import {
|
|
@@ -78,7 +79,9 @@ export function ProfileMenu({
|
|
|
78
79
|
const thirtyDaySpend = spend
|
|
79
80
|
? formatCostSummary({ total: spend.thirtyDaysUsd })
|
|
80
81
|
: "—";
|
|
81
|
-
const profilePages = userPages.filter(
|
|
82
|
+
const profilePages = userPages.filter(
|
|
83
|
+
(page) => page.navigation === "profile",
|
|
84
|
+
);
|
|
82
85
|
|
|
83
86
|
function clearHoverTimers() {
|
|
84
87
|
if (openTimerRef.current) clearTimeout(openTimerRef.current);
|
|
@@ -191,6 +194,12 @@ export function ProfileMenu({
|
|
|
191
194
|
<KeyRound aria-hidden="true" size={16} strokeWidth={2} />
|
|
192
195
|
API tokens
|
|
193
196
|
</Link>
|
|
197
|
+
{identity.user.isAdmin ? (
|
|
198
|
+
<Link className={profileLinkClass} onClick={close} to="/admin">
|
|
199
|
+
<ShieldCheck aria-hidden="true" size={16} strokeWidth={2} />
|
|
200
|
+
Admin
|
|
201
|
+
</Link>
|
|
202
|
+
) : null}
|
|
194
203
|
{profilePages.map((page) => (
|
|
195
204
|
<Link
|
|
196
205
|
className={profileLinkClass}
|
|
@@ -228,7 +237,9 @@ export function ProfileMenu({
|
|
|
228
237
|
</span>
|
|
229
238
|
<span className="whitespace-nowrap">
|
|
230
239
|
7d{" "}
|
|
231
|
-
<span className="font-semibold text-dashboard-text">
|
|
240
|
+
<span className="font-semibold text-dashboard-text">
|
|
241
|
+
{sevenDaySpend}
|
|
242
|
+
</span>
|
|
232
243
|
</span>
|
|
233
244
|
<span className="whitespace-nowrap">
|
|
234
245
|
30d{" "}
|
|
@@ -257,6 +268,11 @@ export function ProfileMenu({
|
|
|
257
268
|
<Link className={sheetItemClass} to="/settings/api-tokens">
|
|
258
269
|
API tokens
|
|
259
270
|
</Link>
|
|
271
|
+
{identity.user.isAdmin ? (
|
|
272
|
+
<Link className={sheetItemClass} to="/admin">
|
|
273
|
+
Admin
|
|
274
|
+
</Link>
|
|
275
|
+
) : null}
|
|
260
276
|
{profilePages.map((page) => (
|
|
261
277
|
<Link
|
|
262
278
|
className={sheetItemClass}
|
|
@@ -343,7 +359,10 @@ export function ProfileMenu({
|
|
|
343
359
|
{sevenDaySpend}
|
|
344
360
|
</span>
|
|
345
361
|
</span>
|
|
346
|
-
<span
|
|
362
|
+
<span
|
|
363
|
+
aria-hidden="true"
|
|
364
|
+
className="h-3 w-px bg-dashboard-fill-strong"
|
|
365
|
+
/>
|
|
347
366
|
<span className="flex items-baseline gap-1 whitespace-nowrap tabular-nums">
|
|
348
367
|
<span className="text-xs font-medium tracking-[0.08em] text-dashboard-text-muted">
|
|
349
368
|
30d
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Boxes, ChevronRight } from "lucide-react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { PluginUserPageLink } from "@sentry/junior-plugin-api";
|
|
4
|
+
|
|
5
|
+
import { Card } from "../../components/layout/Card";
|
|
6
|
+
import { PageHeader } from "../../components/layout/PageHeader";
|
|
7
|
+
import { dashboardContainerClass } from "../../styles";
|
|
8
|
+
import { pluginUserPagePath } from "../user/PluginUserPage";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* List admin-only plugin pages for a Junior admin.
|
|
12
|
+
*
|
|
13
|
+
* The server hides admin pages from other viewers and refuses to serve them,
|
|
14
|
+
* so this page only arranges links.
|
|
15
|
+
*/
|
|
16
|
+
export function AdminPage(props: { pages: PluginUserPageLink[] }) {
|
|
17
|
+
const adminPages = props.pages.filter((page) => page.navigation === "admin");
|
|
18
|
+
return (
|
|
19
|
+
<div className={`${dashboardContainerClass} px-4 py-8 md:px-8`}>
|
|
20
|
+
<section className="mx-auto grid w-full max-w-3xl gap-6">
|
|
21
|
+
<PageHeader
|
|
22
|
+
description={
|
|
23
|
+
<>
|
|
24
|
+
Setup that only Junior admins can see. Change who is an admin with{" "}
|
|
25
|
+
<code>junior admin grant <email></code> and{" "}
|
|
26
|
+
<code>junior admin revoke <email></code>.
|
|
27
|
+
</>
|
|
28
|
+
}
|
|
29
|
+
title="Admin"
|
|
30
|
+
/>
|
|
31
|
+
{adminPages.length === 0 ? (
|
|
32
|
+
<Card padding="md">
|
|
33
|
+
<div className="flex items-center gap-4">
|
|
34
|
+
<div className="grid size-10 shrink-0 place-items-center rounded border border-white/[0.07] bg-white/[0.025] text-dashboard-text-muted">
|
|
35
|
+
<Boxes aria-hidden="true" size={17} />
|
|
36
|
+
</div>
|
|
37
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
38
|
+
No plugin has admin setup.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
</Card>
|
|
42
|
+
) : (
|
|
43
|
+
<div className="grid gap-3">
|
|
44
|
+
{adminPages.map((page) => (
|
|
45
|
+
<Link
|
|
46
|
+
className="no-underline"
|
|
47
|
+
key={`${page.pluginName}:${page.id}`}
|
|
48
|
+
to={pluginUserPagePath(page.pluginName, page.id)}
|
|
49
|
+
>
|
|
50
|
+
<Card padding="md">
|
|
51
|
+
<div className="flex items-center gap-3">
|
|
52
|
+
<div className="min-w-0 flex-1">
|
|
53
|
+
<div className="font-mono text-xs uppercase tracking-[0.12em] text-dashboard-text-muted">
|
|
54
|
+
{page.pluginDisplayName}
|
|
55
|
+
</div>
|
|
56
|
+
<h2 className="mt-1 mb-0 font-display text-base font-medium text-dashboard-text">
|
|
57
|
+
{page.label}
|
|
58
|
+
</h2>
|
|
59
|
+
<p className="mt-1 mb-0 text-sm text-dashboard-text-muted">
|
|
60
|
+
{page.description}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<ChevronRight
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className="shrink-0 text-dashboard-text-muted"
|
|
66
|
+
size={16}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
</Card>
|
|
70
|
+
</Link>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</section>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -137,27 +137,39 @@ export function PluginUserPage(props: { page: PluginUserPageLink }) {
|
|
|
137
137
|
<h2 className="m-0 min-w-0 flex-1 font-display text-base font-medium text-dashboard-text">
|
|
138
138
|
{record.title}
|
|
139
139
|
</h2>
|
|
140
|
-
{record.actions?.map((recordAction) =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
140
|
+
{record.actions?.map((recordAction) =>
|
|
141
|
+
recordAction.method === "GET" ? (
|
|
142
|
+
// Full browser navigation, so plugin routes can redirect
|
|
143
|
+
// to an external sign-in page.
|
|
144
|
+
<a
|
|
145
|
+
className="shrink-0 rounded-md border border-dashboard-border-strong px-3 py-1.5 text-sm font-semibold text-dashboard-text no-underline transition-colors hover:bg-dashboard-fill-strong"
|
|
146
|
+
href={recordAction.href}
|
|
147
|
+
key={`${recordAction.method}:${recordAction.href}`}
|
|
148
|
+
>
|
|
149
|
+
{recordAction.label}
|
|
150
|
+
</a>
|
|
151
|
+
) : (
|
|
152
|
+
<button
|
|
153
|
+
aria-label={`${recordAction.label}: ${record.title}`}
|
|
154
|
+
className={`cursor-pointer border-0 bg-transparent p-1 text-dashboard-text-muted transition-colors ${
|
|
155
|
+
recordAction.tone === "danger"
|
|
156
|
+
? "hover:text-rose-300"
|
|
157
|
+
: "hover:text-dashboard-text"
|
|
158
|
+
}`}
|
|
159
|
+
disabled={action.isPending}
|
|
160
|
+
key={`${recordAction.method}:${recordAction.href}`}
|
|
161
|
+
onClick={() => runAction(recordAction)}
|
|
162
|
+
title={recordAction.label}
|
|
163
|
+
type="button"
|
|
164
|
+
>
|
|
165
|
+
{recordAction.tone === "danger" ? (
|
|
166
|
+
<Trash2 aria-hidden="true" size={16} />
|
|
167
|
+
) : (
|
|
168
|
+
recordAction.label
|
|
169
|
+
)}
|
|
170
|
+
</button>
|
|
171
|
+
),
|
|
172
|
+
)}
|
|
161
173
|
</div>
|
|
162
174
|
{record.description ? (
|
|
163
175
|
<p className="mt-2 mb-0 text-sm text-dashboard-text-muted">
|
package/src/client/primaryNav.ts
CHANGED
package/src/shell.ts
CHANGED
|
@@ -120,6 +120,9 @@ export function dashboardPagePaths(
|
|
|
120
120
|
): Array<{ nested?: boolean; path: string }> {
|
|
121
121
|
const paths: Array<{ nested?: boolean; path: string }> = [
|
|
122
122
|
{ path: basePath },
|
|
123
|
+
{
|
|
124
|
+
path: basePath === "/" ? "/admin" : `${basePath}/admin`,
|
|
125
|
+
},
|
|
123
126
|
{
|
|
124
127
|
nested: true,
|
|
125
128
|
path: basePath === "/" ? "/code" : `${basePath}/code`,
|