@sentry/junior-dashboard 0.236.0 → 0.238.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.236.0",
3
+ "version": "0.238.0",
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-plugin-api": "0.236.0",
38
- "@sentry/junior": "0.236.0"
37
+ "@sentry/junior": "0.238.0",
38
+ "@sentry/junior-plugin-api": "0.238.0"
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: z
6
- .object({
7
- email: z.string().trim().email(),
8
- emailVerified: z.boolean().optional(),
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(route.pluginName, c.get("authSession")),
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
  });
Binary file
Binary file
package/src/auth.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { betterAuth } from "better-auth/minimal";
2
- import { dashboardIdentitySchema } from "./api/schema";
3
- import type { DashboardIdentity } from "./api/schema";
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 = DashboardIdentity["user"];
9
- export type DashboardSession = DashboardIdentity;
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 dashboardIdentitySchema.parse({
73
+ return dashboardSessionSchema.parse({
74
74
  user: {
75
75
  email,
76
76
  emailVerified,
@@ -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((page) => page.navigation === "profile");
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">{sevenDaySpend}</span>
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 aria-hidden="true" className="h-3 w-px bg-dashboard-fill-strong" />
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 &lt;email&gt;</code> and{" "}
26
+ <code>junior admin revoke &lt;email&gt;</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
- <button
142
- aria-label={`${recordAction.label}: ${record.title}`}
143
- className={`cursor-pointer border-0 bg-transparent p-1 text-dashboard-text-muted transition-colors ${
144
- recordAction.tone === "danger"
145
- ? "hover:text-rose-300"
146
- : "hover:text-dashboard-text"
147
- }`}
148
- disabled={action.isPending}
149
- key={`${recordAction.method}:${recordAction.href}`}
150
- onClick={() => runAction(recordAction)}
151
- title={recordAction.label}
152
- type="button"
153
- >
154
- {recordAction.tone === "danger" ? (
155
- <Trash2 aria-hidden="true" size={16} />
156
- ) : (
157
- recordAction.label
158
- )}
159
- </button>
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">
@@ -4,6 +4,7 @@ import type { DashboardHeaderNavItem } from "./components/layout/DashboardHeader
4
4
  import { pluginUserPagePath } from "./pages/user/PluginUserPage";
5
5
 
6
6
  const AUTH_PRIMARY_NAV_PREFIXES = [
7
+ "/admin",
7
8
  "/automations",
8
9
  "/memories",
9
10
  "/settings",
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`,