From ffa824df0fa767221e79a419db4c61397bf48506 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 28 Jul 2026 10:49:38 -0400 Subject: [PATCH 1/4] feat: support deleting slides design systems --- .../.agents/skills/design-systems/SKILL.md | 8 +++ .../slides/actions/delete-design-system.ts | 36 ++++++++++++ .../slides/actions/list-design-systems.ts | 30 +++++++++- .../design-system/DesignSystemCard.tsx | 44 +++++++++++++- .../slides/app/hooks/use-design-systems.ts | 2 + templates/slides/app/i18n/ar-SA.ts | 7 +++ templates/slides/app/i18n/de-DE.ts | 7 +++ templates/slides/app/i18n/en-US.ts | 7 +++ templates/slides/app/i18n/es-ES.ts | 7 +++ templates/slides/app/i18n/fr-FR.ts | 7 +++ templates/slides/app/i18n/hi-IN.ts | 7 +++ templates/slides/app/i18n/ja-JP.ts | 7 +++ templates/slides/app/i18n/ko-KR.ts | 7 +++ templates/slides/app/i18n/pt-BR.ts | 7 +++ templates/slides/app/i18n/zh-CN.ts | 7 +++ templates/slides/app/i18n/zh-TW.ts | 7 +++ templates/slides/app/pages/DesignSystems.tsx | 57 ++++++++++++++++++- ...m-you-own-from-the-design-systems-page-.md | 6 ++ 18 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 templates/slides/actions/delete-design-system.ts create mode 100644 templates/slides/changelog/2026-07-28-delete-a-design-system-you-own-from-the-design-systems-page-.md diff --git a/templates/slides/.agents/skills/design-systems/SKILL.md b/templates/slides/.agents/skills/design-systems/SKILL.md index 3930b898c6..d4b68d4852 100644 --- a/templates/slides/.agents/skills/design-systems/SKILL.md +++ b/templates/slides/.agents/skills/design-systems/SKILL.md @@ -40,6 +40,14 @@ Do not call `create-design-system` locally from `.fig` uploads. Do not call `import-document` for `.fig` files; it only handles metadata and will miss the Builder indexing flow. +## Deleting a Design System + +`delete-design-system` requires owner access and removes the system, its +shares, and the `designSystemId` link on every deck that used it. Those decks +keep the tokens already baked into their slides — deletion never rewrites deck +content — so a deck can look on-brand while no longer being linked to a system. +Deletion does not remove an upstream Builder-indexed design system. + ## Applying to Slides Before creating or extending a system, read the `creative-context` skill and diff --git a/templates/slides/actions/delete-design-system.ts b/templates/slides/actions/delete-design-system.ts new file mode 100644 index 0000000000..c349cf1337 --- /dev/null +++ b/templates/slides/actions/delete-design-system.ts @@ -0,0 +1,36 @@ +import { defineAction } from "@agent-native/core"; +import { assertAccess } from "@agent-native/core/sharing"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; + +export default defineAction({ + description: + "Delete a design system. Requires owner access. Decks linked to it are unlinked.", + schema: z.object({ + id: z.string().min(1).describe("Design system ID to delete"), + }), + run: async ({ id }) => { + await assertAccess("design-system", id, "owner"); + + const db = getDb(); + + await db.transaction(async (tx) => { + await tx + .update(schema.decks) + .set({ designSystemId: null, updatedAt: new Date().toISOString() }) + .where(eq(schema.decks.designSystemId, id)); + + await tx + .delete(schema.designSystemShares) + .where(eq(schema.designSystemShares.resourceId, id)); + + await tx + .delete(schema.designSystems) + .where(eq(schema.designSystems.id, id)); + }); + + return { id, deleted: true }; + }, +}); diff --git a/templates/slides/actions/list-design-systems.ts b/templates/slides/actions/list-design-systems.ts index da375243ea..4521b1d436 100644 --- a/templates/slides/actions/list-design-systems.ts +++ b/templates/slides/actions/list-design-systems.ts @@ -1,10 +1,18 @@ import { defineAction } from "@agent-native/core"; -import { accessFilter } from "@agent-native/core/sharing"; +import { + accessFilter, + resolveAccess, + type ShareRole, +} from "@agent-native/core/sharing"; import { desc } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +function canManageRole(role: "owner" | ShareRole) { + return role === "owner" || role === "admin"; +} + export default defineAction({ description: "List all design systems accessible to the current user. " + @@ -41,12 +49,30 @@ export default defineAction({ return { count: 0, designSystems: [] }; } + const accessById = new Map< + string, + { role: "owner" | ShareRole; canManage: boolean } + >(); + await Promise.all( + rows.map(async (row) => { + const access = await resolveAccess("design-system", row.id); + const role = access?.role ?? "viewer"; + accessById.set(row.id, { role, canManage: canManageRole(role) }); + }), + ); + const items = rows.map((row) => { + const access = accessById.get(row.id) ?? { + role: "viewer" as const, + canManage: false, + }; if (args.compact === "true") { return { id: row.id, title: row.title, isDefault: row.isDefault, + accessRole: access.role, + canManage: access.canManage, }; } return { @@ -56,6 +82,8 @@ export default defineAction({ data: row.data, isDefault: row.isDefault, visibility: row.visibility, + accessRole: access.role, + canManage: access.canManage, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/templates/slides/app/components/design-system/DesignSystemCard.tsx b/templates/slides/app/components/design-system/DesignSystemCard.tsx index 2cef7709df..7bcb8ed164 100644 --- a/templates/slides/app/components/design-system/DesignSystemCard.tsx +++ b/templates/slides/app/components/design-system/DesignSystemCard.tsx @@ -1,7 +1,21 @@ +import { useT } from "@agent-native/core/client/i18n"; import { ShareButton } from "@agent-native/core/client/sharing"; import { VisibilityBadge } from "@agent-native/toolkit/sharing"; -import { IconPalette, IconStar, IconStarFilled } from "@tabler/icons-react"; +import { + IconDots, + IconPalette, + IconStar, + IconStarFilled, + IconTrash, +} from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, @@ -16,8 +30,10 @@ interface DesignSystemCardProps { data: DesignSystemData; isDefault: boolean; visibility?: "private" | "org" | "public" | null; + canManage?: boolean; onClick: () => void; onSetDefault: () => void; + onDelete: () => void; } function firstFontName(stack: string): string { @@ -30,9 +46,12 @@ export function DesignSystemCard({ data, isDefault, visibility, + canManage, onClick, onSetDefault, + onDelete, }: DesignSystemCardProps) { + const t = useT(); const swatchColors = [ { label: "Primary", color: data.colors.primary }, { label: "Secondary", color: data.colors.secondary }, @@ -93,6 +112,29 @@ export function DesignSystemCard({ resourceId={id} resourceTitle={title} /> + {canManage && ( + + + + + + + + {t("designSystems.delete")} + + + + )} {/* Typography preview */} diff --git a/templates/slides/app/hooks/use-design-systems.ts b/templates/slides/app/hooks/use-design-systems.ts index 64c134bcc4..1ebb7b5632 100644 --- a/templates/slides/app/hooks/use-design-systems.ts +++ b/templates/slides/app/hooks/use-design-systems.ts @@ -7,6 +7,8 @@ type DesignSystemSummary = { data: string; isDefault: boolean; visibility?: "private" | "org" | "public" | null; + accessRole?: "owner" | "admin" | "editor" | "viewer"; + canManage?: boolean; createdAt: string; }; diff --git a/templates/slides/app/i18n/ar-SA.ts b/templates/slides/app/i18n/ar-SA.ts index 847b306788..e684e7a8a5 100644 --- a/templates/slides/app/i18n/ar-SA.ts +++ b/templates/slides/app/i18n/ar-SA.ts @@ -161,6 +161,13 @@ const messages = { designSystems: { new: "نظام تصميم جديد", setupBrand: "إعداد علامتك التجارية", + delete: "حذف", + cancel: "إلغاء", + moreActions: "إجراءات إضافية", + deleteDialogTitle: "حذف نظام التصميم؟", + deleteDialogDescription: + "سيؤدي هذا إلى حذف نظام التصميم نهائيًا. تحتفظ العروض التي تستخدمه بمظهرها الحالي لكنها لن تبقى مرتبطة به.", + deleteError: "تعذّر حذف نظام التصميم", emptyTitle: "إعداد هوية علامتك التجارية", emptyDescription: "أنشئ نظام تصميم بألوان علامتك وخطوطها وشعاراتها. سيتبع كل عرض جديد هويتك البصرية.", diff --git a/templates/slides/app/i18n/de-DE.ts b/templates/slides/app/i18n/de-DE.ts index 4ceafa2143..9e7091113f 100644 --- a/templates/slides/app/i18n/de-DE.ts +++ b/templates/slides/app/i18n/de-DE.ts @@ -165,6 +165,13 @@ const messages = { designSystems: { new: "Neues Designsystem", setupBrand: "Marke einrichten", + delete: "Löschen", + cancel: "Abbrechen", + moreActions: "Weitere Aktionen", + deleteDialogTitle: "Designsystem löschen?", + deleteDialogDescription: + "Das Designsystem wird dauerhaft gelöscht. Decks, die es verwenden, behalten ihr aktuelles Aussehen, sind aber nicht mehr damit verknüpft.", + deleteError: "Designsystem konnte nicht gelöscht werden", emptyTitle: "Markenidentität einrichten", emptyDescription: "Erstelle ein Designsystem mit Markenfarben, Typografie und Logos. Jedes neue Deck folgt deiner visuellen Identität.", diff --git a/templates/slides/app/i18n/en-US.ts b/templates/slides/app/i18n/en-US.ts index 32a65f2ed1..354b1c7e19 100644 --- a/templates/slides/app/i18n/en-US.ts +++ b/templates/slides/app/i18n/en-US.ts @@ -162,6 +162,13 @@ const messages = { designSystems: { new: "New Design System", setupBrand: "Set up your brand", + delete: "Delete", + cancel: "Cancel", + moreActions: "More actions", + deleteDialogTitle: "Delete design system?", + deleteDialogDescription: + "This permanently deletes the design system. Decks using it keep their current look but are no longer linked to it.", + deleteError: "Failed to delete design system", emptyTitle: "Set up your brand identity", emptyDescription: "Create a design system with your brand colors, typography, and logos. Every new deck will follow your visual identity.", diff --git a/templates/slides/app/i18n/es-ES.ts b/templates/slides/app/i18n/es-ES.ts index 99799202da..3fe539671e 100644 --- a/templates/slides/app/i18n/es-ES.ts +++ b/templates/slides/app/i18n/es-ES.ts @@ -164,6 +164,13 @@ const messages = { designSystems: { new: "Nuevo sistema de diseño", setupBrand: "Configura tu marca", + delete: "Eliminar", + cancel: "Cancelar", + moreActions: "Más acciones", + deleteDialogTitle: "¿Eliminar el sistema de diseño?", + deleteDialogDescription: + "Esto elimina el sistema de diseño de forma permanente. Las presentaciones que lo usan mantienen su aspecto actual, pero dejan de estar vinculadas a él.", + deleteError: "No se pudo eliminar el sistema de diseño", emptyTitle: "Configura la identidad de tu marca", emptyDescription: "Crea un sistema de diseño con los colores, la tipografía y los logotipos de tu marca. Cada nueva presentación seguirá tu identidad visual.", diff --git a/templates/slides/app/i18n/fr-FR.ts b/templates/slides/app/i18n/fr-FR.ts index 5dcb48c105..e7451c5bc7 100644 --- a/templates/slides/app/i18n/fr-FR.ts +++ b/templates/slides/app/i18n/fr-FR.ts @@ -165,6 +165,13 @@ const messages = { designSystems: { new: "Nouveau système de design", setupBrand: "Configurer votre marque", + delete: "Supprimer", + cancel: "Annuler", + moreActions: "Plus d'actions", + deleteDialogTitle: "Supprimer le système de design ?", + deleteDialogDescription: + "Le système de design est supprimé définitivement. Les présentations qui l'utilisent conservent leur apparence actuelle mais n'y sont plus liées.", + deleteError: "Échec de la suppression du système de design", emptyTitle: "Configurer votre identité de marque", emptyDescription: "Créez un système de design avec les couleurs, la typographie et les logos de votre marque. Chaque nouvelle présentation suivra votre identité visuelle.", diff --git a/templates/slides/app/i18n/hi-IN.ts b/templates/slides/app/i18n/hi-IN.ts index a94fdc9254..26cb624cb7 100644 --- a/templates/slides/app/i18n/hi-IN.ts +++ b/templates/slides/app/i18n/hi-IN.ts @@ -161,6 +161,13 @@ const messages = { designSystems: { new: "नया डिज़ाइन सिस्टम", setupBrand: "अपना ब्रांड सेट करें", + delete: "हटाएं", + cancel: "रद्द करें", + moreActions: "अधिक क्रियाएं", + deleteDialogTitle: "डिज़ाइन सिस्टम हटाएं?", + deleteDialogDescription: + "इससे डिज़ाइन सिस्टम स्थायी रूप से हट जाएगा। इसका उपयोग करने वाले डेक अपना मौजूदा रूप बनाए रखेंगे, लेकिन उनसे लिंक नहीं रहेंगे।", + deleteError: "डिज़ाइन सिस्टम हटाने में विफल", emptyTitle: "अपनी ब्रांड पहचान सेट करें", emptyDescription: "अपने ब्रांड रंगों, टाइपोग्राफी और लोगो के साथ डिज़ाइन सिस्टम बनाएं। हर नया डेक आपकी विज़ुअल पहचान का पालन करेगा।", diff --git a/templates/slides/app/i18n/ja-JP.ts b/templates/slides/app/i18n/ja-JP.ts index 01b5c11cc0..5cc937355e 100644 --- a/templates/slides/app/i18n/ja-JP.ts +++ b/templates/slides/app/i18n/ja-JP.ts @@ -162,6 +162,13 @@ const messages = { designSystems: { new: "新しいデザインシステム", setupBrand: "ブランドを設定", + delete: "削除", + cancel: "キャンセル", + moreActions: "その他の操作", + deleteDialogTitle: "デザインシステムを削除しますか?", + deleteDialogDescription: + "デザインシステムを完全に削除します。使用中のデッキは現在の見た目を保ちますが、リンクは解除されます。", + deleteError: "デザインシステムを削除できませんでした", emptyTitle: "ブランドアイデンティティを設定", emptyDescription: "ブランドカラー、タイポグラフィ、ロゴを使ってデザインシステムを作成します。新しいデッキはすべてそのビジュアルアイデンティティに従います。", diff --git a/templates/slides/app/i18n/ko-KR.ts b/templates/slides/app/i18n/ko-KR.ts index 23d3b4f2fb..4a432ad5fc 100644 --- a/templates/slides/app/i18n/ko-KR.ts +++ b/templates/slides/app/i18n/ko-KR.ts @@ -162,6 +162,13 @@ const messages = { designSystems: { new: "새 디자인 시스템", setupBrand: "브랜드 설정", + delete: "삭제", + cancel: "취소", + moreActions: "추가 작업", + deleteDialogTitle: "디자인 시스템을 삭제할까요?", + deleteDialogDescription: + "디자인 시스템이 영구적으로 삭제됩니다. 이를 사용하는 덱은 현재 모양을 유지하지만 더 이상 연결되지 않습니다.", + deleteError: "디자인 시스템을 삭제하지 못했습니다", emptyTitle: "브랜드 아이덴티티 설정", emptyDescription: "브랜드 색상, 타이포그래피, 로고로 디자인 시스템을 만드세요. 새 덱은 모두 이 시각적 정체성을 따릅니다.", diff --git a/templates/slides/app/i18n/pt-BR.ts b/templates/slides/app/i18n/pt-BR.ts index 983a613ed4..f272a590b9 100644 --- a/templates/slides/app/i18n/pt-BR.ts +++ b/templates/slides/app/i18n/pt-BR.ts @@ -162,6 +162,13 @@ const messages = { designSystems: { new: "Novo sistema de design", setupBrand: "Configure sua marca", + delete: "Excluir", + cancel: "Cancelar", + moreActions: "Mais ações", + deleteDialogTitle: "Excluir sistema de design?", + deleteDialogDescription: + "Isso exclui o sistema de design permanentemente. Os decks que o usam mantêm a aparência atual, mas deixam de estar vinculados a ele.", + deleteError: "Falha ao excluir o sistema de design", emptyTitle: "Configure sua identidade de marca", emptyDescription: "Crie um sistema de design com as cores, tipografia e logotipos da sua marca. Cada novo deck seguirá sua identidade visual.", diff --git a/templates/slides/app/i18n/zh-CN.ts b/templates/slides/app/i18n/zh-CN.ts index ae800c8a8f..09909dee0d 100644 --- a/templates/slides/app/i18n/zh-CN.ts +++ b/templates/slides/app/i18n/zh-CN.ts @@ -159,6 +159,13 @@ const messages = { designSystems: { new: "新建设计系统", setupBrand: "设置你的品牌", + delete: "删除", + cancel: "取消", + moreActions: "更多操作", + deleteDialogTitle: "删除设计系统?", + deleteDialogDescription: + "此操作将永久删除该设计系统。使用它的演示文稿会保留当前外观,但不再与其关联。", + deleteError: "删除设计系统失败", emptyTitle: "设置你的品牌标识", emptyDescription: "使用你的品牌颜色、字体和徽标创建设计系统。每个新演示文稿都会遵循你的视觉标识。", diff --git a/templates/slides/app/i18n/zh-TW.ts b/templates/slides/app/i18n/zh-TW.ts index 08687b3a9d..b356630cd2 100644 --- a/templates/slides/app/i18n/zh-TW.ts +++ b/templates/slides/app/i18n/zh-TW.ts @@ -154,6 +154,13 @@ const messages = { designSystems: { new: "新建設計系統", setupBrand: "設定你的品牌", + delete: "刪除", + cancel: "取消", + moreActions: "更多操作", + deleteDialogTitle: "刪除設計系統?", + deleteDialogDescription: + "此操作會永久刪除該設計系統。使用它的簡報會保留目前外觀,但不再與其連結。", + deleteError: "刪除設計系統失敗", emptyTitle: "設定你的品牌識別", emptyDescription: "使用你的品牌顏色、字型和徽標建立設計系統。每個新簡報都會遵循你的視覺識別。", diff --git a/templates/slides/app/pages/DesignSystems.tsx b/templates/slides/app/pages/DesignSystems.tsx index 3ea9b6a526..6451c33a62 100644 --- a/templates/slides/app/pages/DesignSystems.tsx +++ b/templates/slides/app/pages/DesignSystems.tsx @@ -1,4 +1,4 @@ -import { callAction } from "@agent-native/core/client/hooks"; +import { callAction, useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useSetHeaderActions, @@ -11,9 +11,20 @@ import { IconRefresh, } from "@tabler/icons-react"; import { useMemo, useState } from "react"; +import { toast } from "sonner"; import { DesignSystemCard } from "@/components/design-system/DesignSystemCard"; import { DesignSystemSetup } from "@/components/design-system/DesignSystemSetup"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { useDesignSystems } from "@/hooks/use-design-systems"; @@ -25,6 +36,8 @@ export default function DesignSystems() { const { designSystems, isLoading, error, refetch } = useDesignSystems(); const [showSetup, setShowSetup] = useState(false); const [editingId, setEditingId] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const deleteMutation = useActionMutation("delete-design-system"); const handleCardClick = (id: string) => { setEditingId(id); @@ -51,6 +64,21 @@ export default function DesignSystems() { setEditingId(null); }; + const handleConfirmDelete = () => { + if (deleteId === null) return; + const id = deleteId; + setDeleteId(null); + deleteMutation.mutate({ id } as never, { + onSuccess: () => refetch(), + onError: (err: unknown) => { + void refetch(); + toast.error(t("designSystems.deleteError"), { + description: err instanceof Error ? err.message : undefined, + }); + }, + }); + }; + const parseDesignData = (dataStr: string): DesignSystemData | null => { try { const parsed = JSON.parse(dataStr) as DesignSystemData; @@ -172,8 +200,10 @@ export default function DesignSystems() { data={parsed} isDefault={ds.isDefault} visibility={ds.visibility} + canManage={ds.canManage} onClick={() => handleCardClick(ds.id)} onSetDefault={() => handleSetDefault(ds.id)} + onDelete={() => setDeleteId(ds.id)} /> ); })} @@ -182,6 +212,31 @@ export default function DesignSystems() { )} + !open && setDeleteId(null)} + > + + + + {t("designSystems.deleteDialogTitle")} + + + {t("designSystems.deleteDialogDescription")} + + + + {t("designSystems.cancel")} + + {t("designSystems.delete")} + + + + + {/* Setup/Edit Dialog */} Date: Tue, 28 Jul 2026 15:52:12 +0000 Subject: [PATCH 2/4] fix: address PR review feedback on design system deletion - Allow admin-role (not just owner) to delete a design system, matching the canManage gating already used by the UI's delete button - Clear designSystemId from decks' JSON data payload on delete, not just the SQL column, so list-decks doesn't fall back to a dangling reference - Replace per-row resolveAccess() calls in list-design-systems with a single batched shares query to avoid N+1 queries --- .../slides/actions/delete-design-system.ts | 24 ++++- .../slides/actions/list-design-systems.ts | 99 ++++++++++++++----- 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/templates/slides/actions/delete-design-system.ts b/templates/slides/actions/delete-design-system.ts index c349cf1337..11d34383c7 100644 --- a/templates/slides/actions/delete-design-system.ts +++ b/templates/slides/actions/delete-design-system.ts @@ -7,21 +7,35 @@ import { getDb, schema } from "../server/db/index.js"; export default defineAction({ description: - "Delete a design system. Requires owner access. Decks linked to it are unlinked.", + "Delete a design system. Requires admin access or higher. Decks linked to it are unlinked.", schema: z.object({ id: z.string().min(1).describe("Design system ID to delete"), }), run: async ({ id }) => { - await assertAccess("design-system", id, "owner"); + await assertAccess("design-system", id, "admin"); const db = getDb(); await db.transaction(async (tx) => { - await tx - .update(schema.decks) - .set({ designSystemId: null, updatedAt: new Date().toISOString() }) + const linkedDecks = await tx + .select({ id: schema.decks.id, data: schema.decks.data }) + .from(schema.decks) .where(eq(schema.decks.designSystemId, id)); + const now = new Date().toISOString(); + for (const deck of linkedDecks) { + const data = JSON.parse(deck.data); + if ("designSystemId" in data) delete data.designSystemId; + await tx + .update(schema.decks) + .set({ + designSystemId: null, + data: JSON.stringify(data), + updatedAt: now, + }) + .where(eq(schema.decks.id, deck.id)); + } + await tx .delete(schema.designSystemShares) .where(eq(schema.designSystemShares.resourceId, id)); diff --git a/templates/slides/actions/list-design-systems.ts b/templates/slides/actions/list-design-systems.ts index 4521b1d436..bac79646df 100644 --- a/templates/slides/actions/list-design-systems.ts +++ b/templates/slides/actions/list-design-systems.ts @@ -1,18 +1,29 @@ import { defineAction } from "@agent-native/core"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; import { accessFilter, - resolveAccess, + ROLE_RANK, type ShareRole, } from "@agent-native/core/sharing"; -import { desc } from "drizzle-orm"; +import { and, desc, eq, inArray, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -function canManageRole(role: "owner" | ShareRole) { +type EffectiveRole = "owner" | ShareRole; + +function canManageRole(role: EffectiveRole) { return role === "owner" || role === "admin"; } +function strongerRole(current: ShareRole | null, next: ShareRole): ShareRole { + if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next; + return current; +} + export default defineAction({ description: "List all design systems accessible to the current user. " + @@ -27,6 +38,8 @@ export default defineAction({ http: { method: "GET" }, run: async (args) => { const db = getDb(); + const userEmail = getRequestUserEmail(); + const orgId = getRequestOrgId(); // Project only the columns this list returns. The default path returns // `data`, but neither path returns the heavy `assets` blob — a bare // `.select()` would load it off every row for nothing. @@ -38,6 +51,8 @@ export default defineAction({ data: schema.designSystems.data, isDefault: schema.designSystems.isDefault, visibility: schema.designSystems.visibility, + ownerEmail: schema.designSystems.ownerEmail, + orgId: schema.designSystems.orgId, createdAt: schema.designSystems.createdAt, updatedAt: schema.designSystems.updatedAt, }) @@ -49,30 +64,70 @@ export default defineAction({ return { count: 0, designSystems: [] }; } - const accessById = new Map< - string, - { role: "owner" | ShareRole; canManage: boolean } - >(); - await Promise.all( - rows.map(async (row) => { - const access = await resolveAccess("design-system", row.id); - const role = access?.role ?? "viewer"; - accessById.set(row.id, { role, canManage: canManageRole(role) }); - }), - ); + // Resolve every row's role from a single batched shares query instead of + // calling resolveAccess() per row, which would re-load each resource and + // its shares (N+1) and fan out an unbounded Promise.all as the list grows. + const principalClauses: NonNullable>[] = []; + if (userEmail) { + principalClauses.push( + and( + eq(schema.designSystemShares.principalType, "user"), + eq(schema.designSystemShares.principalId, userEmail), + )!, + ); + } + if (orgId) { + principalClauses.push( + and( + eq(schema.designSystemShares.principalType, "org"), + eq(schema.designSystemShares.principalId, orgId), + )!, + ); + } + + const shareRoleById = new Map(); + if (principalClauses.length > 0) { + const shareRows = await db + .select({ + resourceId: schema.designSystemShares.resourceId, + role: schema.designSystemShares.role, + }) + .from(schema.designSystemShares) + .where( + and( + inArray( + schema.designSystemShares.resourceId, + rows.map((row) => row.id), + ), + or(...principalClauses), + ), + ); + for (const share of shareRows) { + shareRoleById.set( + share.resourceId, + strongerRole(shareRoleById.get(share.resourceId) ?? null, share.role), + ); + } + } const items = rows.map((row) => { - const access = accessById.get(row.id) ?? { - role: "viewer" as const, - canManage: false, - }; + let role: EffectiveRole = shareRoleById.get(row.id) ?? "viewer"; + if ( + userEmail && + row.ownerEmail === userEmail && + (!row.orgId || row.orgId === orgId) + ) { + role = "owner"; + } + const canManage = canManageRole(role); + if (args.compact === "true") { return { id: row.id, title: row.title, isDefault: row.isDefault, - accessRole: access.role, - canManage: access.canManage, + accessRole: role, + canManage, }; } return { @@ -82,8 +137,8 @@ export default defineAction({ data: row.data, isDefault: row.isDefault, visibility: row.visibility, - accessRole: access.role, - canManage: access.canManage, + accessRole: role, + canManage, createdAt: row.createdAt, updatedAt: row.updatedAt, }; From 7548d61b669452ac037099a3a10737b73870fbc2 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 28 Jul 2026 17:52:36 +0000 Subject: [PATCH 3/4] fix: address follow-up PR review feedback on design system deletion - Unlink each deck under its per-deck write lock (same lock as patch-deck/save-deck/update-slide) and re-read the row inside the lock, so this read-modify-write can't clobber a concurrent slide edit - Delete the design system row and its shares before unlinking decks, so a concurrent apply-design-system/create-deck call fails its access check as soon as the resource is gone, shrinking the race window for a fresh link to attach to a design system being deleted - Normalize email comparisons in list-design-systems's batched role query to match the core access model's case-insensitive email matching - Update the design-systems skill doc to say delete requires admin access or higher, matching the action --- .../.agents/skills/design-systems/SKILL.md | 5 +- .../slides/actions/delete-design-system.ts | 60 +++++++++++++------ .../slides/actions/list-design-systems.ts | 17 ++++-- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/templates/slides/.agents/skills/design-systems/SKILL.md b/templates/slides/.agents/skills/design-systems/SKILL.md index d4b68d4852..5bc2c9f511 100644 --- a/templates/slides/.agents/skills/design-systems/SKILL.md +++ b/templates/slides/.agents/skills/design-systems/SKILL.md @@ -42,8 +42,9 @@ Builder indexing flow. ## Deleting a Design System -`delete-design-system` requires owner access and removes the system, its -shares, and the `designSystemId` link on every deck that used it. Those decks +`delete-design-system` requires admin access or higher (owner or admin share +role) and removes the system, its shares, and the `designSystemId` link on +every deck that used it. Those decks keep the tokens already baked into their slides — deletion never rewrites deck content — so a deck can look on-brand while no longer being linked to a system. Deletion does not remove an upstream Builder-indexed design system. diff --git a/templates/slides/actions/delete-design-system.ts b/templates/slides/actions/delete-design-system.ts index 11d34383c7..95c41b1f17 100644 --- a/templates/slides/actions/delete-design-system.ts +++ b/templates/slides/actions/delete-design-system.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { withDeckLock } from "./patch-deck.js"; export default defineAction({ description: @@ -16,26 +17,19 @@ export default defineAction({ const db = getDb(); - await db.transaction(async (tx) => { - const linkedDecks = await tx - .select({ id: schema.decks.id, data: schema.decks.data }) + const linkedDeckIds = ( + await db + .select({ id: schema.decks.id }) .from(schema.decks) - .where(eq(schema.decks.designSystemId, id)); - - const now = new Date().toISOString(); - for (const deck of linkedDecks) { - const data = JSON.parse(deck.data); - if ("designSystemId" in data) delete data.designSystemId; - await tx - .update(schema.decks) - .set({ - designSystemId: null, - data: JSON.stringify(data), - updatedAt: now, - }) - .where(eq(schema.decks.id, deck.id)); - } + .where(eq(schema.decks.designSystemId, id)) + ).map((row) => row.id); + // Delete the design system (and its shares) before touching linked decks. + // Once the row is gone, apply-design-system/create-deck's + // assertAccess("design-system", ...) check fails for anyone trying to + // attach a fresh link, shrinking the window for a deck to end up pointing + // at a design system we're about to remove. + await db.transaction(async (tx) => { await tx .delete(schema.designSystemShares) .where(eq(schema.designSystemShares.resourceId, id)); @@ -45,6 +39,36 @@ export default defineAction({ .where(eq(schema.designSystems.id, id)); }); + // Unlink each deck under its per-deck lock — the same lock patch-deck, + // save-deck, and update-slide use for all deck writes — and re-read the + // row inside the lock so a concurrent slide edit can't be clobbered by + // this read-modify-write. + await Promise.all( + linkedDeckIds.map((deckId) => + withDeckLock(deckId, async () => { + const [deck] = await db + .select({ + designSystemId: schema.decks.designSystemId, + data: schema.decks.data, + }) + .from(schema.decks) + .where(eq(schema.decks.id, deckId)); + if (!deck || deck.designSystemId !== id) return; + + const data = JSON.parse(deck.data); + if ("designSystemId" in data) delete data.designSystemId; + await db + .update(schema.decks) + .set({ + designSystemId: null, + data: JSON.stringify(data), + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.decks.id, deckId)); + }), + ), + ); + return { id, deleted: true }; }, }); diff --git a/templates/slides/actions/list-design-systems.ts b/templates/slides/actions/list-design-systems.ts index bac79646df..d8806d6843 100644 --- a/templates/slides/actions/list-design-systems.ts +++ b/templates/slides/actions/list-design-systems.ts @@ -8,7 +8,7 @@ import { ROLE_RANK, type ShareRole, } from "@agent-native/core/sharing"; -import { and, desc, eq, inArray, or } from "drizzle-orm"; +import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -19,6 +19,15 @@ function canManageRole(role: EffectiveRole) { return role === "owner" || role === "admin"; } +// Mirrors the core access model (assertAccess/resolveAccess), which compares +// emails with `lower(column) = lowercased-input` so a share or ownership +// grant survives casing differences between the stored principal and the +// caller's session email. +function normalizeEmail(email: string | undefined): string | null { + const normalized = email?.trim().toLowerCase(); + return normalized || null; +} + function strongerRole(current: ShareRole | null, next: ShareRole): ShareRole { if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next; return current; @@ -38,7 +47,7 @@ export default defineAction({ http: { method: "GET" }, run: async (args) => { const db = getDb(); - const userEmail = getRequestUserEmail(); + const userEmail = normalizeEmail(getRequestUserEmail()); const orgId = getRequestOrgId(); // Project only the columns this list returns. The default path returns // `data`, but neither path returns the heavy `assets` blob — a bare @@ -72,7 +81,7 @@ export default defineAction({ principalClauses.push( and( eq(schema.designSystemShares.principalType, "user"), - eq(schema.designSystemShares.principalId, userEmail), + sql`lower(${schema.designSystemShares.principalId}) = ${userEmail}`, )!, ); } @@ -114,7 +123,7 @@ export default defineAction({ let role: EffectiveRole = shareRoleById.get(row.id) ?? "viewer"; if ( userEmail && - row.ownerEmail === userEmail && + normalizeEmail(row.ownerEmail) === userEmail && (!row.orgId || row.orgId === orgId) ) { role = "owner"; From 64fea905f05e0338c69c867d2c529e8c989c2078 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 29 Jul 2026 15:33:11 +0000 Subject: [PATCH 4/4] fix: scope design-system deletion cleanup to deck access and promote a new default - Check deck-level edit access before unlinking each deck from a deleted design system: admin access on a shared design system doesn't imply edit access on every deck that references it, so decks the caller can't edit are left with a dangling reference instead of being mutated - Report skipped (no access) and failed (write error) decks back in the action result instead of silently swallowing or throwing a generic error after the design system is already gone - Promote another of the owner's design systems to default in the same transaction when the deleted system was their default, so create-deck's automatic default lookup doesn't silently come up empty - Update the design-systems skill doc to describe the access-scoped unlink and default promotion --- .../.agents/skills/design-systems/SKILL.md | 14 +- .../slides/actions/delete-design-system.ts | 139 +++++++++++++----- 2 files changed, 115 insertions(+), 38 deletions(-) diff --git a/templates/slides/.agents/skills/design-systems/SKILL.md b/templates/slides/.agents/skills/design-systems/SKILL.md index 5bc2c9f511..4a62ce48b2 100644 --- a/templates/slides/.agents/skills/design-systems/SKILL.md +++ b/templates/slides/.agents/skills/design-systems/SKILL.md @@ -44,10 +44,16 @@ Builder indexing flow. `delete-design-system` requires admin access or higher (owner or admin share role) and removes the system, its shares, and the `designSystemId` link on -every deck that used it. Those decks -keep the tokens already baked into their slides — deletion never rewrites deck -content — so a deck can look on-brand while no longer being linked to a system. -Deletion does not remove an upstream Builder-indexed design system. +every linked deck the caller can edit — decks the caller can't edit keep a +dangling reference instead of being silently mutated, reported back as +`decksSkippedForAccess` (clear it later with `patch-deck`'s +`patch-deck-fields`, `designSystemId: null`). Those decks keep the tokens +already baked into their slides — deletion never rewrites deck content — so a +deck can look on-brand while no longer being linked to a system. If the +deleted system was the caller's default, another of their design systems is +promoted to default so future deck creation doesn't silently drop to "no +design system". Deletion does not remove an upstream Builder-indexed design +system. ## Applying to Slides diff --git a/templates/slides/actions/delete-design-system.ts b/templates/slides/actions/delete-design-system.ts index 95c41b1f17..6e503543fa 100644 --- a/templates/slides/actions/delete-design-system.ts +++ b/templates/slides/actions/delete-design-system.ts @@ -1,19 +1,76 @@ import { defineAction } from "@agent-native/core"; -import { assertAccess } from "@agent-native/core/sharing"; -import { eq } from "drizzle-orm"; +import { + assertAccess, + resolveAccess, + type ShareRole, +} from "@agent-native/core/sharing"; +import { desc, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { withDeckLock } from "./patch-deck.js"; +function canEditDeckRole(role: "owner" | ShareRole) { + return role === "owner" || role === "admin" || role === "editor"; +} + +type UnlinkResult = + | { deckId: string; status: "unlinked" } + | { deckId: string; status: "skipped-no-access" }; + +// Runs under the same per-deck lock patch-deck/save-deck/update-slide use for +// all deck writes, re-reading the row inside the lock so a concurrent slide +// edit can't be clobbered by this read-modify-write. Decks the caller can't +// edit are left pointing at the deleted id rather than silently mutated — +// admin access on a shared design system does not imply edit access on every +// deck that happens to reference it. +function unlinkDeck( + deckId: string, + designSystemId: string, +): Promise { + return withDeckLock(deckId, async () => { + const access = await resolveAccess("deck", deckId); + if (!access || !canEditDeckRole(access.role)) { + return { deckId, status: "skipped-no-access" }; + } + + const db = getDb(); + const [deck] = await db + .select({ + designSystemId: schema.decks.designSystemId, + data: schema.decks.data, + }) + .from(schema.decks) + .where(eq(schema.decks.id, deckId)); + if (!deck || deck.designSystemId !== designSystemId) { + return { deckId, status: "unlinked" }; + } + + const data = JSON.parse(deck.data); + if ("designSystemId" in data) delete data.designSystemId; + await db + .update(schema.decks) + .set({ + designSystemId: null, + data: JSON.stringify(data), + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.decks.id, deckId)); + return { deckId, status: "unlinked" }; + }); +} + export default defineAction({ description: - "Delete a design system. Requires admin access or higher. Decks linked to it are unlinked.", + "Delete a design system. Requires admin access or higher. Decks linked to it that " + + "the caller can edit are unlinked; others keep a dangling reference (clear it with " + + "patch-deck's patch-deck-fields, designSystemId: null). If the deleted system was the " + + "caller's default, another of their design systems is promoted to default.", schema: z.object({ id: z.string().min(1).describe("Design system ID to delete"), }), run: async ({ id }) => { - await assertAccess("design-system", id, "admin"); + const access = await assertAccess("design-system", id, "admin"); const db = getDb(); @@ -28,7 +85,10 @@ export default defineAction({ // Once the row is gone, apply-design-system/create-deck's // assertAccess("design-system", ...) check fails for anyone trying to // attach a fresh link, shrinking the window for a deck to end up pointing - // at a design system we're about to remove. + // at a design system we're about to remove. If this was the owner's + // default, promote another of their design systems in the same + // transaction so deck creation never silently drops to "no design + // system" just because the default happened to be deleted. await db.transaction(async (tx) => { await tx .delete(schema.designSystemShares) @@ -37,38 +97,49 @@ export default defineAction({ await tx .delete(schema.designSystems) .where(eq(schema.designSystems.id, id)); - }); - // Unlink each deck under its per-deck lock — the same lock patch-deck, - // save-deck, and update-slide use for all deck writes — and re-read the - // row inside the lock so a concurrent slide edit can't be clobbered by - // this read-modify-write. - await Promise.all( - linkedDeckIds.map((deckId) => - withDeckLock(deckId, async () => { - const [deck] = await db - .select({ - designSystemId: schema.decks.designSystemId, - data: schema.decks.data, - }) - .from(schema.decks) - .where(eq(schema.decks.id, deckId)); - if (!deck || deck.designSystemId !== id) return; + if (access.resource.isDefault) { + const [next] = await tx + .select({ id: schema.designSystems.id }) + .from(schema.designSystems) + .where( + eq(schema.designSystems.ownerEmail, access.resource.ownerEmail), + ) + .orderBy(desc(schema.designSystems.updatedAt)) + .limit(1); + if (next) { + await tx + .update(schema.designSystems) + .set({ isDefault: true, updatedAt: new Date().toISOString() }) + .where(eq(schema.designSystems.id, next.id)); + } + } + }); - const data = JSON.parse(deck.data); - if ("designSystemId" in data) delete data.designSystemId; - await db - .update(schema.decks) - .set({ - designSystemId: null, - data: JSON.stringify(data), - updatedAt: new Date().toISOString(), - }) - .where(eq(schema.decks.id, deckId)); - }), - ), + // Best-effort cleanup: the design system is already gone, so a deck we + // can't touch (missing access) or fail to update (write error) is left + // dangling rather than retried here. Both cases are reported back instead + // of swallowed, so the caller can decide whether to intervene. + const settled = await Promise.allSettled( + linkedDeckIds.map((deckId) => unlinkDeck(deckId, id)), ); - return { id, deleted: true }; + const decksSkippedForAccess: string[] = []; + const decksFailedToUnlink: string[] = []; + settled.forEach((result, index) => { + const deckId = linkedDeckIds[index]; + if (result.status === "rejected") { + decksFailedToUnlink.push(deckId); + } else if (result.value.status === "skipped-no-access") { + decksSkippedForAccess.push(deckId); + } + }); + + return { + id, + deleted: true, + ...(decksSkippedForAccess.length > 0 ? { decksSkippedForAccess } : {}), + ...(decksFailedToUnlink.length > 0 ? { decksFailedToUnlink } : {}), + }; }, });