Skip to content

Commit 1b17222

Browse files
nachoddyyyyyyyan
andauthored
SEP-1844: Hide per-app write controls from non-admin users so the UI matches what the API allows (#1387)
## What A non-admin signed in to SEP saw create / execute / stop / retry / delete controls that answer 403 now that the server enforces per-route roles. This hides them, so the UI offers only what the session can actually do. The gap was structural, not an oversight per control. `useAuth` lived in `@sep/shell`, and the workspace dependency direction is `shell -> apps -> framework -> api` — neither the app packages nor the framework could reach it. Exactly one app (snippets) was role-aware, and only because a shell-side wrapper read the flag and threaded it down as a prop. ## How **The context moved down the graph** rather than the boolean being threaded across it. `@sep/api` has zero `@sep` dependencies, already declares React as a peer and hosts the query hooks, and every consuming package already depends on it — so no new workspace package and no `package.json` changes. It now owns `AuthContext` + `useAuth()` (`frontend/packages/api/src/auth-context.ts`); `@sep/shell` keeps the provider and all session/token state, and re-exports `useAuth` so shell-local imports are unchanged. **A missing provider resolves to a signed-out, non-admin session** instead of throwing, so the many test and Storybook renders that mount these components bare keep working. That trades a loud failure for a silent one, so a once-per-bundle dev-mode warning fires when it happens. **Gated controls read `canMutate`, never `isAdmin`.** The derivation lives in one function, `deriveCanMutate`. `isAdmin` survives only where the question really is "is this an administrator": the shell's Settings and Admin Apps guards and their `enabled: isAdmin` query suppression. (The `AtwApp` registry wrapper still passes `isAdmin` as a prop — that one gates a link to the Settings page, an admin-identity concern, and was left alone.) **Gating the framework's shared components covers every schema-driven app at once.** The bespoke apps are swept at their rendering call sites, not on the hooks, so a hidden control issues no request and the hooks stay usable unchanged on the admin path. | Layer | Gated | |---|---| | framework | `AppCreatePage`, `AppTaskEditPage`, `SchemaDrivenApp`'s entity edit (whole-page guard); `AppListPage` create + row delete; `AppDetailPage` execute / edit / delete, and the whole action bar when nothing is left in it; `ScheduledTasksPanel` + `ScheduledTaskRow`; `TaskHistoryTable` stop; `SnippetExecutionAccordion` execute form; `SchemaListView` actions column | | atw | create / rename / delete / close / reopen, batch execute, send + re-send, and the execution selection feeding them | | alerts | push / restore / PagerDuty, and the template selection feeding push | | report | start PDF job, start upload job | | inventory | sync, connectivity probe, nested-list row delete, schedule attach / edit / clear / enable | | dipper | execute form | | topology | collect | | tasks, dipper | stop, via the shared `TaskHistoryTable` | **Reads are untouched.** The server gate keys on HTTP method, so every GET still succeeds for a non-admin and no new query suppression was needed. One query is now *avoided*: the snippet accordion no longer fetches a form schema it will not render. **snippets collapses onto the hook** and the bespoke `SnippetsApp` wrapper in `appRegistry.tsx` is deleted. Hiding a control is not enforcement. The backend gate stays the only boundary. ## Acceptance criteria - **AC1** — `useAuth()` and `isAdmin` importable by `@sep/framework` and every `apps/*` package with no dependency on `@sep/shell`; same module exports `canMutate`. - **AC2** — outside a provider, resolves to `UNAUTHENTICATED_SESSION` instead of throwing. - **AC3** — all eight framework surfaces render no create / execute / stop / retry / delete for a non-admin. - **AC4** — all seven bespoke apps swept. - **AC5** — admin behaviour unchanged. Most diffs are `&& canMutate` on existing markup, so the admin branch is byte-identical. Two are not that shape and are described separately: `SchemaListView`'s new `.filter()` also drops an `actions` column for an admin on any caller that never wires `onDeleteRow` (`TasksListPage`, `TargetHostsPage`), and `ScheduledTasksPanel` restructures its header list. Both are improvements, and `app/sep/apps/inventory/schema.py` is the only source of `_actions` columns, so nothing regresses. - **AC6** — no new query for a non-admin, and no new suppression (one query removed). - **AC7** — snippets reads the hook; registry wrapper deleted. - **AC8** — both halves asserted for each gated surface. - **AC9** — every gated control reads `canMutate`; a repo-wide check shows no `isAdmin` left in a mutation decision. ## Notes for the reviewer **SEP-1850 has already merged** (`214e45f52`), so the server resolves a minimum role per route and `User.role` is already on the wire. AC9 assumes it has not. I kept `deriveCanMutate` as `isAdmin` per the ticket: most unsafe routes still require `admin`, so keying on a lesser role here would put back the controls that answer 403. The one real consequence is that an `editor` — who the server would accept on Alert Templates' push and restore — is offered no button for them. Widening this is now a change to that one function plus a per-control minimum role, which is exactly the seam this PR builds. Called out in the derivation comment and the changelog fragment. **Two judgement calls beyond the ticket, both needed to keep the suite honest:** - The 20 e2e specs stubbed `isAdmin: false` while clicking create / execute / delete. The flag did not previously affect app pages; now it does. Flipped to admin, which is what those specs were written to exercise. - Added an admin-session Storybook decorator, otherwise every story would render its read-only state. **Out of scope, per the ticket:** reporting a refusal when a 403 still happens (SEP-1845), granular per-action permissions, redesigning the sparse read-only pages that remain, and server-driven per-app capability flags. **One tradeoff recorded in the module doc:** the session and its derived capability share one context, so a silent-refresh token rotation re-renders capability consumers too. That is a handful of controls every few minutes; splitting the capability into its own context is the fix if it ever costs more than it saves. ## Test plan - `pnpm -r type-check` — clean across all 17 packages. - `pnpm -r test` — 1426 passing. New both-halves coverage for every gated surface: framework (`AppCreatePage`, `AppTaskEditPage`, `AppListPage`, `AppDetailPage` task + entity, `SchemaDrivenApp`, `ScheduledTasksPanel`, `TaskHistoryTable`, `SnippetExecutionAccordion`, `SchemaListView`), apps (`atw` list / workspace / results / collect, `alerts`, `report`, `inventory` sync / connectivity / schedule, `dipper`, `topology`, `snippets`), plus the capability derivation and the missing-provider fallback. - `pnpm test:e2e` — 132 passing. - `oxlint` 0 errors, `oxfmt --check` clean. Reviewed by two independent reviewers before opening. One returned no defects; the other found a real one and three design points, all addressed in this branch: the shared `SchemaListView` left a header-only `actions` column for non-admins (now dropped when no delete handler is wired, with a regression test); the page guards had no way back (back chrome kept); the read-only wording was forked between the guard and the snippet accordion (one component, new `inline` variant); and the capability derivation had no test in its own package (added). --------- Signed-off-by: Ignacio Durand <nachodurand@gmail.com> Co-authored-by: yyyyyyy <yan.orestes@percona.com> Co-authored-by: yyyyyyy <contact@yyyyyyyan.tech>
1 parent 1c663ca commit 1b17222

77 files changed

Lines changed: 2146 additions & 472 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

changelog.d/SEP-1844.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Non-administrator sessions no longer see per-app create, execute, stop, retry or delete controls in the React UI: every such control is hidden rather than offered and answered with a 403. This is a UI-only change and never a security boundary — the server-side gate is unchanged and remains the only one. Reads are untouched, so a non-administrator keeps every list, detail, log and history view; the pages they are left with are read-only, which for some apps means a sparse view with no actions. Where the withheld control was a whole form — the ATW collect pane, Dipper's execution form, a snippet's execute accordion — a short notice takes its place rather than leaving blank space. Which sessions this affects depends on the auth provider: Casdoor defaults new users to non-administrator, and the Grafana provider grants administrator only from `isGrafanaAdmin` or an Admin org role. The UI still keys on the administrator flag alone, so a session holding a lesser role that the server would accept (an `editor` on Alert Templates' push and restore) is offered no control for it yet.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Copyright (C) 2026 Percona LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
import { createContext, useContext, useMemo } from 'react';
19+
import type { User } from './types/api';
20+
21+
/**
22+
* Session state owned by the shell's ``AuthProvider``.
23+
*
24+
* The context lives here, at the root of the frontend dependency graph, so the
25+
* framework and every app package can read it — they all depend on ``@sep/api``
26+
* and none of them may depend on ``@sep/shell``. The provider itself, and all
27+
* token/session bookkeeping, stays in the shell.
28+
*
29+
* One context carries both the session and the capability derived from it, so a
30+
* silent-refresh token rotation re-renders every capability consumer too. That
31+
* is a handful of controls every few minutes; splitting the capability into its
32+
* own context is the fix if it ever costs more than it saves.
33+
*/
34+
export interface AuthSession {
35+
user: User | null;
36+
accessToken: string | null;
37+
isAuthenticated: boolean;
38+
/**
39+
* Administrator identity. Read this only for genuinely admin-only surfaces
40+
* (the shell's Settings / Admin Apps pages and their query suppression). Per-app
41+
* write controls gate on {@link AuthState.canMutate} instead.
42+
*/
43+
isAdmin: boolean;
44+
/** true during initial session bootstrap & during login */
45+
loading: boolean;
46+
/** true after the initial session check finishes (success or failure) */
47+
ready: boolean;
48+
login: (username: string, password: string) => Promise<void>;
49+
logout: () => Promise<void>;
50+
}
51+
52+
/** {@link AuthSession} plus the capabilities derived from it. */
53+
export interface AuthState extends AuthSession {
54+
/**
55+
* Whether this session may mutate: the gate every per-app create / execute /
56+
* stop / retry / delete control reads.
57+
*
58+
* Semantically distinct from {@link AuthSession.isAdmin} even though it is
59+
* exactly that today. The server already resolves a minimum role per route
60+
* rather than one administrator flag, and ``User`` carries that role, so
61+
* widening the UI to match is an edit to {@link deriveCanMutate} —
62+
* per-control minimum roles — and to no call site.
63+
*/
64+
canMutate: boolean;
65+
}
66+
67+
/**
68+
* Single derivation of "may this session mutate?" from session state.
69+
*
70+
* Deliberately the administrator flag and nothing finer: most unsafe routes
71+
* require ``admin``, so keying on a lesser role here would put back the
72+
* controls that answer 403. Widening this to a per-control minimum role is the
73+
* follow-up that {@link AuthState.canMutate} describes.
74+
*/
75+
export function deriveCanMutate(session: AuthSession): boolean {
76+
return session.isAdmin;
77+
}
78+
79+
/**
80+
* Resolved state for a consumer rendered outside an ``AuthProvider``: signed
81+
* out, non-admin, and therefore unable to mutate. Tests and Storybook renders
82+
* mount framework/app components without the shell's provider, so a missing
83+
* provider must degrade to the least-privileged state rather than throw.
84+
*/
85+
export const UNAUTHENTICATED_SESSION: AuthSession = Object.freeze({
86+
user: null,
87+
accessToken: null,
88+
isAuthenticated: false,
89+
isAdmin: false,
90+
loading: false,
91+
ready: false,
92+
login: async () => {},
93+
logout: async () => {},
94+
});
95+
96+
/**
97+
* Session state for a signed-in administrator: the mirror of
98+
* {@link UNAUTHENTICATED_SESSION}, and the fixture every "an admin still sees
99+
* this control" render needs.
100+
*
101+
* A test fixture living in shipped code, deliberately. It belongs beside the
102+
* constant it mirrors, and the alternative — a ``@sep/test-utils`` export —
103+
* would drag ``@sep/api`` into every package's vitest setup file, where the
104+
* eagerly-loaded real module defeats ``vi.mock('@sep/api')`` in suites that
105+
* have nothing to do with auth.
106+
*
107+
* Do not hand this to ``AuthContext`` in application code: the shell's
108+
* ``AuthProvider`` owns the real session, and a hardcoded admin one only
109+
* unlocks controls the API still refuses.
110+
*/
111+
export const ADMIN_SESSION: AuthSession = Object.freeze({
112+
...UNAUTHENTICATED_SESSION,
113+
isAuthenticated: true,
114+
isAdmin: true,
115+
ready: true,
116+
});
117+
118+
export const AuthContext = createContext<AuthSession | null>(null);
119+
120+
let warnedMissingProvider = false;
121+
122+
/**
123+
* Warn once per bundle when the provider is missing. Hiding controls is a
124+
* quieter failure than the throw this replaced, so a stray consumer mounted
125+
* outside the provider would otherwise silently look like a non-admin session.
126+
*/
127+
function warnMissingProvider(): void {
128+
if (warnedMissingProvider || !import.meta.env?.DEV) {
129+
return;
130+
}
131+
warnedMissingProvider = true;
132+
// eslint-disable-next-line no-console -- surface a silently degraded session in dev
133+
console.warn(
134+
'useAuth() was called outside an AuthProvider — falling back to a signed-out, ' +
135+
'non-admin session. Mutation controls will be hidden.',
136+
);
137+
}
138+
139+
/**
140+
* Read the current session and its derived capabilities.
141+
*
142+
* Resolves to {@link UNAUTHENTICATED_SESSION} when no provider is mounted.
143+
*/
144+
export function useAuth(): AuthState {
145+
const session = useContext(AuthContext);
146+
if (!session) {
147+
warnMissingProvider();
148+
}
149+
const resolved = session ?? UNAUTHENTICATED_SESSION;
150+
return useMemo(() => ({ ...resolved, canMutate: deriveCanMutate(resolved) }), [resolved]);
151+
}

frontend/packages/api/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ export { createQueryClient, defaultQueryClientConfig } from './queryClient';
3737
export { ApiError, normalizeAxiosError, parseFieldErrors } from './errors';
3838
export type { ApiErrorDetails, ApiErrorKind, FieldValidationError } from './errors';
3939

40+
// Auth context (provider lives in @sep/shell; the context lives here so the
41+
// framework and app packages can read it without depending on the shell)
42+
export {
43+
ADMIN_SESSION,
44+
AuthContext,
45+
UNAUTHENTICATED_SESSION,
46+
deriveCanMutate,
47+
useAuth,
48+
} from './auth-context';
49+
export type { AuthSession, AuthState } from './auth-context';
50+
4051
// Auth
4152
export {
4253
postLogin,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Copyright (C) 2026 Percona LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
import { describe, expect, it } from 'vitest';
19+
import {
20+
ADMIN_SESSION,
21+
UNAUTHENTICATED_SESSION,
22+
deriveCanMutate,
23+
type AuthSession,
24+
} from '../src/auth-context';
25+
26+
function session(overrides: Partial<AuthSession> = {}): AuthSession {
27+
return { ...UNAUTHENTICATED_SESSION, ...overrides };
28+
}
29+
30+
describe('deriveCanMutate', () => {
31+
it('grants mutation to an administrator', () => {
32+
expect(deriveCanMutate(session({ isAdmin: true, isAuthenticated: true }))).toBe(true);
33+
});
34+
35+
it('withholds mutation from an authenticated non-administrator', () => {
36+
expect(deriveCanMutate(session({ isAdmin: false, isAuthenticated: true }))).toBe(false);
37+
});
38+
39+
it('withholds mutation from a signed-out session', () => {
40+
expect(deriveCanMutate(UNAUTHENTICATED_SESSION)).toBe(false);
41+
});
42+
43+
it('keys on the administrator flag alone, not on authentication', () => {
44+
// The server resolves a minimum role per route; the UI deliberately keys on
45+
// the one flag, so widening it stays a single-function change.
46+
expect(deriveCanMutate(session({ isAdmin: true, isAuthenticated: false }))).toBe(true);
47+
});
48+
});
49+
50+
describe('UNAUTHENTICATED_SESSION', () => {
51+
it('is the least-privileged session a missing provider can resolve to', () => {
52+
expect(UNAUTHENTICATED_SESSION.isAdmin).toBe(false);
53+
expect(UNAUTHENTICATED_SESSION.isAuthenticated).toBe(false);
54+
expect(UNAUTHENTICATED_SESSION.user).toBeNull();
55+
expect(UNAUTHENTICATED_SESSION.accessToken).toBeNull();
56+
});
57+
58+
it('is frozen, so a consumer cannot escalate the shared fallback', () => {
59+
expect(Object.isFrozen(UNAUTHENTICATED_SESSION)).toBe(true);
60+
expect(() => {
61+
(UNAUTHENTICATED_SESSION as { isAdmin: boolean }).isAdmin = true;
62+
}).toThrow();
63+
expect(UNAUTHENTICATED_SESSION.isAdmin).toBe(false);
64+
});
65+
66+
it('resolves its no-op login and logout without throwing', async () => {
67+
await expect(UNAUTHENTICATED_SESSION.login('u', 'p')).resolves.toBeUndefined();
68+
await expect(UNAUTHENTICATED_SESSION.logout()).resolves.toBeUndefined();
69+
});
70+
});
71+
72+
describe('ADMIN_SESSION', () => {
73+
it('is a session that may mutate, so an "admin still sees it" render is a real admin', () => {
74+
expect(ADMIN_SESSION.isAdmin).toBe(true);
75+
expect(ADMIN_SESSION.isAuthenticated).toBe(true);
76+
expect(ADMIN_SESSION.ready).toBe(true);
77+
expect(deriveCanMutate(ADMIN_SESSION)).toBe(true);
78+
});
79+
80+
it('is frozen, so one test cannot mutate the fixture the next one reads', () => {
81+
expect(Object.isFrozen(ADMIN_SESSION)).toBe(true);
82+
expect(() => {
83+
(ADMIN_SESSION as { isAdmin: boolean }).isAdmin = false;
84+
}).toThrow();
85+
expect(ADMIN_SESSION.isAdmin).toBe(true);
86+
});
87+
88+
it('leaves the shared fallback alone', () => {
89+
// Built by spreading UNAUTHENTICATED_SESSION: a spread that mutated its
90+
// source would hand every provider-less consumer an administrator.
91+
expect(UNAUTHENTICATED_SESSION.isAdmin).toBe(false);
92+
});
93+
});

frontend/packages/apps/alerts/src/AlertsListPage.tsx

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
3636
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
3737
import SettingsIcon from '@mui/icons-material/Settings';
38+
import { useAuth } from '@sep/api';
3839
import { useAlertBackups, useAlertsIndex } from './hooks';
3940
import { AlertsWizard } from './AlertsWizard';
4041
import type { AlertTemplate, WizardMode } from './types';
@@ -54,6 +55,7 @@ const SEVERITY_COLORS: Record<string, 'default' | 'info' | 'warning' | 'error'>
5455
* AlertsWizard dialog.
5556
*/
5657
export function AlertsListPage() {
58+
const { canMutate } = useAuth();
5759
const { data, isLoading, error } = useAlertsIndex();
5860

5961
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -133,30 +135,34 @@ export function AlertsListPage() {
133135
PMM not connected
134136
</Alert>
135137
)}
136-
<Button
137-
variant="contained"
138-
startIcon={<NotificationsActiveIcon />}
139-
disabled={selected.size === 0 || !data?.pmm_connected}
140-
onClick={() => openWizard('push')}
141-
>
142-
Push Selected ({selected.size})
143-
</Button>
144-
<Button
145-
variant="outlined"
146-
onClick={() => openWizard('restore')}
147-
disabled={(data?.recent_backups ?? []).length === 0}
148-
>
149-
Restore from Backup
150-
</Button>
151-
<Button
152-
variant="outlined"
153-
startIcon={<SettingsIcon />}
154-
onClick={() => openWizard('pagerduty')}
155-
disabled={!data?.pmm_connected}
156-
color={pagerdutyConfigured ? 'success' : 'primary'}
157-
>
158-
{pagerdutyConfigured ? 'PagerDuty Configured ✓' : 'Configure PagerDuty'}
159-
</Button>
138+
{canMutate && (
139+
<>
140+
<Button
141+
variant="contained"
142+
startIcon={<NotificationsActiveIcon />}
143+
disabled={selected.size === 0 || !data?.pmm_connected}
144+
onClick={() => openWizard('push')}
145+
>
146+
Push Selected ({selected.size})
147+
</Button>
148+
<Button
149+
variant="outlined"
150+
onClick={() => openWizard('restore')}
151+
disabled={(data?.recent_backups ?? []).length === 0}
152+
>
153+
Restore from Backup
154+
</Button>
155+
<Button
156+
variant="outlined"
157+
startIcon={<SettingsIcon />}
158+
onClick={() => openWizard('pagerduty')}
159+
disabled={!data?.pmm_connected}
160+
color={pagerdutyConfigured ? 'success' : 'primary'}
161+
>
162+
{pagerdutyConfigured ? 'PagerDuty Configured ✓' : 'Configure PagerDuty'}
163+
</Button>
164+
</>
165+
)}
160166
</Box>
161167
</Box>
162168

@@ -173,16 +179,19 @@ export function AlertsListPage() {
173179
<Accordion key={group.service_type} disableGutters sx={{ mb: 1 }}>
174180
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
175181
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
176-
<Checkbox
177-
checked={allChecked}
178-
indeterminate={someChecked && !allChecked}
179-
onClick={(e) => {
180-
e.stopPropagation();
181-
toggleGroup(groupNames);
182-
}}
183-
size="small"
184-
inputProps={{ 'aria-label': `Select all ${group.label} templates` }}
185-
/>
182+
{/* Selection exists only to feed Push Selected, so both go together. */}
183+
{canMutate && (
184+
<Checkbox
185+
checked={allChecked}
186+
indeterminate={someChecked && !allChecked}
187+
onClick={(e) => {
188+
e.stopPropagation();
189+
toggleGroup(groupNames);
190+
}}
191+
size="small"
192+
inputProps={{ 'aria-label': `Select all ${group.label} templates` }}
193+
/>
194+
)}
186195
<Typography variant="subtitle1" fontWeight={500}>
187196
{group.label} ({group.templates.length})
188197
</Typography>
@@ -205,12 +214,14 @@ export function AlertsListPage() {
205214
py: 0.5,
206215
}}
207216
>
208-
<Checkbox
209-
checked={selected.has(template.name)}
210-
onChange={() => toggleTemplate(template.name)}
211-
size="small"
212-
inputProps={{ 'aria-label': template.name }}
213-
/>
217+
{canMutate && (
218+
<Checkbox
219+
checked={selected.has(template.name)}
220+
onChange={() => toggleTemplate(template.name)}
221+
size="small"
222+
inputProps={{ 'aria-label': template.name }}
223+
/>
224+
)}
214225
<Box sx={{ flex: 1, minWidth: 0 }}>
215226
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
216227
<Typography variant="body2" fontWeight={500}>

0 commit comments

Comments
 (0)