diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index f3f8b5d34b6..9cdc4579856 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -548,6 +548,7 @@ const deleteUserController = async (req, res) => { await db.deleteAllUserMemories(user.id); await db.deleteUserPrompts(user.id); await db.deleteUserSkills(user.id); + await db.deleteUserArtifactApps(user.id); await deleteUserMcpServers(user.id); await db.deleteActions({ user: user.id }); await db.deleteTokens({ userId: user.id }); diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 34d932263d6..6be85cd9691 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -60,6 +60,7 @@ jest.mock('~/models', () => { cancelAgentTriggerUserDeletion: jest.fn().mockResolvedValue(true), deleteUserPrompts: jest.fn().mockResolvedValue(undefined), deleteUserSkills: jest.fn().mockResolvedValue(undefined), + deleteUserArtifactApps: jest.fn().mockResolvedValue(undefined), deleteUserCodeEnvironments: jest.fn().mockResolvedValue(undefined), deleteMessages: jest.fn().mockResolvedValue(undefined), deleteBalances: jest.fn().mockResolvedValue(undefined), diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index 15cb6b8ba9c..4cb799efc44 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -19,6 +19,7 @@ const mockDeleteToolCalls = jest.fn(); const mockDeleteUserAgents = jest.fn(); const mockDeleteUserPrompts = jest.fn(); const mockDeleteUserSkills = jest.fn(); +const mockDeleteUserArtifactApps = jest.fn(); const mockDeleteUserCodeEnvironments = jest.fn(); const mockInvalidateCodeEnvironmentConfigCache = jest.fn(); const mockGetCleanupBlockingJobIdsForUser = jest.fn(); @@ -106,6 +107,7 @@ jest.mock('~/models', () => ({ deleteUserAgents: (...args) => mockDeleteUserAgents(...args), deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args), deleteUserSkills: (...args) => mockDeleteUserSkills(...args), + deleteUserArtifactApps: (...args) => mockDeleteUserArtifactApps(...args), deleteUserCodeEnvironments: (...args) => mockDeleteUserCodeEnvironments(...args), deleteTransactions: jest.fn(), deleteBalances: jest.fn(), diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js index a0806d6e3cd..bd49f8b02f3 100644 --- a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js +++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js @@ -18,6 +18,7 @@ const HANDLED_RESOURCE_TYPES = { [ResourceType.MCPSERVER]: 'deleteUserMcpServers', [ResourceType.SKILL]: 'deleteUserSkills', [ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup', + [ResourceType.ARTIFACT_APP]: 'deleteUserArtifactApps', }; /** diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js index 53c36b7719e..1dba60e7b8d 100644 --- a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js +++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js @@ -147,6 +147,8 @@ jest.mock('~/models', () => ({ deleteToolCalls: jest.fn(), deleteUserAgents: jest.fn(), deleteUserPrompts: jest.fn(), + deleteUserSkills: jest.fn(), + deleteUserArtifactApps: jest.fn(), deleteTransactions: jest.fn(), deleteBalances: jest.fn(), deleteAllAgentApiKeys: jest.fn(), diff --git a/api/server/index.js b/api/server/index.js index 9d28fc7a17a..d104350de7d 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -426,6 +426,7 @@ const startServer = async () => { app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); + app.use('/api/artifact-apps', routes.artifactApps); app.use('/api/categories', routes.categories); app.use('/api/endpoints', routes.endpoints); app.use('/api/balance', routes.balance); diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.js new file mode 100644 index 00000000000..0df48493863 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.js @@ -0,0 +1,34 @@ +const { ResourceType } = require('librechat-data-provider'); +const { canAccessResource } = require('./canAccessResource'); +const { resolveArtifactAppId } = require('~/models'); + +/** + * Artifact-app-specific middleware factory that checks access permissions. + * Wraps the generic `canAccessResource` with the ARTIFACT_APP resource type and + * `resolveArtifactAppId` (custom `app_*` id → ObjectId) as the ID resolver. + * + * @param {Object} options + * @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @param {string} [options.resourceIdParam='id'] - Route parameter holding the artifact app id + * @returns {Function} Express middleware + */ +const canAccessArtifactAppResource = (options) => { + const { requiredPermission, resourceIdParam = 'id' } = options || {}; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error( + 'canAccessArtifactAppResource: requiredPermission is required and must be a number', + ); + } + + return canAccessResource({ + resourceType: ResourceType.ARTIFACT_APP, + requiredPermission, + resourceIdParam, + idResolver: (artifactAppId) => resolveArtifactAppId({ artifactAppId }), + }); +}; + +module.exports = { + canAccessArtifactAppResource, +}; diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js new file mode 100644 index 00000000000..756bb35586a --- /dev/null +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js @@ -0,0 +1,203 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); +const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource'); +const { User, Role, AclEntry } = require('~/db/models'); +const { createArtifactAppWithVersion } = require('~/models'); + +const VIEW = 1; +const EDIT = 2; +const DELETE = 4; + +/** Owner ACL bits (view+edit+delete+share), matching what publish grants. */ +const OWNER_BITS = 15; + +describe('canAccessArtifactAppResource middleware', () => { + let mongoServer; + let req, res, next; + let testUser; + + const publishApp = async (createdBy) => + createArtifactAppWithVersion({ + title: 'Test App', + visibility: 'private', + createdBy: String(createdBy), + version: { + artifactType: 'react', + sourceSnapshot: 'export default () =>
hi
;', + createdBy: String(createdBy), + }, + }); + + const grant = async ({ principalId, resourceId, permBits, grantedBy }) => + AclEntry.create({ + principalType: PrincipalType.USER, + principalId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.ARTIFACT_APP, + resourceId, + permBits, + grantedBy: grantedBy ?? principalId, + }); + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + await Role.create({ name: 'test-role', permissions: {} }); + testUser = await User.create({ + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + role: 'test-role', + }); + + req = { user: { id: testUser._id, role: testUser.role }, params: {} }; + res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + next = jest.fn(); + jest.clearAllMocks(); + }); + + describe('middleware factory', () => { + test('throws when requiredPermission is missing', () => { + expect(() => canAccessArtifactAppResource({})).toThrow( + 'canAccessArtifactAppResource: requiredPermission is required and must be a number', + ); + }); + + test('throws when requiredPermission is not a number', () => { + expect(() => canAccessArtifactAppResource({ requiredPermission: '1' })).toThrow( + 'canAccessArtifactAppResource: requiredPermission is required and must be a number', + ); + }); + + test('throws when called with no options at all', () => { + expect(() => canAccessArtifactAppResource()).toThrow( + 'canAccessArtifactAppResource: requiredPermission is required and must be a number', + ); + }); + + test('returns an express middleware with the default resourceIdParam', () => { + const middleware = canAccessArtifactAppResource({ requiredPermission: VIEW }); + expect(typeof middleware).toBe('function'); + expect(middleware.length).toBe(3); + }); + }); + + describe('permission checking against real ACL entries', () => { + test('allows the owner to view their own app', async () => { + const { app } = await publishApp(testUser._id); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS }); + req.params.id = app.artifactAppId; + + await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('denies a user with no ACL entry for the app', async () => { + const otherUser = await User.create({ + email: 'other@example.com', + name: 'Other User', + username: 'otheruser', + role: 'test-role', + }); + const { app } = await publishApp(otherUser._id); + await grant({ principalId: otherUser._id, resourceId: app.id, permBits: OWNER_BITS }); + req.params.id = app.artifactAppId; + + await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Forbidden', + message: `Insufficient permissions to access this ${ResourceType.ARTIFACT_APP}`, + }); + }); + + /* The route table gates PATCH on EDIT and DELETE on DELETE, so a + * view-only grant must not satisfy either. */ + test.each([ + ['edit', EDIT], + ['delete', DELETE], + ])('denies %s when the grant is view-only', async (_label, requiredPermission) => { + const { app } = await publishApp(testUser._id); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW }); + req.params.id = app.artifactAppId; + + await canAccessArtifactAppResource({ requiredPermission })(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('allows edit when the grant carries edit bits', async () => { + const { app } = await publishApp(testUser._id); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW | EDIT }); + req.params.id = app.artifactAppId; + + await canAccessArtifactAppResource({ requiredPermission: EDIT })(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('returns 404 for an unknown artifact app id', async () => { + req.params.id = 'app_does_not_exist'; + + await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'Not Found', + message: `${ResourceType.ARTIFACT_APP} not found`, + }); + }); + + /* A grant on the same ObjectId under a different resourceType must not + * leak across: ACL rows are keyed by (resourceType, resourceId). */ + test('ignores a grant recorded under a different resourceType', async () => { + const { app } = await publishApp(testUser._id); + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: app.id, + permBits: OWNER_BITS, + grantedBy: testUser._id, + }); + req.params.id = app.artifactAppId; + + await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('reads the app id from a custom resourceIdParam', async () => { + const { app } = await publishApp(testUser._id); + await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS }); + req.params.appId = app.artifactAppId; + + await canAccessArtifactAppResource({ + requiredPermission: VIEW, + resourceIdParam: 'appId', + })(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index 31548411bac..6af901fade1 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -5,6 +5,7 @@ const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup'); const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource'); const { canAccessMCPServerResource } = require('./canAccessMCPServerResource'); const { canAccessSkillResource } = require('./canAccessSkillResource'); +const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource'); module.exports = { canAccessResource, @@ -14,4 +15,5 @@ module.exports = { canAccessPromptGroupResource, canAccessMCPServerResource, canAccessSkillResource, + canAccessArtifactAppResource, }; diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 96aa8c8a3e0..114ab821ae4 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -6,7 +6,11 @@ const { ResourceType, PermissionBits, } = require('librechat-data-provider'); -const { createAgentAdminPermissionAccess, isAgentPermissionsAdmin } = require('@librechat/api'); +const { + createAgentAdminPermissionAccess, + createArtifactAppSharingPolicy, + isAgentPermissionsAdmin, +} = require('@librechat/api'); const { getUserEffectivePermissions, getAllEffectivePermissions, @@ -25,6 +29,9 @@ const db = require('~/models'); const { findMCPServerByObjectId, getSkillById } = db; const router = express.Router(); +const enforceArtifactAppSharingPolicy = createArtifactAppSharingPolicy({ + getArtifactAppsByIds: db.getArtifactAppsByIds, +}); // Apply common middleware router.use(requireJwtAuth); @@ -99,6 +106,12 @@ const checkResourcePermissionAccess = (requiredPermission) => requiredPermission, resourceIdParam: 'resourceId', }); + } else if (resourceType === ResourceType.ARTIFACT_APP) { + middleware = canAccessResource({ + resourceType: ResourceType.ARTIFACT_APP, + requiredPermission, + resourceIdParam: 'resourceId', + }); } else if (resourceType === ResourceType.SHARED_LINK) { middleware = canAccessResource({ resourceType: ResourceType.SHARED_LINK, @@ -197,6 +210,7 @@ router.put( checkResourcePermissionAccess(PermissionBits.SHARE), checkShareAccessUnlessAgentAdmin, checkSharePublicAccess, + enforceArtifactAppSharingPolicy, rejectSharedLinkOwnerPermissionChanges, updateResourcePermissions, ); diff --git a/api/server/routes/accessPermissions.sharePolicy.test.js b/api/server/routes/accessPermissions.sharePolicy.test.js index ed17a044529..ade6192c89f 100644 --- a/api/server/routes/accessPermissions.sharePolicy.test.js +++ b/api/server/routes/accessPermissions.sharePolicy.test.js @@ -2,6 +2,7 @@ jest.mock('~/models', () => ({ getRoleByName: jest.fn(), findMCPServerByObjectId: jest.fn(), getSkillById: jest.fn(), + getArtifactAppsByIds: jest.fn(), })); jest.mock('~/server/middleware', () => ({ @@ -45,7 +46,7 @@ const { updateResourcePermissions } = require('~/server/controllers/PermissionsC const { hasCapability } = require('~/server/middleware/roles/capabilities'); const { canAccessResource } = require('~/server/middleware'); const accessPermissionsRouter = require('./accessPermissions'); -const { getRoleByName } = require('~/models'); +const { getRoleByName, getArtifactAppsByIds } = require('~/models'); describe('Access permissions share policy', () => { let app; @@ -65,6 +66,17 @@ describe('Access permissions share policy', () => { resourceIdParam: 'resourceId', }, }, + { + label: 'artifact', + resourceType: ResourceType.ARTIFACT_APP, + permissionType: PermissionTypes.ARTIFACTS, + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + middlewareOptions: { + resourceType: ResourceType.ARTIFACT_APP, + requiredPermission: PermissionBits.SHARE, + resourceIdParam: 'resourceId', + }, + }, { label: 'prompt group', resourceType: ResourceType.PROMPTGROUP, @@ -130,6 +142,17 @@ describe('Access permissions share policy', () => { }); }; + const allowArtifactSharing = () => { + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.ARTIFACTS]: { + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + }; + const mockSharedLinkOwner = (ownerId = 'owner-user') => { mockSharedLinkFindById.mockReturnValue({ lean: jest.fn().mockResolvedValue({ user: ownerId }), @@ -317,4 +340,88 @@ describe('Access permissions share policy', () => { expect(response.status).toBe(200); expect(updateResourcePermissions).toHaveBeenCalledTimes(1); }); + + it('blocks direct Artifact App editor grants', async () => { + allowArtifactSharing(); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`) + .send({ + updated: [ + { + type: PrincipalType.USER, + id: 'target-user', + accessRoleId: AccessRoleIds.ARTIFACT_APP_EDITOR, + }, + ], + removed: [], + public: false, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Artifact Apps can only be shared with viewer access'); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + }); + + it('blocks removing the canonical Artifact App owner', async () => { + allowArtifactSharing(); + getArtifactAppsByIds.mockResolvedValue([{ createdBy: 'artifact-owner' }]); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`) + .send({ + updated: [], + removed: [{ type: PrincipalType.USER, id: 'artifact-owner' }], + public: false, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Artifact App owner permissions cannot be changed'); + expect(getArtifactAppsByIds).toHaveBeenCalledWith([resourceId]); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + }); + + it('allows Artifact App viewer grants for non-owners', async () => { + allowArtifactSharing(); + getArtifactAppsByIds.mockResolvedValue([{ createdBy: 'artifact-owner' }]); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`) + .send({ + updated: [ + { + type: PrincipalType.USER, + id: 'target-user', + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + }, + ], + removed: [], + public: false, + }); + + expect(response.status).toBe(200); + expect(updateResourcePermissions).toHaveBeenCalledTimes(1); + }); + + it('blocks Artifact App role-wide grants even when public sharing is allowed', async () => { + allowArtifactSharing(); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`) + .send({ + updated: [ + { + type: PrincipalType.ROLE, + id: 'USER', + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + }, + ], + removed: [], + public: false, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Artifact Apps cannot be shared with roles'); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + }); }); diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js new file mode 100644 index 00000000000..ebeb53954ff --- /dev/null +++ b/api/server/routes/artifactApps.js @@ -0,0 +1,143 @@ +const express = require('express'); +const { + createArtifactAppHandlers, + createSourceConversationExistsCheck, + generateCheckAccess, +} = require('@librechat/api'); +const { ResourceCapabilityMap } = require('@librechat/data-schemas'); +const { + Permissions, + PermissionBits, + PermissionTypes, + ResourceType, +} = require('librechat-data-provider'); +const { + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + restoreArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + getDeletedArtifactAppBySource, + listArtifactApps, + getArtifactAppsByIds, + updateArtifactApp, + deleteArtifactApp, + prepareArtifactAppDeletion, + finalizeArtifactAppDeletion, + getArtifactVersion, + listArtifactVersions, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + recordAuditEntry, + getRoleByName, + getConvo, +} = require('~/models'); +const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware'); +const { hasCapability } = require('~/server/middleware/roles/capabilities'); +const { + getResourcePermissionsMap, + grantPermission, + removeAllPermissions, +} = require('~/server/services/PermissionService'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); + +router.use(requireJwtAuth); +router.use(configMiddleware); + +const checkArtifactAccess = generateCheckAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permissions: [Permissions.USE], + getRoleByName, +}); +const checkArtifactCreate = generateCheckAccess({ + permissionType: PermissionTypes.ARTIFACTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + +const handlers = createArtifactAppHandlers({ + createArtifactAppWithVersion, + syncArtifactAppWithVersion, + restoreArtifactAppWithVersion, + getArtifactAppByAppId, + getArtifactAppBySource, + getDeletedArtifactAppBySource, + listArtifactApps, + getArtifactAppsByIds, + updateArtifactApp, + deleteArtifactApp, + prepareArtifactAppDeletion, + finalizeArtifactAppDeletion, + getArtifactVersion, + listArtifactVersions, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + getResourcePermissionsMap, + grantPermission, + removeAllPermissions, + hasResourceManagementCapability: (user) => + hasCapability(user, ResourceCapabilityMap[ResourceType.ARTIFACT_APP]), + recordAuditEntry, + sourceConversationExists: createSourceConversationExistsCheck(getConvo), + getConfig: (req) => req.config?.artifactApps, +}); + +// Collection +router.get('/', checkArtifactAccess, handlers.list); +router.post('/', checkArtifactCreate, handlers.publish); +router.post('/sync', checkArtifactCreate, handlers.sync); +router.post('/restore', checkArtifactCreate, handlers.restore); +router.get('/source', checkArtifactAccess, handlers.getBySource); + +// Single app +router.get( + '/:id', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.get, +); +router.patch( + '/:id', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.update, +); +router.delete('/:id', checkArtifactAccess, handlers.remove); + +// Versions +router.get( + '/:id/versions', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.listVersions, +); +router.get( + '/:id/versions/:versionId', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.getVersion, +); +router.post( + '/:id/versions/:versionId/release', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.releaseVersion, +); +router.post( + '/:id/versions/:versionId/activate', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.activateVersion, +); +router.post( + '/:id/versions/:versionId/withdraw', + checkArtifactAccess, + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.withdrawVersion, +); + +module.exports = router; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index fd0adb57ae9..58c9b3353b2 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -307,6 +307,7 @@ router.get('/', async function (req, res) { ...buildPostLoginPayload(appConfig), ...getConversationTitleCapabilities(appConfig?.interfaceConfig), conversationListLimits, + artifactApps: appConfig?.artifactApps, sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, projects: appConfig?.projects, diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 14afa6f2691..861346339a4 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -21,6 +21,7 @@ const projects = require('./projects'); const prompts = require('./prompts'); const schedules = require('./schedules'); const skills = require('./skills'); +const artifactApps = require('./artifactApps'); const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); @@ -80,6 +81,7 @@ module.exports = { projects, schedules, skills, + artifactApps, actions, presets, balance, diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index ec4c4fea116..73d8c97ecca 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -5,6 +5,7 @@ const { roleDefaults, PermissionTypes, agentPermissionsSchema, + artifactPermissionsSchema, promptPermissionsSchema, memoryPermissionsSchema, mcpServersPermissionsSchema, @@ -36,6 +37,11 @@ const permissionConfigs = { permissionType: PermissionTypes.AGENTS, errorMessage: 'Invalid agent permissions.', }, + artifacts: { + schema: artifactPermissionsSchema, + permissionType: PermissionTypes.ARTIFACTS, + errorMessage: 'Invalid artifact permissions.', + }, memories: { schema: memoryPermissionsSchema, permissionType: PermissionTypes.MEMORIES, @@ -165,6 +171,9 @@ router.put('/:roleName/prompts', manageRoles, createPermissionUpdateHandler('pro */ router.put('/:roleName/agents', manageRoles, createPermissionUpdateHandler('agents')); +/** Update Artifact Catalog permissions for a specific role. */ +router.put('/:roleName/artifacts', manageRoles, createPermissionUpdateHandler('artifacts')); + /** * PUT /api/roles/:roleName/memories * Update memory permissions for a specific role diff --git a/api/server/services/start/migration.js b/api/server/services/start/migration.js index 70f8300e08d..607fd41dff4 100644 --- a/api/server/services/start/migration.js +++ b/api/server/services/start/migration.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { logger } = require('@librechat/data-schemas'); +const { logger, ensureArtifactAppIndexes } = require('@librechat/data-schemas'); const { logAgentMigrationWarning, logPromptMigrationWarning, @@ -13,6 +13,7 @@ const { findRoleByIdentifier } = require('~/models'); * This runs at the end to ensure all systems are initialized */ async function checkMigrations() { + await ensureArtifactAppIndexes(mongoose.connection); try { const agentMigrationResult = await checkAgentPermissionsMigration({ mongoose, diff --git a/client/package.json b/client/package.json index 6831d650cdf..366b533ac23 100644 --- a/client/package.json +++ b/client/package.json @@ -158,6 +158,7 @@ "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", "eslint-plugin-jest": "^29.1.0", + "fake-indexeddb": "^6.2.5", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-canvas-mock": "^2.5.2", diff --git a/client/src/Providers/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx index f2bb28fabf1..bbb5d378ee4 100644 --- a/client/src/Providers/ArtifactsContext.tsx +++ b/client/src/Providers/ArtifactsContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { Constants } from 'librechat-data-provider'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { getLatestText } from '~/utils'; import store from '~/store'; @@ -8,6 +9,7 @@ export interface ArtifactsContextValue { isSubmitting: boolean; latestMessageId: string | null; latestMessageText: string; + latestMessageError: boolean; conversationId: string | null; /** Whether this host offers opening the pane in its own window. The pane * consumes the deployment's `interface.artifactUndocking` rather than @@ -37,14 +39,31 @@ export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) { return getLatestText(latestMessage); }, [latestMessage]); + /** + * A tool-call-limit pause also sets `unfinished: true` even though nothing + * failed or was cancelled — its distinct `finish_reason` is what excludes + * it here, so a paused-but-valid generation still registers its artifacts. + */ + const latestMessageError = + latestMessage?.error === true || + (latestMessage?.unfinished === true && + latestMessage.finish_reason !== Constants.TOOL_CALL_LIMIT_FINISH_REASON); + const defaultContextValue = useMemo>( () => ({ isSubmitting, conversationId: conversationId ?? null, latestMessageText: chatLatestMessageText, latestMessageId: latestMessage?.messageId ?? null, + latestMessageError, }), - [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversationId], + [ + isSubmitting, + chatLatestMessageText, + latestMessage?.messageId, + latestMessageError, + conversationId, + ], ); const contextValue = useMemo( diff --git a/client/src/Providers/__tests__/ArtifactsContext.spec.tsx b/client/src/Providers/__tests__/ArtifactsContext.spec.tsx new file mode 100644 index 00000000000..95da5d1b3f2 --- /dev/null +++ b/client/src/Providers/__tests__/ArtifactsContext.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { Constants } from 'librechat-data-provider'; +import { render, screen } from '@testing-library/react'; +import type { TMessage } from 'librechat-data-provider'; +import { ArtifactsProvider, useArtifactsContext } from '~/Providers/ArtifactsContext'; + +const mockUseLatestMessage = jest.fn(); + +jest.mock('~/hooks/Messages/useLatestMessage', () => ({ + useLatestMessage: () => mockUseLatestMessage(), +})); + +function TestConsumer() { + const { latestMessageError } = useArtifactsContext(); + return {String(latestMessageError)}; +} + +function renderWithMessage(message: Partial | null) { + mockUseLatestMessage.mockReturnValue(message); + return render( + + + + + , + ); +} + +describe('ArtifactsProvider latestMessageError', () => { + it('is false for a normally completed message', () => { + renderWithMessage({ messageId: 'message-1', error: false, unfinished: false }); + expect(screen.getByTestId('latest-message-error')).toHaveTextContent('false'); + }); + + it('is true for an errored message', () => { + renderWithMessage({ messageId: 'message-1', error: true, unfinished: false }); + expect(screen.getByTestId('latest-message-error')).toHaveTextContent('true'); + }); + + it('is true for an aborted message (unfinished with no finish_reason)', () => { + renderWithMessage({ messageId: 'message-1', error: false, unfinished: true }); + expect(screen.getByTestId('latest-message-error')).toHaveTextContent('true'); + }); + + it('is false for a tool-call-limit pause even though the message is unfinished', () => { + renderWithMessage({ + messageId: 'message-1', + error: false, + unfinished: true, + finish_reason: String(Constants.TOOL_CALL_LIMIT_FINISH_REASON), + }); + expect(screen.getByTestId('latest-message-error')).toHaveTextContent('false'); + }); +}); diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts index 09811fda24e..61d4219bead 100644 --- a/client/src/common/artifacts.ts +++ b/client/src/common/artifacts.ts @@ -1,3 +1,5 @@ +import type { ArtifactPreview } from 'librechat-data-provider'; + export interface CodeBlock { id: string; language: string; @@ -33,6 +35,7 @@ export interface Artifact { content?: string; title?: string; type?: string; + preview?: ArtifactPreview; download?: ArtifactDownload; } diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx new file mode 100644 index 00000000000..6f686bc8423 --- /dev/null +++ b/client/src/components/ArtifactApps/AppRenderer.tsx @@ -0,0 +1,42 @@ +import { useRef } from 'react'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { TArtifactVersion } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { ArtifactPreview } from '~/components/Artifacts/ArtifactPreview'; +import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; +import { useGetStartupConfig } from '~/data-provider'; +import { getArtifactMimeType } from './runtime'; + +export default function AppRenderer({ + title, + version, +}: { + title: string; + version: TArtifactVersion; +}) { + const previewRef = useRef(null as unknown as SandpackPreviewRef); + const { data: startupConfig } = useGetStartupConfig(); + + const artifact: Artifact = { + id: version.artifactVersionId, + lastUpdateTime: Date.now(), + type: getArtifactMimeType(version.artifactType), + content: version.sourceSnapshot, + title, + }; + + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx new file mode 100644 index 00000000000..ab7a36e3fec --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ArtifactAppsAdminSettings from './ArtifactAppsAdminSettings'; + +let mockRole = 'ADMIN'; +const mockDefaultValues = {}; + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useToastContext: () => ({ showToast: jest.fn() }), +})); + +jest.mock('~/components/ui', () => { + const ReactModule = jest.requireActual('react') as typeof React; + const labels: Record = { + com_ui_artifacts_allow_use: 'Allow access to the artifact catalog', + com_ui_artifacts_allow_create: 'Automatically save created artifacts', + com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups', + com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone', + }; + return { + AdminSettingsDialog: ({ + trigger, + permissions, + }: { + trigger: React.ReactElement; + permissions: Array<{ labelKey: string }>; + }) => { + const [open, setOpen] = ReactModule.useState(false); + if (mockRole !== 'ADMIN') { + return null; + } + const heading = ['Admin Settings', 'Artifacts'].join(' - '); + return ( + <> + {ReactModule.cloneElement(trigger, { onClick: () => setOpen(true) })} + {open && ( +
+

{heading}

+ {permissions.map(({ labelKey }) => ( + {labels[labelKey]} + ))} +
+ )} + + ); + }, + }; +}); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => ({ user: { role: mockRole } }), + useLocalize: () => (key: string, values?: { section?: string }) => { + const translations: Record = { + com_ui_admin_settings: 'Admin Settings', + com_ui_artifact_apps: 'Artifacts', + com_ui_artifacts_allow_use: 'Allow access to the artifact catalog', + com_ui_artifacts_allow_create: 'Automatically save created artifacts', + com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups', + com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone', + }; + if (key === 'com_ui_admin_settings_section') { + return `Admin Settings - ${values?.section}`; + } + return translations[key] ?? key; + }, + useRoleSelector: () => ({ + selectedRole: 'ADMIN', + isSelectedCustomRole: false, + isCustomRoleLoading: false, + isCustomRoleError: false, + defaultValues: mockDefaultValues, + roleDropdownItems: [], + }), +})); + +jest.mock('~/data-provider', () => ({ + useUpdateArtifactPermissionsMutation: () => ({ mutate: jest.fn(), isLoading: false }), +})); + +describe('ArtifactAppsAdminSettings', () => { + beforeEach(() => { + mockRole = 'ADMIN'; + }); + + it('opens artifact permission settings for an administrator', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Admin Settings' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Admin Settings - Artifacts')).toBeInTheDocument(); + expect(screen.getByText('Allow access to the artifact catalog')).toBeInTheDocument(); + expect(screen.getByText('Allow sharing artifacts with everyone')).toBeInTheDocument(); + }); + + it('does not show the administration action to a non-administrator', () => { + mockRole = 'USER'; + render(); + + expect(screen.queryByRole('button', { name: 'Admin Settings' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx new file mode 100644 index 00000000000..b6ba51a142f --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx @@ -0,0 +1,55 @@ +import { ShieldEllipsis } from 'lucide-react'; +import { Button, useToastContext } from '@librechat/client'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import type { PermissionConfig } from '~/components/ui'; +import { useUpdateArtifactPermissionsMutation } from '~/data-provider'; +import { AdminSettingsDialog } from '~/components/ui'; +import { useLocalize } from '~/hooks'; + +const permissions: PermissionConfig[] = [ + { permission: Permissions.USE, labelKey: 'com_ui_artifacts_allow_use' }, + { permission: Permissions.CREATE, labelKey: 'com_ui_artifacts_allow_create' }, + { permission: Permissions.SHARE, labelKey: 'com_ui_artifacts_allow_share' }, + { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_artifacts_allow_share_public' }, +]; + +const ArtifactAppsAdminSettings = ({ compact = false }: { compact?: boolean }) => { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const mutation = useUpdateArtifactPermissionsMutation({ + onSuccess: () => showToast({ status: 'success', message: localize('com_ui_saved') }), + onError: () => + showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }), + }); + + const trigger = ( + + ); + + return ( + + ); +}; + +export default ArtifactAppsAdminSettings; diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx new file mode 100644 index 00000000000..9a5b1ed73d8 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx @@ -0,0 +1,781 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useSetAtom } from 'jotai'; +import { PermissionBits } from 'librechat-data-provider'; +import type { TArtifactApp } from 'librechat-data-provider'; +import type { MenuItemProps } from '~/common'; +import { + useDeleteArtifactAppMutation, + useListArtifactAppsQuery, + useWithdrawArtifactVersionMutation, +} from '~/data-provider'; +import ArtifactAppsList from './ArtifactAppsList'; + +const mockShowToast = jest.fn(); +const mockUseAuthContext = jest.fn(() => ({ + user: { id: 'user-1', role: 'USER', tenantId: undefined as string | undefined }, +})); + +const getPinnedStorageKey = (userId: string, tenantId = '__default__') => + `librechat.pinnedArtifactApps:${tenantId}:${userId}`; +const getViewedStorageKey = (userId: string, tenantId = '__default__') => + `librechat.viewedArtifactApps:${tenantId}:${userId}`; + +jest.mock('~/data-provider', () => ({ + useDeleteArtifactAppMutation: jest.fn(), + useListArtifactAppsQuery: jest.fn(), + useWithdrawArtifactVersionMutation: jest.fn(), +})); + +jest.mock('jotai', () => ({ + ...jest.requireActual('jotai'), + useSetAtom: jest.fn(), +})); + +jest.mock('~/hooks', () => ({ + useAuthContext: () => mockUseAuthContext(), + useDebounce: (value: string) => value, + useHasAccess: () => true, + useLocalize: () => (key: string, values?: Record) => { + const translations: Record = { + com_ui_artifact_apps: 'Artifacts', + com_ui_artifact_apps_description: 'Artifacts published from your conversations.', + com_ui_artifact_apps_search_placeholder: 'Search artifacts', + com_ui_artifact_apps_search_aria: 'Search artifacts', + com_ui_artifact_apps_clear_search: 'Clear artifact search', + com_ui_artifact_apps_no_results: 'No artifacts found', + com_ui_artifact_apps_no_results_hint: 'Try a different name, description, category, or tag.', + com_ui_artifact_apps_load_error: "Couldn't load artifacts.", + com_ui_artifact_apps_refresh_error: + "Couldn't update the artifact catalog. Your loaded artifacts are still available.", + com_ui_artifact_catalog_filters: 'Artifact catalog filters', + com_ui_artifact_scope_personal: 'Personal', + com_ui_artifact_scope_shared: 'Shared', + com_ui_artifact_scope_all: 'All', + com_ui_artifact_shared_with_you: 'Shared with you', + com_ui_artifact_link_copied: 'Artifact link copied', + com_ui_artifact_delete: 'Delete artifact?', + com_ui_artifact_delete_confirm: `Delete "${values?.[0] ?? ''}"? This action cannot be undone.`, + com_ui_artifact_delete_error: "Couldn't delete the artifact. Try again.", + com_ui_artifact_delete_success: 'Artifact deleted', + com_ui_artifact_withdraw: 'Withdraw active version?', + com_ui_artifact_withdraw_confirm: `Withdraw the active version of "${values?.[0] ?? ''}"?`, + com_ui_artifact_withdraw_error: "Couldn't withdraw the active version. Try again.", + com_ui_artifact_withdraw_success: 'Active version withdrawn', + com_ui_artifact_apps_view_mode: 'Artifact view mode', + com_ui_artifact_apps_list_view: 'List view', + com_ui_artifact_apps_grid_view: 'Grid view', + com_ui_artifact_app_preview: 'Artifact preview', + com_ui_artifact_app_preview_unavailable: 'Preview unavailable', + com_ui_artifact_activity_just_now: 'just now', + com_ui_copy_failed: 'Failed to copy to clipboard', + com_ui_copy_link: 'Copy link', + com_ui_delete: 'Delete', + com_ui_withdraw: 'Withdraw', + com_ui_share: 'Share', + com_ui_options: 'options', + com_ui_pin: 'Pin', + com_ui_pinned: 'Pinned', + com_ui_unpin: 'Unpin', + com_ui_unpinned: 'Unpinned', + com_ui_retry: 'Retry', + }; + if (key === 'com_ui_artifact_app_version_number') { + return `v${values?.[0] ?? ''}`; + } + if (key === 'com_ui_artifact_activity_edited') { + return `edited ${values?.[0] ?? ''}`; + } + if (key === 'com_ui_artifact_activity_viewed') { + return `viewed ${values?.[0] ?? ''}`; + } + if (key === 'com_ui_artifact_activity_minutes_ago') { + return `${values?.[0] ?? ''}m ago`; + } + if (key === 'com_ui_artifact_activity_hours_ago') { + return `${values?.[0] ?? ''}h ago`; + } + return translations[key] ?? key; + }, +})); + +jest.mock('@librechat/client', () => { + const ReactModule = jest.requireActual('react'); + + return { + ...jest.requireActual('@librechat/client'), + DropdownPopup: ({ trigger, items }: { trigger: React.ReactNode; items: MenuItemProps[] }) => { + const label = ReactModule.isValidElement<{ 'aria-label'?: string }>(trigger) + ? trigger.props['aria-label'] + : undefined; + + return ( +
+ + {items + .filter((item) => item.show !== false) + .map((item) => + item.separate ? ( +
+ ) : ( + + ), + )} +
+ ); + }, + useMediaQuery: () => false, + useToastContext: () => ({ showToast: mockShowToast }), + }; +}); + +jest.mock('~/components/Chat/Menus/OpenSidebar', () => () => null); +jest.mock('./ArtifactAppsAdminSettings', () => () => + ))} + + ); +} + +function ArtifactAppMenu({ + app, + isPinned, + onTogglePin, + onDeleted, +}: { + app: TArtifactApp; + isPinned: boolean; + onTogglePin: (artifactAppId: string) => void; + onDeleted: (artifactAppId: string) => void; +}) { + const localize = useLocalize(); + const { user } = useAuthContext(); + const { showToast } = useToastContext(); + const canShare = useCanShareArtifactApp(app); + const canDelete = + app.createdBy === user?.id || + user?.role === SystemRoles.ADMIN || + hasPermissions(app.permissionBits ?? 0, PermissionBits.DELETE); + const canManageVersion = + app.createdBy === user?.id || + user?.role === SystemRoles.ADMIN || + hasPermissions(app.permissionBits ?? 0, PermissionBits.EDIT); + const canWithdraw = canManageVersion && Boolean(app.activeVersionId); + const deleteArtifact = useDeleteArtifactAppMutation(); + const withdrawVersion = useWithdrawArtifactVersionMutation(); + const [menuOpen, setMenuOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [withdrawOpen, setWithdrawOpen] = useState(false); + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(getArtifactAppUrl(app.artifactAppId)); + showToast({ status: 'success', message: localize('com_ui_artifact_link_copied') }); + } catch { + showToast({ status: 'error', message: localize('com_ui_copy_failed') }); + } + }; + + const items: MenuItemProps[] = [ + { + id: `artifact-pin-${app.artifactAppId}`, + label: localize(isPinned ? 'com_ui_unpin' : 'com_ui_pin'), + icon: isPinned ? : , + ariaChecked: isPinned, + onClick: () => onTogglePin(app.artifactAppId), + }, + { + id: `artifact-copy-link-${app.artifactAppId}`, + label: localize('com_ui_copy_link'), + icon: , + onClick: () => void copyLink(), + }, + ]; + + if (canShare) { + items.push({ + id: `artifact-share-${app.artifactAppId}`, + label: localize('com_ui_share'), + icon: , + onClick: () => setShareOpen(true), + }); + } + + if (canWithdraw) { + items.push({ + id: `artifact-withdraw-${app.artifactAppId}`, + label: localize('com_ui_withdraw'), + icon: , + onClick: () => setWithdrawOpen(true), + }); + } + + if (canDelete) { + items.push({ id: `artifact-delete-separator-${app.artifactAppId}`, separate: true }); + items.push({ + id: `artifact-delete-${app.artifactAppId}`, + label: localize('com_ui_delete'), + icon: , + className: + 'text-text-destructive hover:bg-destructive/10 hover:text-text-destructive focus:bg-destructive/10 focus:text-text-destructive', + onClick: () => setDeleteOpen(true), + }); + } + + const confirmDelete = () => { + if (deleteArtifact.isLoading) { + return; + } + deleteArtifact.mutate(app.artifactAppId, { + onSuccess: () => { + onDeleted(app.artifactAppId); + setDeleteOpen(false); + showToast({ status: 'success', message: localize('com_ui_artifact_delete_success') }); + }, + onError: () => { + showToast({ status: 'error', message: localize('com_ui_artifact_delete_error') }); + }, + }); + }; + + const confirmWithdraw = () => { + if (withdrawVersion.isLoading || !app.activeVersionId) { + return; + } + withdrawVersion.mutate( + { artifactAppId: app.artifactAppId, versionId: app.activeVersionId }, + { + onSuccess: () => { + setWithdrawOpen(false); + showToast({ status: 'success', message: localize('com_ui_artifact_withdraw_success') }); + }, + onError: () => { + showToast({ status: 'error', message: localize('com_ui_artifact_withdraw_error') }); + }, + }, + ); + }; + + return ( + <> + {shareOpen && ( + { + if (!open) { + setShareOpen(false); + } + }} + /> + )} + + + {localize('com_ui_artifact_delete_confirm', { 0: app.title })} +

+ } + selection={ + + } + /> +
+ + + {localize('com_ui_artifact_withdraw_confirm', { 0: app.title })} +

+ } + selection={ + + } + /> +
+ +