From 171218969fadee0cee4a5c059bcd17ccd4f1164b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:49:11 -0500 Subject: [PATCH 1/4] [FIX] Update Endpoints (#1896) * fix(api): update response and docs for policy & risks endpoints * fix(api): add API key auth support with userId parameter for comments API * fix(api): add API key auth support with userId parameter for transfer-ownership and task/attachments endpoints * fix(api): move userId to request body for comment deletion endpoint * fix(api): don't expose sensitive fields of user model from /risks endpoint * fix(api): update error description of transfer-ownership api --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../src/attachments/upload-attachment.dto.ts | 11 ++ apps/api/src/comments/comments.controller.ts | 82 ++++++++-- .../src/comments/dto/create-comment.dto.ts | 11 ++ .../src/comments/dto/delete-comment.dto.ts | 15 ++ .../src/comments/dto/update-comment.dto.ts | 13 +- .../dto/transfer-ownership.dto.ts | 5 + .../organization/organization.controller.ts | 31 +++- .../schemas/organization-api-bodies.ts | 6 + .../schemas/get-all-policies.responses.ts | 86 +++++++++-- apps/api/src/risks/risks.service.ts | 21 +++ apps/api/src/tasks/tasks.controller.ts | 22 ++- packages/docs/openapi.json | 144 ++++++++++++++---- 12 files changed, 381 insertions(+), 66 deletions(-) create mode 100644 apps/api/src/comments/dto/delete-comment.dto.ts diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts index c91dc176c..2981948c9 100644 --- a/apps/api/src/attachments/upload-attachment.dto.ts +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -69,4 +69,15 @@ export class UploadAttachmentDto { @IsString() @MaxLength(500) description?: string; + + @ApiProperty({ + description: + 'User ID of the user uploading the attachment (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + userId?: string; } diff --git a/apps/api/src/comments/comments.controller.ts b/apps/api/src/comments/comments.controller.ts index 2026055e3..7d0899898 100644 --- a/apps/api/src/comments/comments.controller.ts +++ b/apps/api/src/comments/comments.controller.ts @@ -5,8 +5,6 @@ import { Controller, Delete, Get, - HttpCode, - HttpStatus, Param, Post, Put, @@ -14,6 +12,7 @@ import { UseGuards, } from '@nestjs/common'; import { + ApiBody, ApiHeader, ApiOperation, ApiParam, @@ -28,6 +27,7 @@ import type { AuthContext as AuthContextType } from '../auth/types'; import { CommentsService } from './comments.service'; import { CommentResponseDto } from './dto/comment-responses.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; +import { DeleteCommentDto } from './dto/delete-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; @ApiTags('Comments') @@ -92,13 +92,28 @@ export class CommentsController { @AuthContext() authContext: AuthContextType, @Body() createCommentDto: CreateCommentDto, ): Promise { - if (!authContext.userId) { - throw new BadRequestException('User ID is required'); + // For API key auth, userId must be provided in the request body + // For JWT auth, userId comes from the authenticated session + let userId: string; + if (authContext.isApiKey) { + // For API key auth, userId must be provided in the DTO + if (!createCommentDto.userId) { + throw new BadRequestException( + 'User ID is required when using API key authentication. Provide userId in the request body.', + ); + } + userId = createCommentDto.userId; + } else { + // For JWT auth, use the authenticated user's ID + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + userId = authContext.userId; } return await this.commentsService.createComment( organizationId, - authContext.userId, + userId, createCommentDto, ); } @@ -124,14 +139,29 @@ export class CommentsController { @Param('commentId') commentId: string, @Body() updateCommentDto: UpdateCommentDto, ): Promise { - if (!authContext.userId) { - throw new BadRequestException('User ID is required'); + // For API key auth, userId must be provided in the request body + // For JWT auth, userId comes from the authenticated session + let userId: string; + if (authContext.isApiKey) { + // For API key auth, userId must be provided in the DTO + if (!updateCommentDto.userId) { + throw new BadRequestException( + 'User ID is required when using API key authentication. Provide userId in the request body.', + ); + } + userId = updateCommentDto.userId; + } else { + // For JWT auth, use the authenticated user's ID + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + userId = authContext.userId; } return await this.commentsService.updateComment( organizationId, commentId, - authContext.userId, + userId, updateCommentDto.content, ); } @@ -146,6 +176,20 @@ export class CommentsController { description: 'Unique comment identifier', example: 'cmt_abc123def456', }) + @ApiBody({ + description: 'Delete comment request body', + schema: { + type: 'object', + properties: { + userId: { + type: 'string', + description: + 'User ID of the comment author (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + }, + }, + }, + }) @ApiResponse({ status: 200, description: 'Comment deleted successfully', @@ -198,15 +242,31 @@ export class CommentsController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @Param('commentId') commentId: string, + @Body() deleteDto: DeleteCommentDto, ): Promise<{ success: boolean; deletedCommentId: string; message: string }> { - if (!authContext.userId) { - throw new BadRequestException('User ID is required'); + // For API key auth, userId must be provided in the request body + // For JWT auth, userId comes from the authenticated session + let userId: string; + if (authContext.isApiKey) { + // For API key auth, userId must be provided in the request body + if (!deleteDto.userId) { + throw new BadRequestException( + 'User ID is required when using API key authentication. Provide userId in the request body.', + ); + } + userId = deleteDto.userId; + } else { + // For JWT auth, use the authenticated user's ID + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + userId = authContext.userId; } await this.commentsService.deleteComment( organizationId, commentId, - authContext.userId, + userId, ); return { diff --git a/apps/api/src/comments/dto/create-comment.dto.ts b/apps/api/src/comments/dto/create-comment.dto.ts index 6775f8832..086f31b4d 100644 --- a/apps/api/src/comments/dto/create-comment.dto.ts +++ b/apps/api/src/comments/dto/create-comment.dto.ts @@ -49,4 +49,15 @@ export class CreateCommentDto { @ValidateNested({ each: true }) @Type(() => UploadAttachmentDto) attachments?: UploadAttachmentDto[]; + + @ApiProperty({ + description: + 'User ID of the comment author (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + userId?: string; } diff --git a/apps/api/src/comments/dto/delete-comment.dto.ts b/apps/api/src/comments/dto/delete-comment.dto.ts new file mode 100644 index 000000000..b251cfdbb --- /dev/null +++ b/apps/api/src/comments/dto/delete-comment.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class DeleteCommentDto { + @ApiProperty({ + description: + 'User ID of the comment author (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + userId?: string; +} diff --git a/apps/api/src/comments/dto/update-comment.dto.ts b/apps/api/src/comments/dto/update-comment.dto.ts index 64fb93226..883438837 100644 --- a/apps/api/src/comments/dto/update-comment.dto.ts +++ b/apps/api/src/comments/dto/update-comment.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; export class UpdateCommentDto { @ApiProperty({ @@ -11,4 +11,15 @@ export class UpdateCommentDto { @IsNotEmpty() @MaxLength(2000) content: string; + + @ApiProperty({ + description: + 'User ID of the comment author (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + userId?: string; } diff --git a/apps/api/src/organization/dto/transfer-ownership.dto.ts b/apps/api/src/organization/dto/transfer-ownership.dto.ts index 59d2a58e1..4de12184b 100644 --- a/apps/api/src/organization/dto/transfer-ownership.dto.ts +++ b/apps/api/src/organization/dto/transfer-ownership.dto.ts @@ -1,5 +1,10 @@ export interface TransferOwnershipDto { newOwnerId: string; + /** + * User ID of the current owner initiating the transfer + * Required for API key auth, ignored for JWT auth + */ + userId?: string; } export interface TransferOwnershipResponseDto { diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index 732cfc4c4..73f1ed3ff 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -119,25 +119,40 @@ export class OrganizationController { @AuthContext() authContext: AuthContextType, @Body() transferData: TransferOwnershipDto, ) { - if (!authContext.userId) { - throw new BadRequestException( - 'User ID is required for this operation. This endpoint requires session authentication.', - ); + // For API key auth, userId must be provided in the request body + // For JWT auth, userId comes from the authenticated session + let userId: string; + if (authContext.isApiKey) { + // For API key auth, userId must be provided in the DTO + if (!transferData.userId) { + throw new BadRequestException( + 'User ID is required when using API key authentication. Provide userId in the request body.', + ); + } + userId = transferData.userId; + } else { + // For JWT auth, use the authenticated user's ID + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required for this operation. This endpoint requires session authentication.', + ); + } + userId = authContext.userId; } const result = await this.organizationService.transferOwnership( organizationId, - authContext.userId, + userId, transferData.newOwnerId, ); return { ...result, authType: authContext.authType, - // Include user context for session auth (helpful for debugging) + // Include user context (helpful for debugging) authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, + id: userId, + ...(authContext.userEmail && { email: authContext.userEmail }), }, }; } diff --git a/apps/api/src/organization/schemas/organization-api-bodies.ts b/apps/api/src/organization/schemas/organization-api-bodies.ts index 857fdaf76..c832de196 100644 --- a/apps/api/src/organization/schemas/organization-api-bodies.ts +++ b/apps/api/src/organization/schemas/organization-api-bodies.ts @@ -71,6 +71,12 @@ export const TRANSFER_OWNERSHIP_BODY: ApiBodyOptions = { description: 'Member ID of the new owner', example: 'mem_xyz789', }, + userId: { + type: 'string', + description: + 'User ID of the current owner initiating the transfer (required for API key auth, ignored for JWT auth)', + example: 'usr_abc123def456', + }, }, additionalProperties: false, }, diff --git a/apps/api/src/policies/schemas/get-all-policies.responses.ts b/apps/api/src/policies/schemas/get-all-policies.responses.ts index fe75f07ee..388e21227 100644 --- a/apps/api/src/policies/schemas/get-all-policies.responses.ts +++ b/apps/api/src/policies/schemas/get-all-policies.responses.ts @@ -7,24 +7,78 @@ export const GET_ALL_POLICIES_RESPONSES: Record = { content: { 'application/json': { schema: { - type: 'array', - items: { $ref: '#/components/schemas/PolicyResponseDto' }, + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/PolicyResponseDto' }, + description: 'Array of policies', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'Authenticated user information (only present for session auth)', + properties: { + id: { + type: 'string', + description: 'User ID', + example: 'usr_abc123def456', + }, + email: { + type: 'string', + description: 'User email', + example: 'user@company.com', + }, + }, + }, + }, + required: ['data', 'authType'], }, - example: [ - { - id: 'pol_abc123def456', - name: 'Data Privacy Policy', - status: 'draft', - content: [ - { type: 'paragraph', content: [{ type: 'text', text: '...' }] }, - ], - isRequiredToSign: true, - signedBy: [], - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-15T00:00:00.000Z', - organizationId: 'org_abc123def456', + example: { + data: [ + { + id: 'pol_abc123def456', + name: 'Data Privacy Policy', + description: 'This policy outlines how we handle and protect personal data', + status: 'draft', + content: [ + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'text', + text: 'This policy outlines our commitment to protecting personal data.', + }, + ], + }, + ], + frequency: 'yearly', + department: 'IT', + isRequiredToSign: true, + signedBy: [], + reviewDate: '2024-12-31T00:00:00.000Z', + isArchived: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-15T00:00:00.000Z', + lastArchivedAt: null, + lastPublishedAt: '2024-01-10T00:00:00.000Z', + organizationId: 'org_abc123def456', + assigneeId: 'usr_abc123def456', + approverId: 'usr_xyz789abc123', + policyTemplateId: null, + }, + ], + authType: 'session', + authenticatedUser: { + id: 'usr_abc123def456', + email: 'user@company.com', }, - ], + }, }, }, }, diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index 3d425ccc8..74cdd8bea 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -12,6 +12,20 @@ export class RisksService { const risks = await db.risk.findMany({ where: { organizationId }, orderBy: { createdAt: 'desc' }, + include: { + assignee: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, }); this.logger.log( @@ -34,6 +48,13 @@ export class RisksService { id, organizationId, }, + include: { + assignee: { + include: { + user: true, + }, + }, + }, }); if (!risk) { diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index cde966a7d..2d3aac447 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -308,9 +308,23 @@ export class TasksController { taskId, ); - // Ensure userId is present for attachment upload - if (!authContext.userId) { - throw new BadRequestException('User ID is required for file upload'); + // For API key auth, userId must be provided in the request body + // For JWT auth, userId comes from the authenticated session + let userId: string; + if (authContext.isApiKey) { + // For API key auth, userId must be provided in the DTO + if (!uploadDto.userId) { + throw new BadRequestException( + 'User ID is required when using API key authentication. Provide userId in the request body.', + ); + } + userId = uploadDto.userId; + } else { + // For JWT auth, use the authenticated user's ID + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + userId = authContext.userId; } return await this.attachmentsService.uploadAttachment( @@ -318,7 +332,7 @@ export class TasksController { taskId, AttachmentEntityType.task, uploadDto, - authContext.userId, + userId, ); } diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index eda38adb0..dbe8e60fe 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -481,6 +481,11 @@ "type": "string", "description": "Member ID of the new owner", "example": "mem_xyz789" + }, + "userId": { + "type": "string", + "description": "User ID of the current owner initiating the transfer (required for API key auth, ignored for JWT auth)", + "example": "usr_abc123def456" } }, "additionalProperties": false @@ -5093,34 +5098,88 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PolicyResponseDto" - } - }, - "example": [ - { - "id": "pol_abc123def456", - "name": "Data Privacy Policy", - "status": "draft", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "..." - } - ] + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PolicyResponseDto" + }, + "description": "Array of policies" + }, + "authType": { + "type": "string", + "enum": [ + "api-key", + "session" + ], + "description": "How the request was authenticated" + }, + "authenticatedUser": { + "type": "object", + "description": "Authenticated user information (only present for session auth)", + "properties": { + "id": { + "type": "string", + "description": "User ID", + "example": "usr_abc123def456" + }, + "email": { + "type": "string", + "description": "User email", + "example": "user@company.com" + } } - ], - "isRequiredToSign": true, - "signedBy": [], - "createdAt": "2024-01-01T00:00:00.000Z", - "updatedAt": "2024-01-15T00:00:00.000Z", - "organizationId": "org_abc123def456" + } + }, + "required": [ + "data", + "authType" + ] + }, + "example": { + "data": [ + { + "id": "pol_abc123def456", + "name": "Data Privacy Policy", + "description": "This policy outlines how we handle and protect personal data", + "status": "draft", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "This policy outlines our commitment to protecting personal data." + } + ] + } + ], + "frequency": "yearly", + "department": "IT", + "isRequiredToSign": true, + "signedBy": [], + "reviewDate": "2024-12-31T00:00:00.000Z", + "isArchived": false, + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-15T00:00:00.000Z", + "lastArchivedAt": null, + "lastPublishedAt": "2024-01-10T00:00:00.000Z", + "organizationId": "org_abc123def456", + "assigneeId": "usr_abc123def456", + "approverId": "usr_xyz789abc123", + "policyTemplateId": null + } + ], + "authType": "session", + "authenticatedUser": { + "id": "usr_abc123def456", + "email": "user@company.com" } - ] + } } } }, @@ -7171,6 +7230,24 @@ } } ], + "requestBody": { + "required": true, + "description": "Delete comment request body", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User ID of the comment author (required for API key auth, ignored for JWT auth)", + "example": "usr_abc123def456" + } + } + } + } + } + }, "responses": { "200": { "description": "Comment deleted successfully", @@ -13507,6 +13584,11 @@ "description": "Description of the attachment", "example": "Meeting notes from Q4 planning session", "maxLength": 500 + }, + "userId": { + "type": "string", + "description": "User ID of the user uploading the attachment (required for API key auth, ignored for JWT auth)", + "example": "usr_abc123def456" } }, "required": [ @@ -13675,6 +13757,11 @@ "items": { "$ref": "#/components/schemas/UploadAttachmentDto" } + }, + "userId": { + "type": "string", + "description": "User ID of the comment author (required for API key auth, ignored for JWT auth)", + "example": "usr_abc123def456" } }, "required": [ @@ -13691,6 +13778,11 @@ "description": "Updated content of the comment", "example": "This task needs to be completed by end of week (updated)", "maxLength": 2000 + }, + "userId": { + "type": "string", + "description": "User ID of the comment author (required for API key auth, ignored for JWT auth)", + "example": "usr_abc123def456" } }, "required": [ From 3a53acce9230d343c59348e49851c05308952b2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:56:38 -0500 Subject: [PATCH 2/4] [dev] [Itsnotaka] daniel/ai-policy-editor-improv (#1939) * feat(ai): enhance policy assistant prompts for user guidance * chore(db): update package.json exports to include types for modules * fix(app): change overflow style in PolicyDetails component * fix(app): enable AI assistant by default in PolicyDetails component --------- Co-authored-by: Daniel Fu Co-authored-by: Mariano Fuentes --- .../editor/components/PolicyDetails.tsx | 31 +++++++++---------- .../components/ai/policy-ai-assistant.tsx | 13 +++++--- packages/db/package.json | 12 +++++-- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index cc784717a..6a82e5bc0 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -90,7 +90,7 @@ export function PolicyContentManager({ displayFormat = 'EDITOR', pdfUrl, }: PolicyContentManagerProps) { - const [showAiAssistant, setShowAiAssistant] = useState(false); + const [showAiAssistant, setShowAiAssistant] = useState(true); const [editorKey, setEditorKey] = useState(0); const [currentContent, setCurrentContent] = useState>(() => { const formattedContent = Array.isArray(policyContent) @@ -104,7 +104,6 @@ export function PolicyContentManager({ const [chatErrorMessage, setChatErrorMessage] = useState(null); const diffViewerRef = useRef(null); - const isAiPolicyAssistantEnabled = true; const scrollToDiffViewer = useCallback(() => { diffViewerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); @@ -189,7 +188,7 @@ export function PolicyContentManager({
-
+
@@ -206,19 +205,17 @@ export function PolicyContentManager({ PDF View - {!isPendingApproval && - displayFormat === 'EDITOR' && - isAiPolicyAssistantEnabled && ( - - )} + {!isPendingApproval && ( + + )}
- {showAiAssistant && isAiPolicyAssistantEnabled && ( + {showAiAssistant && (
{messages.length === 0 ? (
-

Ask me to help edit this policy.

+

+ I can help you edit, adapt, or check this policy for compliance. Try asking me + things like: +

    -
  • "Add a data retention section"
  • -
  • "Make this more SOC 2 compliant"
  • -
  • "Simplify the language"
  • +
  • "Adapt this for a fully remote, distributed team."
  • +
  • + "Can I shorten the data retention timeframe and still meet SOC 2 standards?" +
  • +
  • "Modify the access control section to include contractors."
) : ( diff --git a/packages/db/package.json b/packages/db/package.json index f65ac99d6..708f6e1b9 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,8 +14,16 @@ "typescript": "^5.9.2" }, "exports": { - ".": "./dist/index.js", - "./postinstall": "./dist/postinstall.js" + ".": { + "import": "./dist/index.js", + "types": "./src/index.ts", + "default": "./dist/index.js" + }, + "./postinstall": { + "import": "./dist/postinstall.js", + "types": "./src/postinstall.ts", + "default": "./dist/postinstall.js" + } }, "bin": { "comp-prisma-postinstall": "./dist/postinstall.js" From d7f7af8caa708d4114837fc6b5b95477317841da Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Wed, 17 Dec 2025 14:20:38 -0800 Subject: [PATCH 3/4] [dev] [Carhartlewis] lewis/comp-jumpcloud-integration (#1941) * feat(jumpcloud): add integration for syncing employees from JumpCloud * fix(jumpcloud): added rippling back as a valid provider * fix(jumpcloud): updated help text * fix(sync): normalize email to lowercase for consistent database operations * chore(deps): update package versions and bun.lock for consistency --- .../controllers/sync.controller.ts | 526 +++++++++++++++++- .../sync-employees-schedule.ts | 39 ++ .../all/components/TeamMembersClient.tsx | 23 +- .../(app)/[orgId]/people/all/data/queries.ts | 18 +- .../people/all/hooks/useEmployeeSync.ts | 22 +- bun.lock | 22 +- package.json | 60 +- packages/docs/openapi.json | 37 ++ .../jumpcloud/checks/employee-sync.ts | 169 ++++++ .../src/manifests/jumpcloud/checks/index.ts | 7 + .../src/manifests/jumpcloud/index.ts | 60 ++ .../src/manifests/jumpcloud/types.ts | 153 +++++ .../src/manifests/jumpcloud/variables.ts | 35 ++ .../src/registry/index.ts | 2 + turbo.json | 2 +- 15 files changed, 1121 insertions(+), 54 deletions(-) create mode 100644 packages/integration-platform/src/manifests/jumpcloud/checks/employee-sync.ts create mode 100644 packages/integration-platform/src/manifests/jumpcloud/checks/index.ts create mode 100644 packages/integration-platform/src/manifests/jumpcloud/index.ts create mode 100644 packages/integration-platform/src/manifests/jumpcloud/types.ts create mode 100644 packages/integration-platform/src/manifests/jumpcloud/variables.ts diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index f34117014..32322dcca 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -242,10 +242,14 @@ export class SyncController { }; for (const gwUser of activeUsers) { + // Normalize email to lowercase for consistent database operations + // This matches how we build suspendedEmails and activeEmails sets + const normalizedEmail = gwUser.primaryEmail.toLowerCase(); + try { // Check if user already exists const existingUser = await db.user.findUnique({ - where: { email: gwUser.primaryEmail }, + where: { email: normalizedEmail }, }); let userId: string; @@ -256,8 +260,8 @@ export class SyncController { // Create new user const newUser = await db.user.create({ data: { - email: gwUser.primaryEmail, - name: gwUser.name.fullName || gwUser.primaryEmail.split('@')[0], + email: normalizedEmail, + name: gwUser.name.fullName || normalizedEmail.split('@')[0], emailVerified: true, // Google Workspace users are verified }, }); @@ -281,14 +285,14 @@ export class SyncController { }); results.reactivated++; results.details.push({ - email: gwUser.primaryEmail, + email: normalizedEmail, status: 'reactivated', reason: 'User is active again in Google Workspace', }); } else { results.skipped++; results.details.push({ - email: gwUser.primaryEmail, + email: normalizedEmail, status: 'skipped', reason: 'Already a member', }); @@ -308,14 +312,14 @@ export class SyncController { results.imported++; results.details.push({ - email: gwUser.primaryEmail, + email: normalizedEmail, status: 'imported', }); } catch (error) { this.logger.error(`Error importing Google Workspace user: ${error}`); results.errors++; results.details.push({ - email: gwUser.primaryEmail, + email: normalizedEmail, status: 'error', reason: error instanceof Error ? error.message : 'Unknown error', }); @@ -334,9 +338,10 @@ export class SyncController { }, }); - // Get the domain from active users to only check members with matching domain + // Get the domains from ALL users (including suspended) to track which domains Google Workspace manages + // This ensures members get deactivated even when an entire domain has no active users const gwDomains = new Set( - activeUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()), + users.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()), ); for (const member of allOrgMembers) { @@ -634,9 +639,10 @@ export class SyncController { `Found ${activeWorkers.length} active workers and ${inactiveEmails.size} inactive/terminated workers in Rippling`, ); - // Derive domains from Rippling workers to match against our members + // Derive domains from ALL Rippling workers (including inactive) to track which domains Rippling manages + // This ensures members get deactivated even when an entire domain has no active workers const ripplingDomains = new Set( - activeWorkers.map((w) => getWorkerEmail(w).split('@')[1]).filter(Boolean), + workers.map((w) => getWorkerEmail(w).split('@')[1]).filter(Boolean), ); // Get all existing members @@ -821,6 +827,502 @@ export class SyncController { }; } + /** + * Sync employees from JumpCloud + */ + @Post('jumpcloud/employees') + async syncJumpCloudEmployees(@Query() query: SyncQuery) { + const { organizationId, connectionId } = query; + + if (!organizationId || !connectionId) { + throw new HttpException( + 'organizationId and connectionId are required', + HttpStatus.BAD_REQUEST, + ); + } + + // Get the connection + const connection = await this.connectionRepository.findById(connectionId); + if (!connection) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + + if (connection.organizationId !== organizationId) { + throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); + } + + // Get the provider to check the slug + const provider = await db.integrationProvider.findUnique({ + where: { id: connection.providerId }, + }); + + if (!provider || provider.slug !== 'jumpcloud') { + throw new HttpException( + 'This endpoint only supports JumpCloud connections', + HttpStatus.BAD_REQUEST, + ); + } + + // Get credentials (API key auth) + const credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + + if (!credentials?.api_key) { + throw new HttpException( + 'No valid API key found. Please reconnect the integration.', + HttpStatus.UNAUTHORIZED, + ); + } + + // JumpCloud API types + interface JumpCloudUser { + _id: string; + username: string; + email: string; + firstname?: string; + lastname?: string; + displayname?: string; + department?: string; + jobTitle?: string; + state: 'ACTIVATED' | 'SUSPENDED' | 'STAGED' | 'PENDING_LOCK_STATE'; + activated: boolean; + suspended: boolean; + mfa?: { configured: boolean }; + totp_enabled?: boolean; + created?: string; + } + + interface JumpCloudUsersResponse { + totalCount: number; + results: JumpCloudUser[]; + } + + interface JumpCloudSystem { + _id: string; + displayName?: string; + hostname?: string; + os?: string; + version?: string; + arch?: string; + serialNumber?: string; + systemTimezone?: string; + lastContact?: string; + active?: boolean; + agentVersion?: string; + allowMultiFactorAuthentication?: boolean; + allowPublicKeyAuthentication?: boolean; + allowSshPasswordAuthentication?: boolean; + created?: string; + modifySSHDConfig?: boolean; + organization?: string; + remoteIP?: string; + } + + interface JumpCloudSystemsResponse { + totalCount: number; + results: JumpCloudSystem[]; + } + + interface JumpCloudUserSystemBinding { + id: string; + type: 'system'; + } + + const apiKey = credentials.api_key; + const users: JumpCloudUser[] = []; + + try { + // JumpCloud API v1 uses pagination with limit/skip + const limit = 100; + let skip = 0; + let hasMore = true; + + while (hasMore) { + const url = new URL('https://console.jumpcloud.com/api/systemusers'); + url.searchParams.set('limit', String(limit)); + url.searchParams.set('skip', String(skip)); + url.searchParams.set('sort', 'email'); + + const response = await fetch(url.toString(), { + headers: { + 'x-api-key': apiKey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 401) { + throw new HttpException( + 'JumpCloud API key is invalid. Please reconnect.', + HttpStatus.UNAUTHORIZED, + ); + } + const errorText = await response.text(); + this.logger.error( + `JumpCloud API error: ${response.status} ${response.statusText} - ${errorText}`, + ); + throw new HttpException( + 'Failed to fetch users from JumpCloud', + HttpStatus.BAD_GATEWAY, + ); + } + + const data: JumpCloudUsersResponse = await response.json(); + + if (data.results && data.results.length > 0) { + users.push(...data.results); + skip += data.results.length; + + if (data.results.length < limit || skip >= data.totalCount) { + hasMore = false; + } + } else { + hasMore = false; + } + } + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Error fetching JumpCloud users: ${error}`); + throw new HttpException( + 'Failed to fetch users from JumpCloud', + HttpStatus.BAD_GATEWAY, + ); + } + + // Fetch all systems from JumpCloud + const systems: JumpCloudSystem[] = []; + try { + const sysLimit = 100; + let sysSkip = 0; + let sysHasMore = true; + + while (sysHasMore) { + const sysUrl = new URL('https://console.jumpcloud.com/api/systems'); + sysUrl.searchParams.set('limit', String(sysLimit)); + sysUrl.searchParams.set('skip', String(sysSkip)); + + const sysResponse = await fetch(sysUrl.toString(), { + headers: { + 'x-api-key': apiKey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (sysResponse.ok) { + const sysData: JumpCloudSystemsResponse = await sysResponse.json(); + if (sysData.results && sysData.results.length > 0) { + systems.push(...sysData.results); + sysSkip += sysData.results.length; + if ( + sysData.results.length < sysLimit || + sysSkip >= sysData.totalCount + ) { + sysHasMore = false; + } + } else { + sysHasMore = false; + } + } else { + this.logger.warn( + 'Failed to fetch JumpCloud systems, continuing without device info', + ); + sysHasMore = false; + } + } + this.logger.log(`Fetched ${systems.length} systems from JumpCloud`); + } catch (error) { + this.logger.warn(`Error fetching JumpCloud systems: ${error}`); + } + + // Create a map of system ID to system details + const systemsById = new Map(systems.map((s) => [s._id, s])); + + // Fetch user-to-system bindings for each user + const userDevices = new Map(); + + for (const user of users) { + try { + const bindingsUrl = new URL( + `https://console.jumpcloud.com/api/v2/users/${user._id}/systems`, + ); + + const bindingsResponse = await fetch(bindingsUrl.toString(), { + headers: { + 'x-api-key': apiKey, + Accept: 'application/json', + }, + }); + + if (bindingsResponse.ok) { + const bindings: JumpCloudUserSystemBinding[] = + await bindingsResponse.json(); + const userSystems = bindings + .map((b) => systemsById.get(b.id)) + .filter((s): s is JumpCloudSystem => s !== undefined); + if (userSystems.length > 0) { + userDevices.set(user._id, userSystems); + } + } + } catch { + // Ignore binding fetch errors for individual users + } + } + + this.logger.log(`Found device bindings for ${userDevices.size} users`); + + // Helper to get full name + const getFullName = (user: JumpCloudUser): string => { + if (user.displayname) return user.displayname; + const parts = [user.firstname, user.lastname].filter(Boolean); + if (parts.length > 0) return parts.join(' '); + return user.username; + }; + + // Filter to active users (exclude staged and suspended) + const activeUsers = users.filter( + (u) => u.state === 'ACTIVATED' && u.activated && !u.suspended, + ); + const suspendedEmails = new Set( + users + .filter((u) => u.suspended || u.state === 'SUSPENDED') + .map((u) => u.email.toLowerCase()), + ); + const activeEmails = new Set(activeUsers.map((u) => u.email.toLowerCase())); + + this.logger.log( + `Found ${activeUsers.length} active users and ${suspendedEmails.size} suspended users in JumpCloud`, + ); + + // Import users into the organization + const results = { + imported: 0, + skipped: 0, + deactivated: 0, + reactivated: 0, + errors: 0, + totalDevices: systems.length, + usersWithDevices: userDevices.size, + details: [] as Array<{ + email: string; + status: + | 'imported' + | 'skipped' + | 'deactivated' + | 'reactivated' + | 'error'; + reason?: string; + devices?: Array<{ + id: string; + name: string; + os: string; + lastContact?: string; + }>; + }>, + }; + + // Helper to get devices for a user + const getUserDeviceDetails = (userId: string) => { + const devices = userDevices.get(userId); + if (!devices || devices.length === 0) return undefined; + return devices.map((d) => ({ + id: d._id, + name: d.displayName || d.hostname || 'Unknown Device', + os: d.os ? `${d.os} ${d.version || ''}`.trim() : 'Unknown OS', + lastContact: d.lastContact, + })); + }; + + for (const jcUser of activeUsers) { + // Normalize email to lowercase for consistent database operations + // This matches how we build suspendedEmails and activeEmails sets + const normalizedEmail = jcUser.email.toLowerCase(); + + try { + // Check if user already exists + const existingUser = await db.user.findUnique({ + where: { email: normalizedEmail }, + }); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + } else { + // Create new user + const newUser = await db.user.create({ + data: { + email: normalizedEmail, + name: getFullName(jcUser), + emailVerified: true, + }, + }); + userId = newUser.id; + } + + // Get device info for this user + const deviceDetails = getUserDeviceDetails(jcUser._id); + + // Check if member already exists in this org + const existingMember = await db.member.findFirst({ + where: { + organizationId, + userId, + }, + }); + + if (existingMember) { + // If member was deactivated but is now active in JumpCloud, reactivate them + if (existingMember.deactivated) { + await db.member.update({ + where: { id: existingMember.id }, + data: { deactivated: false, isActive: true }, + }); + results.reactivated++; + results.details.push({ + email: normalizedEmail, + status: 'reactivated', + reason: 'User is active again in JumpCloud', + devices: deviceDetails, + }); + } else { + results.skipped++; + results.details.push({ + email: normalizedEmail, + status: 'skipped', + reason: 'Already a member', + devices: deviceDetails, + }); + } + continue; + } + + // Create member - always as employee, admins can be promoted manually + await db.member.create({ + data: { + organizationId, + userId, + role: 'employee', + isActive: true, + }, + }); + + results.imported++; + results.details.push({ + email: normalizedEmail, + status: 'imported', + devices: deviceDetails, + }); + } catch (error) { + this.logger.error(`Error importing JumpCloud user: ${error}`); + results.errors++; + results.details.push({ + email: normalizedEmail, + status: 'error', + reason: error instanceof Error ? error.message : 'Unknown error', + devices: getUserDeviceDetails(jcUser._id), + }); + } + } + + // Deactivate members who are suspended OR deleted in JumpCloud + const allOrgMembers = await db.member.findMany({ + where: { + organizationId, + deactivated: false, + }, + include: { + user: true, + }, + }); + + // Get the domains from ALL users (including suspended) to track which domains JumpCloud manages + // This ensures members get deactivated even when an entire domain has no active users + const jcDomains = new Set( + users.map((u) => u.email.split('@')[1]?.toLowerCase()), + ); + + for (const member of allOrgMembers) { + const memberEmail = member.user.email.toLowerCase(); + const memberDomain = memberEmail.split('@')[1]; + + // Only check members whose email domain matches the JumpCloud domain + if (!memberDomain || !jcDomains.has(memberDomain)) { + continue; + } + + // If this member's email is suspended OR not in the active list, deactivate them + const isSuspended = suspendedEmails.has(memberEmail); + const isDeleted = + !activeEmails.has(memberEmail) && !suspendedEmails.has(memberEmail); + + if (isSuspended || isDeleted) { + try { + await db.member.update({ + where: { id: member.id }, + data: { deactivated: true, isActive: false }, + }); + results.deactivated++; + results.details.push({ + email: member.user.email, + status: 'deactivated', + reason: isSuspended + ? 'User is suspended in JumpCloud' + : 'User was removed from JumpCloud', + }); + } catch (error) { + this.logger.error(`Error deactivating member: ${error}`); + } + } + } + + this.logger.log( + `JumpCloud sync complete: ${results.imported} imported, ${results.reactivated} reactivated, ${results.deactivated} deactivated, ${results.skipped} skipped, ${results.errors} errors`, + ); + + return { + success: true, + totalFound: activeUsers.length, + totalSuspended: suspendedEmails.size, + ...results, + }; + } + + /** + * Check if JumpCloud is connected for an organization + */ + @Post('jumpcloud/status') + async getJumpCloudStatus(@Query('organizationId') organizationId: string) { + if (!organizationId) { + throw new HttpException( + 'organizationId is required', + HttpStatus.BAD_REQUEST, + ); + } + + const connection = await this.connectionRepository.findBySlugAndOrg( + 'jumpcloud', + organizationId, + ); + + if (!connection || connection.status !== 'active') { + return { + connected: false, + connectionId: null, + lastSyncAt: null, + nextSyncAt: null, + }; + } + + return { + connected: true, + connectionId: connection.id, + lastSyncAt: connection.lastSyncAt?.toISOString() ?? null, + nextSyncAt: connection.nextSyncAt?.toISOString() ?? null, + }; + } + /** * Get the current employee sync provider for an organization */ @@ -868,7 +1370,7 @@ export class SyncController { // Validate provider if set if (provider) { - const validProviders = ['google-workspace', 'rippling']; + const validProviders = ['google-workspace', 'rippling', 'jumpcloud']; if (!validProviders.includes(provider)) { throw new HttpException( `Invalid provider. Must be one of: ${validProviders.join(', ')}`, diff --git a/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts b/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts index a4f63c803..4e9355593 100644 --- a/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts +++ b/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts @@ -194,6 +194,9 @@ async function syncProvider(params: SyncProviderParams): Promise { case 'rippling': return syncRippling({ connectionId, organizationId }); + case 'jumpcloud': + return syncJumpCloud({ connectionId, organizationId }); + default: throw new Error(`No sync handler for provider: ${providerSlug}`); } @@ -272,3 +275,39 @@ async function syncRippling({ errors: data.errors || 0, }; } + +async function syncJumpCloud({ + connectionId, + organizationId, +}: { + connectionId: string; + organizationId: string; +}): Promise { + const url = new URL( + `${API_BASE_URL}/v1/integrations/sync/jumpcloud/employees`, + ); + url.searchParams.set('organizationId', organizationId); + url.searchParams.set('connectionId', connectionId); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`JumpCloud sync failed: ${response.status} - ${errorBody}`); + } + + const data = await response.json(); + return { + success: data.success, + imported: data.imported || 0, + reactivated: data.reactivated || 0, + deactivated: data.deactivated || 0, + skipped: data.skipped || 0, + errors: data.errors || 0, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index f92271230..6c5c7e7ea 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -23,10 +23,10 @@ import type { MemberWithUser, TeamMembersData } from './TeamMembers'; import type { removeMember } from '../actions/removeMember'; import type { revokeInvitation } from '../actions/revokeInvitation'; +import { usePeopleActions } from '@/hooks/use-people-api'; import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; import { InviteMembersModal } from './InviteMembersModal'; -import { usePeopleActions } from '@/hooks/use-people-api'; // Define prop types using typeof for the actions still used interface TeamMembersClientProps { @@ -74,6 +74,7 @@ export function TeamMembersClient({ const { googleWorkspaceConnectionId, ripplingConnectionId, + jumpcloudConnectionId, selectedProvider, isSyncing, syncEmployees, @@ -85,7 +86,7 @@ export function TeamMembersClient({ const lastSyncAt = employeeSyncData.lastSyncAt; const nextSyncAt = employeeSyncData.nextSyncAt; - const handleEmployeeSync = async (provider: 'google-workspace' | 'rippling') => { + const handleEmployeeSync = async (provider: 'google-workspace' | 'rippling' | 'jumpcloud') => { const result = await syncEmployees(provider); if (result?.success) { router.refresh(); @@ -383,6 +384,24 @@ export function TeamMembersClient({
)} + {jumpcloudConnectionId && ( + +
+ JumpCloud + JumpCloud + {selectedProvider === 'jumpcloud' && ( + Active + )} +
+
+ )}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/data/queries.ts b/apps/app/src/app/(app)/[orgId]/people/all/data/queries.ts index 4bf48380c..d6bdafbc3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/data/queries.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/data/queries.ts @@ -3,7 +3,8 @@ import { serverApi } from '@/lib/server-api-client'; export interface EmployeeSyncConnectionsData { googleWorkspaceConnectionId: string | null; ripplingConnectionId: string | null; - selectedProvider: 'google-workspace' | 'rippling' | null | undefined; + jumpcloudConnectionId: string | null; + selectedProvider: 'google-workspace' | 'rippling' | 'jumpcloud' | null | undefined; lastSyncAt: Date | null; nextSyncAt: Date | null; } @@ -18,14 +19,17 @@ interface ConnectionStatus { export async function getEmployeeSyncConnections( organizationId: string, ): Promise { - const [gwResponse, ripplingResponse, providerResponse] = await Promise.all([ + const [gwResponse, ripplingResponse, jumpcloudResponse, providerResponse] = await Promise.all([ serverApi.post( `/v1/integrations/sync/google-workspace/status?organizationId=${organizationId}`, ), serverApi.post( `/v1/integrations/sync/rippling/status?organizationId=${organizationId}`, ), - serverApi.get<{ provider: 'google-workspace' | 'rippling' | null }>( + serverApi.post( + `/v1/integrations/sync/jumpcloud/status?organizationId=${organizationId}`, + ), + serverApi.get<{ provider: 'google-workspace' | 'rippling' | 'jumpcloud' | null }>( `/v1/integrations/sync/employee-sync-provider?organizationId=${organizationId}`, ), ]); @@ -37,7 +41,9 @@ export async function getEmployeeSyncConnections( ? gwResponse.data : selectedProviderSlug === 'rippling' ? ripplingResponse.data - : null; + : selectedProviderSlug === 'jumpcloud' + ? jumpcloudResponse.data + : null; return { googleWorkspaceConnectionId: @@ -48,6 +54,10 @@ export async function getEmployeeSyncConnections( ripplingResponse.data?.connected && ripplingResponse.data.connectionId ? ripplingResponse.data.connectionId : null, + jumpcloudConnectionId: + jumpcloudResponse.data?.connected && jumpcloudResponse.data.connectionId + ? jumpcloudResponse.data.connectionId + : null, selectedProvider: selectedProviderSlug, lastSyncAt: selectedConnection?.lastSyncAt ? new Date(selectedConnection.lastSyncAt) : null, nextSyncAt: selectedConnection?.nextSyncAt ? new Date(selectedConnection.nextSyncAt) : null, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts index cc0e76321..871c41f08 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts @@ -7,7 +7,7 @@ import useSWR from 'swr'; import type { EmployeeSyncConnectionsData } from '../data/queries'; -type SyncProvider = 'google-workspace' | 'rippling'; +type SyncProvider = 'google-workspace' | 'rippling' | 'jumpcloud'; interface SyncResult { success: boolean; @@ -27,6 +27,7 @@ interface UseEmployeeSyncOptions { interface UseEmployeeSyncReturn { googleWorkspaceConnectionId: string | null; ripplingConnectionId: string | null; + jumpcloudConnectionId: string | null; selectedProvider: SyncProvider | null; isSyncing: boolean; syncEmployees: (provider: SyncProvider) => Promise; @@ -47,6 +48,11 @@ const PROVIDER_CONFIG = { shortName: 'Rippling', logo: 'https://img.logo.dev/rippling.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ&format=png&retina=true', }, + jumpcloud: { + name: 'JumpCloud', + shortName: 'JumpCloud', + logo: 'https://img.logo.dev/jumpcloud.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ&format=png&retina=true', + }, } as const; export const useEmployeeSync = ({ @@ -63,6 +69,7 @@ export const useEmployeeSync = ({ const googleWorkspaceConnectionId = data?.googleWorkspaceConnectionId ?? null; const ripplingConnectionId = data?.ripplingConnectionId ?? null; + const jumpcloudConnectionId = data?.jumpcloudConnectionId ?? null; const selectedProvider = data?.selectedProvider ?? null; const setSyncProvider = async (provider: SyncProvider | null) => { @@ -86,7 +93,11 @@ export const useEmployeeSync = ({ const syncEmployees = async (provider: SyncProvider): Promise => { const connectionId = - provider === 'google-workspace' ? googleWorkspaceConnectionId : ripplingConnectionId; + provider === 'google-workspace' + ? googleWorkspaceConnectionId + : provider === 'rippling' + ? ripplingConnectionId + : jumpcloudConnectionId; if (!connectionId) { toast.error(`${PROVIDER_CONFIG[provider].name} is not connected`); @@ -149,11 +160,16 @@ export const useEmployeeSync = ({ return { googleWorkspaceConnectionId, ripplingConnectionId, + jumpcloudConnectionId, selectedProvider, isSyncing, syncEmployees, setSyncProvider, - hasAnyConnection: !!(googleWorkspaceConnectionId || ripplingConnectionId), + hasAnyConnection: !!( + googleWorkspaceConnectionId || + ripplingConnectionId || + jumpcloudConnectionId + ), getProviderName, getProviderLogo, }; diff --git a/bun.lock b/bun.lock index 0dce40e17..fac055ec8 100644 --- a/bun.lock +++ b/bun.lock @@ -604,7 +604,7 @@ "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DDNZSZn6OuExVBJBAWdk3VeyQPH+pYwSykixePhzll9EnT3aakapMYr5gjw3wMl+eZ0tLplythHL1TfIehUZ0g=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6fHjDfCbjfj4vyMExuLei7ir2///E5sNwNZaobdJsJIxJjDSsjzSLGO/aUI7p9eOnB8XctDrDSF5ilwDGpi6eg=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-grIlvzh+jzMoKNOnn5Xe/8fdYiJOs0ThMVetsGzqflvMkUNF3B83t5i0kf4XqiM8MwTJ8gkdOA4VeQOZKR7TkA=="], @@ -2568,7 +2568,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], + "ai": ["ai@5.0.115", "", { "dependencies": { "@ai-sdk/gateway": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aVuHx0orGxXvhyL7oXUyW8TnWQE6Al8f3Bl6VZjz0WHMV+WaACHPkSyvQ3wje2QCUGzdl5DBF5d+OaXyghPQyg=="], "ai-elements": ["ai-elements@1.6.3", "", { "bin": { "elements": "index.js" } }, "sha512-M0A5NrUqCMV2w9hJV+kOuFi+XKo1BjlawheaqfrG+jovWsyXyCalOOH2dM4w/BwjABwje5yZX5MnMIUwnFmqzg=="], @@ -5744,6 +5744,10 @@ "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@ai-sdk/react/ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], + + "@ai-sdk/rsc/ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], + "@angular-devkit/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "@angular-devkit/core/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -5790,6 +5794,8 @@ "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@browserbasehq/stagehand/ai": ["ai@5.0.113", "", { "dependencies": { "@ai-sdk/gateway": "2.0.21", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g=="], + "@browserbasehq/stagehand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@browserbasehq/stagehand/puppeteer-core": ["puppeteer-core@22.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "chromium-bidi": "0.6.3", "debug": "^4.3.6", "devtools-protocol": "0.0.1312386", "ws": "^8.18.0" } }, "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA=="], @@ -6978,6 +6984,10 @@ "zod-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + + "@ai-sdk/rsc/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + "@angular-devkit/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@angular-devkit/schematics/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], @@ -7004,6 +7014,8 @@ "@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@browserbasehq/stagehand/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA=="], + "@browserbasehq/stagehand/puppeteer-core/@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="], "@browserbasehq/stagehand/puppeteer-core/chromium-bidi": ["chromium-bidi@0.6.3", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A=="], @@ -7594,6 +7606,10 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@ai-sdk/react/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + + "@ai-sdk/rsc/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@angular-devkit/schematics/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "@angular-devkit/schematics/ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7604,6 +7620,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@browserbasehq/stagehand/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@browserbasehq/stagehand/puppeteer-core/chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], "@calcom/atoms/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/package.json b/package.json index cae784e29..55396077d 100644 --- a/package.json +++ b/package.json @@ -3,52 +3,52 @@ "version": "1.50.0", "devDependencies": { "@azure/core-http": "^3.0.5", - "@azure/core-rest-pipeline": "^1.21.0", - "@azure/core-tracing": "^1.2.0", - "@azure/identity": "^4.10.0", + "@azure/core-rest-pipeline": "^1.22.2", + "@azure/core-tracing": "^1.3.1", + "@azure/identity": "^4.13.0", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", - "@hookform/resolvers": "^5.1.1", - "@number-flow/react": "^0.5.9", + "@hookform/resolvers": "^5.2.2", + "@number-flow/react": "^0.5.10", "@prisma/adapter-pg": "6.10.1", "@react-email/components": "^0.0.41", - "@react-email/render": "^1.1.2", + "@react-email/render": "^1.4.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^11.0.3", - "@semantic-release/npm": "^12.0.1", - "@semantic-release/release-notes-generator": "^14.0.3", - "@types/bun": "^1.2.15", + "@semantic-release/github": "^11.0.6", + "@semantic-release/npm": "^12.0.2", + "@semantic-release/release-notes-generator": "^14.1.0", + "@types/bun": "^1.3.4", "@types/d3": "^7.4.3", - "@types/lodash": "^4.17.17", - "@types/react": "^19.1.6", - "@types/react-dom": "^19.1.1", - "ai": "^5.0.0", - "concurrently": "^9.1.2", + "@types/lodash": "^4.17.21", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "ai": "^5.0.115", + "concurrently": "^9.2.1", "d3": "^7.9.0", "date-fns": "^4.1.0", - "dayjs": "^1.11.13", - "execa": "^9.0.0", + "dayjs": "^1.11.19", + "execa": "^9.6.1", "gitmoji": "^1.1.1", "gray-matter": "^4.0.3", "husky": "^9.1.7", - "prettier": "^3.5.3", - "prettier-plugin-organize-imports": "^4.1.0", - "prettier-plugin-tailwindcss": "^0.6.0", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.6.14", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-email": "^4.0.15", - "react-hook-form": "^7.61.1", - "semantic-release": "^24.2.8", + "react-email": "^4.3.2", + "react-hook-form": "^7.68.0", + "semantic-release": "^24.2.9", "semantic-release-discord": "^1.2.0", - "semantic-release-discord-notifier": "^1.0.11", - "sharp": "^0.34.2", + "semantic-release-discord-notifier": "^1.1.1", + "sharp": "^0.34.5", "syncpack": "^13.0.4", - "tsup": "^8.5.0", - "turbo": "^2.5.4", - "typescript": "^5.8.3", - "use-debounce": "^10.0.4" + "tsup": "^8.5.1", + "turbo": "^2.6.3", + "typescript": "^5.9.3", + "use-debounce": "^10.0.6" }, "engines": { "node": ">=18" @@ -96,6 +96,6 @@ "react-syntax-highlighter": "^15.6.6", "unpdf": "^1.4.0", "xlsx": "^0.18.5", - "zod": "^4.0.0" + "zod": "^4.2.1" } } diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index dbe8e60fe..cc72e3346 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -10774,6 +10774,43 @@ ] } }, + "/v1/integrations/sync/jumpcloud/employees": { + "post": { + "operationId": "SyncController_syncJumpCloudEmployees_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sync" + ] + } + }, + "/v1/integrations/sync/jumpcloud/status": { + "post": { + "operationId": "SyncController_getJumpCloudStatus_v1", + "parameters": [ + { + "name": "organizationId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sync" + ] + } + }, "/v1/integrations/sync/employee-sync-provider": { "get": { "operationId": "SyncController_getEmployeeSyncProvider_v1", diff --git a/packages/integration-platform/src/manifests/jumpcloud/checks/employee-sync.ts b/packages/integration-platform/src/manifests/jumpcloud/checks/employee-sync.ts new file mode 100644 index 000000000..80b533dab --- /dev/null +++ b/packages/integration-platform/src/manifests/jumpcloud/checks/employee-sync.ts @@ -0,0 +1,169 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import type { JumpCloudEmployee, JumpCloudUser, JumpCloudUsersResponse } from '../types'; +import { includeSuspendedVariable } from '../variables'; + +/** + * Helper to determine user status from JumpCloud state + */ +const getUserStatus = (user: JumpCloudUser): 'active' | 'suspended' | 'staged' => { + if (user.suspended) return 'suspended'; + if (user.state === 'STAGED') return 'staged'; + if (user.state === 'ACTIVATED' && user.activated) return 'active'; + return 'suspended'; +}; + +/** + * Helper to build full name from user data + */ +const getFullName = (user: JumpCloudUser): string => { + if (user.displayname) return user.displayname; + const parts = [user.firstname, user.lastname].filter(Boolean); + if (parts.length > 0) return parts.join(' '); + return user.username; +}; + +/** + * Employee Sync Check + * + * Fetches all users from JumpCloud and syncs them as employees. + * This provides a complete list of team members for access review and compliance. + */ +export const employeeSyncCheck: IntegrationCheck = { + id: 'employee-sync', + name: 'Employee Sync', + description: 'Sync users from JumpCloud as employees for access review and verification', + taskMapping: TASK_TEMPLATES.employeeAccess, + defaultSeverity: 'info', + variables: [includeSuspendedVariable], + + run: async (ctx: CheckContext) => { + ctx.log('Starting JumpCloud Employee Sync'); + + const includeSuspended = ctx.variables.include_suspended === 'true'; + + // JumpCloud API v1 uses pagination with limit/skip + const allUsers: JumpCloudUser[] = []; + const limit = 100; + let skip = 0; + let hasMore = true; + + ctx.log('Fetching users from JumpCloud...'); + + while (hasMore) { + // Note: path must NOT start with / when using baseUrl, otherwise URL constructor + // treats it as absolute from domain root + const response = await ctx.fetch('systemusers', { + baseUrl: 'https://console.jumpcloud.com/api/', + params: { + limit: String(limit), + skip: String(skip), + sort: 'email', + }, + }); + + if (response.results && response.results.length > 0) { + allUsers.push(...response.results); + skip += response.results.length; + + // Check if we've fetched all users + if (response.results.length < limit || skip >= response.totalCount) { + hasMore = false; + } + } else { + hasMore = false; + } + + ctx.log(`Fetched ${allUsers.length} of ${response.totalCount} users`); + } + + ctx.log(`Fetched ${allUsers.length} total users from JumpCloud`); + + // Filter users based on configuration + const filteredUsers = allUsers.filter((user) => { + // Always exclude staged users (not yet activated) + if (user.state === 'STAGED') return false; + + // Include/exclude suspended users based on variable + if (user.suspended && !includeSuspended) return false; + + return true; + }); + + ctx.log(`Found ${filteredUsers.length} users after filtering`); + + // Build manager lookup map + const userById = new Map(allUsers.map((u) => [u._id, u])); + + // Transform to employee format + const employees: JumpCloudEmployee[] = filteredUsers.map((user) => ({ + id: user._id, + email: user.email, + name: getFullName(user), + firstName: user.firstname, + lastName: user.lastname, + username: user.username, + jobTitle: user.jobTitle, + department: user.department, + employeeType: user.employeeType, + managerId: user.manager, + status: getUserStatus(user), + mfaEnabled: user.mfa?.configured ?? user.totp_enabled ?? false, + isAdmin: user.sudo ?? false, + createdAt: user.created, + })); + + // Calculate statistics + const activeUsers = employees.filter((e) => e.status === 'active'); + const suspendedUsers = employees.filter((e) => e.status === 'suspended'); + const mfaEnabled = employees.filter((e) => e.mfaEnabled); + const admins = employees.filter((e) => e.isAdmin); + + // Group by department for summary + const departmentCounts = new Map(); + for (const emp of employees) { + const dept = emp.department || 'No Department'; + departmentCounts.set(dept, (departmentCounts.get(dept) || 0) + 1); + } + + const departmentSummary = Array.from(departmentCounts.entries()) + .map(([name, count]) => ({ department: name, count })) + .sort((a, b) => b.count - a.count); + + // Build employee list with manager names resolved + const employeeList = employees.map((emp) => { + let managerName: string | undefined; + if (emp.managerId) { + const manager = userById.get(emp.managerId); + if (manager) { + managerName = getFullName(manager); + } + } + + return { + ...emp, + managerName, + }; + }); + + // Pass with the full employee list as evidence + ctx.pass({ + title: 'JumpCloud Employee List', + resourceType: 'organization', + resourceId: 'jumpcloud', + description: `Retrieved ${employees.length} employees from JumpCloud (${activeUsers.length} active, ${suspendedUsers.length} suspended, ${admins.length} admins, ${mfaEnabled.length} with MFA)`, + evidence: { + totalUsers: employees.length, + activeCount: activeUsers.length, + suspendedCount: suspendedUsers.length, + adminCount: admins.length, + mfaEnabledCount: mfaEnabled.length, + departmentSummary, + reviewedAt: new Date().toISOString(), + employees: employeeList, + }, + }); + + ctx.log('JumpCloud Employee Sync complete'); + }, +}; diff --git a/packages/integration-platform/src/manifests/jumpcloud/checks/index.ts b/packages/integration-platform/src/manifests/jumpcloud/checks/index.ts new file mode 100644 index 000000000..df3c6b750 --- /dev/null +++ b/packages/integration-platform/src/manifests/jumpcloud/checks/index.ts @@ -0,0 +1,7 @@ +/** + * JumpCloud Check Exports + * + * Export all checks from this folder for use in the manifest. + */ + +export { employeeSyncCheck } from './employee-sync'; diff --git a/packages/integration-platform/src/manifests/jumpcloud/index.ts b/packages/integration-platform/src/manifests/jumpcloud/index.ts new file mode 100644 index 000000000..db21e8356 --- /dev/null +++ b/packages/integration-platform/src/manifests/jumpcloud/index.ts @@ -0,0 +1,60 @@ +/** + * JumpCloud Integration Manifest + * + * This integration connects to JumpCloud to sync users as employees. + * JumpCloud is an identity and access management platform that provides + * a cloud directory for managing users, devices, and access. + * + * @see https://docs.jumpcloud.com/api/1.0/index.html + * @see https://docs.jumpcloud.com/api/2.0/index.html + */ + +import type { IntegrationManifest } from '../../types'; +import { employeeSyncCheck } from './checks'; + +export const manifest: IntegrationManifest = { + id: 'jumpcloud', + name: 'JumpCloud', + description: 'Sync users from JumpCloud as employees for access review and compliance.', + category: 'Identity & Access', + logoUrl: 'https://img.logo.dev/jumpcloud.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ', + docsUrl: 'https://docs.trycomp.ai/integrations/jumpcloud', + + // JumpCloud API v1 base URL (used for systemusers endpoint) + // Note: trailing slash is required for proper URL construction + baseUrl: 'https://console.jumpcloud.com/api/', + defaultHeaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + + auth: { + type: 'api_key', + config: { + in: 'header', + name: 'x-api-key', + }, + }, + + credentialFields: [ + { + id: 'api_key', + label: 'JumpCloud Admin API Key', + type: 'password', + required: true, + placeholder: 'Your JumpCloud API key', + helpText: 'https://jumpcloud.com/university/resources/guided-simulations/generating-a-new-api-key', + }, + ], + + // Supports both checks and sync capabilities + capabilities: ['checks', 'sync'], + + // Employee sync check + checks: [employeeSyncCheck], + + isActive: true, +}; + +export default manifest; +export * from './types'; diff --git a/packages/integration-platform/src/manifests/jumpcloud/types.ts b/packages/integration-platform/src/manifests/jumpcloud/types.ts new file mode 100644 index 000000000..344cb3259 --- /dev/null +++ b/packages/integration-platform/src/manifests/jumpcloud/types.ts @@ -0,0 +1,153 @@ +/** + * JumpCloud API Types + * + * Types for JumpCloud System Users API v1 + * @see https://docs.jumpcloud.com/api/1.0/index.html + */ + +/** + * JumpCloud System User + * Represents a user in JumpCloud's directory + */ +export interface JumpCloudUser { + /** Unique identifier for the user */ + _id: string; + + /** User's login username */ + username: string; + + /** User's email address (required, globally unique) */ + email: string; + + /** User's first name */ + firstname?: string; + + /** User's last name */ + lastname?: string; + + /** User's display name */ + displayname?: string; + + /** User's job title */ + jobTitle?: string; + + /** Department the user belongs to */ + department?: string; + + /** Cost center */ + costCenter?: string; + + /** Employee identifier */ + employeeIdentifier?: string; + + /** Employee type (e.g., full-time, contractor) */ + employeeType?: string; + + /** Company name */ + company?: string; + + /** User's location */ + location?: string; + + /** Description / notes about the user */ + description?: string; + + /** Manager's user ID */ + manager?: string; + + /** User's current state */ + state: 'ACTIVATED' | 'SUSPENDED' | 'STAGED' | 'PENDING_LOCK_STATE'; + + /** Whether the user account is activated */ + activated: boolean; + + /** Whether the user account is suspended */ + suspended: boolean; + + /** Whether MFA is enabled for the user */ + mfa?: { + configured: boolean; + exclusion: boolean; + exclusionUntil?: string; + }; + + /** Whether TOTP is enabled */ + totp_enabled?: boolean; + + /** Whether the user has admin privileges */ + sudo?: boolean; + + /** Whether this is an LDAP binding user */ + ldap_binding_user?: boolean; + + /** Whether password never expires */ + password_never_expires?: boolean; + + /** Account creation timestamp */ + created?: string; + + /** Last password set timestamp */ + password_date?: string; + + /** Account locked status */ + account_locked?: boolean; + + /** Account locked date */ + account_locked_date?: string; + + /** User's phone numbers */ + phoneNumbers?: Array<{ + type: string; + number: string; + }>; + + /** User's addresses */ + addresses?: Array<{ + type: string; + streetAddress?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + }>; + + /** External source synchronization */ + external_source_type?: string; + + /** External DN (for AD/LDAP synced users) */ + external_dn?: string; + + /** Organization ID */ + organization?: string; +} + +/** + * Response from JumpCloud System Users list endpoint + */ +export interface JumpCloudUsersResponse { + /** Total count of users matching the query */ + totalCount: number; + + /** Array of user objects */ + results: JumpCloudUser[]; +} + +/** + * Simplified employee record for sync + */ +export interface JumpCloudEmployee { + id: string; + email: string; + name: string; + firstName?: string; + lastName?: string; + username: string; + jobTitle?: string; + department?: string; + employeeType?: string; + managerId?: string; + status: 'active' | 'suspended' | 'staged'; + mfaEnabled: boolean; + isAdmin: boolean; + createdAt?: string; +} diff --git a/packages/integration-platform/src/manifests/jumpcloud/variables.ts b/packages/integration-platform/src/manifests/jumpcloud/variables.ts new file mode 100644 index 000000000..53f24da9f --- /dev/null +++ b/packages/integration-platform/src/manifests/jumpcloud/variables.ts @@ -0,0 +1,35 @@ +/** + * Shared Variables for JumpCloud Integration + * + * Variables that can be reused across multiple checks. + */ + +import type { CheckVariable } from '../../types'; + +/** + * Whether to include suspended users in the sync + */ +export const includeSuspendedVariable: CheckVariable = { + id: 'include_suspended', + label: 'Include suspended users', + type: 'select', + required: false, + default: 'false', + helpText: 'Include suspended users in the employee list', + options: [ + { value: 'false', label: 'No - Active users only' }, + { value: 'true', label: 'Yes - Include suspended users' }, + ], +}; + +/** + * Filter by department + */ +export const departmentFilterVariable: CheckVariable = { + id: 'department_filter', + label: 'Filter by department', + type: 'text', + required: false, + placeholder: 'e.g., Engineering', + helpText: 'Only include users from this department (leave empty for all)', +}; diff --git a/packages/integration-platform/src/registry/index.ts b/packages/integration-platform/src/registry/index.ts index 9b077cce2..b3cb3e312 100644 --- a/packages/integration-platform/src/registry/index.ts +++ b/packages/integration-platform/src/registry/index.ts @@ -12,6 +12,7 @@ import { azureManifest } from '../manifests/azure'; import { gcpManifest } from '../manifests/gcp'; import { manifest as githubManifest } from '../manifests/github'; import { googleWorkspaceManifest } from '../manifests/google-workspace'; +import { manifest as jumpcloudManifest } from '../manifests/jumpcloud'; import { ripplingManifest } from '../manifests/rippling'; import { vercelManifest } from '../manifests/vercel'; @@ -101,6 +102,7 @@ const allManifests: IntegrationManifest[] = [ gcpManifest, githubManifest, googleWorkspaceManifest, + jumpcloudManifest, ripplingManifest, vercelManifest, ]; diff --git a/turbo.json b/turbo.json index 4ce30b180..b17bbbba4 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turborepo.org/schema.json", "globalDependencies": ["**/.env"], - "ui": "stream", + "ui": "tui", "tasks": { "prisma:generate": { "cache": false, From 74a444e9c49c69a286e549d64d5f631f2dba61cd Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 18 Dec 2025 12:09:15 -0500 Subject: [PATCH 4/4] chore(deps): update package versions and add OAuth token revocation services (#1946) --- apps/api/src/app/s3.ts | 4 +- .../integration-platform.module.ts | 4 + .../repositories/credential.repository.ts | 8 + .../connection-auth-teardown.service.ts | 73 ++++++++ .../services/connection.service.ts | 12 +- .../oauth-token-revocation.service.ts | 149 ++++++++++++++++ bun.lock | 60 +++---- packages/analytics/src/server.ts | 4 +- packages/db/src/postinstall.ts | 6 +- .../email/components/unsubscribe-link.tsx | 1 - .../email/emails/all-policy-notification.tsx | 8 +- packages/email/emails/policy-notification.tsx | 11 +- .../emails/unassigned-items-notification.tsx | 13 +- packages/email/lib/all-policy-notification.ts | 8 +- packages/email/lib/check-unsubscribe.ts | 9 +- packages/email/lib/policy-notification.ts | 10 +- packages/email/lib/resend.ts | 21 +-- .../lib/unassigned-items-notification.ts | 11 +- packages/email/lib/weekly-task-digest.ts | 1 - .../src/manifests/github/index.ts | 9 + packages/integration-platform/src/types.ts | 30 ++++ packages/ui/package.json | 2 +- packages/ui/src/components/badge.tsx | 6 +- packages/ui/src/components/button.tsx | 3 +- packages/ui/src/components/diff/index.tsx | 74 ++++---- packages/ui/src/components/diff/theme.css | 96 ++++------ .../src/components/diff/utils/guess-lang.ts | 164 +++++++++--------- .../ui/src/components/diff/utils/index.ts | 4 +- packages/ui/src/components/sheet.tsx | 3 +- packages/ui/src/utils/clamp.ts | 5 +- 30 files changed, 514 insertions(+), 295 deletions(-) create mode 100644 apps/api/src/integration-platform/services/connection-auth-teardown.service.ts create mode 100644 apps/api/src/integration-platform/services/oauth-token-revocation.service.ts diff --git a/apps/api/src/app/s3.ts b/apps/api/src/app/s3.ts index 30e6c0393..d003c5240 100644 --- a/apps/api/src/app/s3.ts +++ b/apps/api/src/app/s3.ts @@ -1,4 +1,4 @@ -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3'; import { Logger } from '@nestjs/common'; import '../config/load-env'; @@ -126,7 +126,7 @@ export async function getFleetAgent({ os, }: { os: 'macos' | 'windows' | 'linux'; -}) { +}): Promise { if (!s3Client) { throw new Error('S3 client not configured'); } diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index bf404f62f..18fca66b9 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -12,6 +12,8 @@ import { CredentialVaultService } from './services/credential-vault.service'; import { ConnectionService } from './services/connection.service'; import { OAuthCredentialsService } from './services/oauth-credentials.service'; import { AutoCheckRunnerService } from './services/auto-check-runner.service'; +import { ConnectionAuthTeardownService } from './services/connection-auth-teardown.service'; +import { OAuthTokenRevocationService } from './services/oauth-token-revocation.service'; import { ProviderRepository } from './repositories/provider.repository'; import { ConnectionRepository } from './repositories/connection.repository'; import { CredentialRepository } from './repositories/credential.repository'; @@ -38,6 +40,8 @@ import { CheckRunRepository } from './repositories/check-run.repository'; ConnectionService, OAuthCredentialsService, AutoCheckRunnerService, + OAuthTokenRevocationService, + ConnectionAuthTeardownService, // Repositories ProviderRepository, ConnectionRepository, diff --git a/apps/api/src/integration-platform/repositories/credential.repository.ts b/apps/api/src/integration-platform/repositories/credential.repository.ts index e3935ba1c..108b277ed 100644 --- a/apps/api/src/integration-platform/repositories/credential.repository.ts +++ b/apps/api/src/integration-platform/repositories/credential.repository.ts @@ -95,4 +95,12 @@ export class CredentialRepository { return result.count; } + + async deleteAllByConnection(connectionId: string): Promise { + const result = await db.integrationCredentialVersion.deleteMany({ + where: { connectionId }, + }); + + return result.count; + } } diff --git a/apps/api/src/integration-platform/services/connection-auth-teardown.service.ts b/apps/api/src/integration-platform/services/connection-auth-teardown.service.ts new file mode 100644 index 000000000..1fe953372 --- /dev/null +++ b/apps/api/src/integration-platform/services/connection-auth-teardown.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CredentialVaultService } from './credential-vault.service'; +import { OAuthTokenRevocationService } from './oauth-token-revocation.service'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { CredentialRepository } from '../repositories/credential.repository'; + +@Injectable() +export class ConnectionAuthTeardownService { + private readonly logger = new Logger(ConnectionAuthTeardownService.name); + + constructor( + private readonly connectionRepository: ConnectionRepository, + private readonly credentialVaultService: CredentialVaultService, + private readonly credentialRepository: CredentialRepository, + private readonly oauthTokenRevocationService: OAuthTokenRevocationService, + ) {} + + /** + * Best-effort teardown of a connection's auth: + * - Revoke provider token if supported/configured + * - Delete all stored credential versions + * - Clear active credential pointer + */ + async teardown({ connectionId }: { connectionId: string }): Promise { + const connection = await this.connectionRepository.findById(connectionId); + if (!connection) return; + + const providerSlug = (connection as { provider?: { slug: string } }) + .provider?.slug; + + const credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + const accessToken = credentials?.access_token; + + if (providerSlug && accessToken) { + try { + await this.oauthTokenRevocationService.revokeAccessToken({ + providerSlug, + accessToken, + organizationId: connection.organizationId, + }); + } catch (error) { + this.logger.warn( + `Failed to revoke OAuth token for ${providerSlug} connection ${connectionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + try { + await this.credentialRepository.deleteAllByConnection(connectionId); + } catch (error) { + this.logger.warn( + `Failed deleting credential versions for connection ${connectionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + try { + await this.connectionRepository.update(connectionId, { + activeCredentialVersionId: null, + }); + } catch (error) { + this.logger.warn( + `Failed clearing active credential pointer for connection ${connectionId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/apps/api/src/integration-platform/services/connection.service.ts b/apps/api/src/integration-platform/services/connection.service.ts index 5f2022978..78267f806 100644 --- a/apps/api/src/integration-platform/services/connection.service.ts +++ b/apps/api/src/integration-platform/services/connection.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; +import { ConnectionAuthTeardownService } from './connection-auth-teardown.service'; import type { IntegrationConnection, IntegrationConnectionStatus, @@ -22,6 +23,7 @@ export class ConnectionService { constructor( private readonly connectionRepository: ConnectionRepository, private readonly providerRepository: ProviderRepository, + private readonly connectionAuthTeardownService: ConnectionAuthTeardownService, ) {} async getConnection(connectionId: string): Promise { @@ -117,11 +119,19 @@ export class ConnectionService { async disconnectConnection( connectionId: string, ): Promise { - return this.updateConnectionStatus(connectionId, 'disconnected'); + await this.connectionAuthTeardownService.teardown({ connectionId }); + + return this.connectionRepository.update(connectionId, { + status: 'disconnected', + errorMessage: null, + activeCredentialVersionId: null, + }); } async deleteConnection(connectionId: string): Promise { await this.getConnection(connectionId); // Verify exists + await this.connectionAuthTeardownService.teardown({ connectionId }); + await this.connectionRepository.delete(connectionId); } diff --git a/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts b/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts new file mode 100644 index 000000000..d7b0c569d --- /dev/null +++ b/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { getManifest } from '@comp/integration-platform'; +import { OAuthCredentialsService } from './oauth-credentials.service'; + +type OAuthRevokeConfig = { + url: string; + method?: 'POST' | 'DELETE'; + auth?: 'basic' | 'bearer' | 'none'; + body?: 'form' | 'json'; + tokenField?: string; + extraBodyFields?: Record; +}; + +const isOAuthRevokeConfig = (value: unknown): value is OAuthRevokeConfig => { + if (!value || typeof value !== 'object') return false; + + const v = value as Record; + if (typeof v.url !== 'string') return false; + + if (v.method !== undefined && v.method !== 'POST' && v.method !== 'DELETE') { + return false; + } + + if ( + v.auth !== undefined && + v.auth !== 'basic' && + v.auth !== 'bearer' && + v.auth !== 'none' + ) { + return false; + } + + if (v.body !== undefined && v.body !== 'form' && v.body !== 'json') { + return false; + } + + if (v.tokenField !== undefined && typeof v.tokenField !== 'string') { + return false; + } + + if ( + v.extraBodyFields !== undefined && + (typeof v.extraBodyFields !== 'object' || v.extraBodyFields === null) + ) { + return false; + } + + return true; +}; + +@Injectable() +export class OAuthTokenRevocationService { + private readonly logger = new Logger(OAuthTokenRevocationService.name); + + constructor( + private readonly oauthCredentialsService: OAuthCredentialsService, + ) {} + + async revokeAccessToken({ + providerSlug, + organizationId, + accessToken, + }: { + providerSlug: string; + organizationId: string; + accessToken: string; + }): Promise { + const manifest = getManifest(providerSlug); + if (!manifest || manifest.auth.type !== 'oauth2') return; + + const oauthConfig = manifest.auth.config; + if (!('revoke' in oauthConfig)) return; + + const revokeConfigUnknown = oauthConfig.revoke; + if (!isOAuthRevokeConfig(revokeConfigUnknown)) return; + + const revokeConfig = { + url: revokeConfigUnknown.url, + method: revokeConfigUnknown.method ?? 'POST', + auth: revokeConfigUnknown.auth ?? 'basic', + body: revokeConfigUnknown.body ?? 'form', + tokenField: revokeConfigUnknown.tokenField ?? 'token', + extraBodyFields: revokeConfigUnknown.extraBodyFields, + }; + + const oauthCreds = await this.oauthCredentialsService.getCredentials( + providerSlug, + organizationId, + ); + + const url = revokeConfig.url.replace( + '{CLIENT_ID}', + encodeURIComponent(oauthCreds?.clientId ?? ''), + ); + + const headers: Record = { + Accept: 'application/json', + 'User-Agent': 'CompAI-Integration', + }; + + if (revokeConfig.auth === 'basic') { + if (!oauthCreds?.clientId || !oauthCreds.clientSecret) { + this.logger.warn( + `OAuth credentials not configured; cannot revoke token for ${providerSlug} org ${organizationId}`, + ); + return; + } + + const basicAuth = Buffer.from( + `${oauthCreds.clientId}:${oauthCreds.clientSecret}`, + ).toString('base64'); + headers.Authorization = `Basic ${basicAuth}`; + } + + if (revokeConfig.auth === 'bearer') { + headers.Authorization = `Bearer ${accessToken}`; + } + + let body: string | undefined; + if (revokeConfig.body === 'json') { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify({ + [revokeConfig.tokenField]: accessToken, + ...(revokeConfig.extraBodyFields ?? {}), + }); + } else { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + const params = new URLSearchParams({ + [revokeConfig.tokenField]: accessToken, + ...(revokeConfig.extraBodyFields ?? {}), + }); + body = params.toString(); + } + + const response = await fetch(url, { + method: revokeConfig.method, + headers, + body, + }); + + // Treat 404 as already revoked. + if (response.ok || response.status === 404) return; + + const text = await response.text().catch(() => ''); + throw new Error( + `Token revoke failed for ${providerSlug} (${response.status}): ${text.slice(0, 200)}`, + ); + } +} diff --git a/bun.lock b/bun.lock index fac055ec8..ec4e53ea8 100644 --- a/bun.lock +++ b/bun.lock @@ -13,56 +13,56 @@ "react-syntax-highlighter": "^15.6.6", "unpdf": "^1.4.0", "xlsx": "^0.18.5", - "zod": "^4.0.0", + "zod": "^4.2.1", }, "devDependencies": { "@azure/core-http": "^3.0.5", - "@azure/core-rest-pipeline": "^1.21.0", - "@azure/core-tracing": "^1.2.0", - "@azure/identity": "^4.10.0", + "@azure/core-rest-pipeline": "^1.22.2", + "@azure/core-tracing": "^1.3.1", + "@azure/identity": "^4.13.0", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", - "@hookform/resolvers": "^5.1.1", - "@number-flow/react": "^0.5.9", + "@hookform/resolvers": "^5.2.2", + "@number-flow/react": "^0.5.10", "@prisma/adapter-pg": "6.10.1", "@react-email/components": "^0.0.41", - "@react-email/render": "^1.1.2", + "@react-email/render": "^1.4.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^11.0.3", - "@semantic-release/npm": "^12.0.1", - "@semantic-release/release-notes-generator": "^14.0.3", - "@types/bun": "^1.2.15", + "@semantic-release/github": "^11.0.6", + "@semantic-release/npm": "^12.0.2", + "@semantic-release/release-notes-generator": "^14.1.0", + "@types/bun": "^1.3.4", "@types/d3": "^7.4.3", - "@types/lodash": "^4.17.17", - "@types/react": "^19.1.6", - "@types/react-dom": "^19.1.1", - "ai": "^5.0.0", - "concurrently": "^9.1.2", + "@types/lodash": "^4.17.21", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "ai": "^5.0.115", + "concurrently": "^9.2.1", "d3": "^7.9.0", "date-fns": "^4.1.0", - "dayjs": "^1.11.13", - "execa": "^9.0.0", + "dayjs": "^1.11.19", + "execa": "^9.6.1", "gitmoji": "^1.1.1", "gray-matter": "^4.0.3", "husky": "^9.1.7", - "prettier": "^3.5.3", - "prettier-plugin-organize-imports": "^4.1.0", - "prettier-plugin-tailwindcss": "^0.6.0", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-tailwindcss": "^0.6.14", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-email": "^4.0.15", - "react-hook-form": "^7.61.1", - "semantic-release": "^24.2.8", + "react-email": "^4.3.2", + "react-hook-form": "^7.68.0", + "semantic-release": "^24.2.9", "semantic-release-discord": "^1.2.0", - "semantic-release-discord-notifier": "^1.0.11", - "sharp": "^0.34.2", + "semantic-release-discord-notifier": "^1.1.1", + "sharp": "^0.34.5", "syncpack": "^13.0.4", - "tsup": "^8.5.0", - "turbo": "^2.5.4", - "typescript": "^5.8.3", - "use-debounce": "^10.0.4", + "tsup": "^8.5.1", + "turbo": "^2.6.3", + "typescript": "^5.9.3", + "use-debounce": "^10.0.6", }, }, "apps/api": { diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts index ac2e62f59..a05a4c850 100644 --- a/packages/analytics/src/server.ts +++ b/packages/analytics/src/server.ts @@ -42,7 +42,9 @@ export async function identify(distinctId: string, properties?: Properties) { await serverInstance.flush(); } -export async function getFeatureFlags(distinctId: string): Promise> { +export async function getFeatureFlags( + distinctId: string, +): Promise> { if (!serverInstance) return {}; const flags = await serverInstance.getAllFlags(distinctId); diff --git a/packages/db/src/postinstall.ts b/packages/db/src/postinstall.ts index 4d3cfd0bc..f5468159a 100644 --- a/packages/db/src/postinstall.ts +++ b/packages/db/src/postinstall.ts @@ -157,9 +157,9 @@ function shouldRunCli(force: boolean): boolean { return Boolean( process.env.TRIGGER_SECRET_KEY || - process.env.TRIGGER_DEPLOYMENT || - process.env.CI === 'true' || - process.env.PRISMA_GENERATE_ON_INSTALL === '1', + process.env.TRIGGER_DEPLOYMENT || + process.env.CI === 'true' || + process.env.PRISMA_GENERATE_ON_INSTALL === '1', ); } diff --git a/packages/email/components/unsubscribe-link.tsx b/packages/email/components/unsubscribe-link.tsx index 089a17707..eea695173 100644 --- a/packages/email/components/unsubscribe-link.tsx +++ b/packages/email/components/unsubscribe-link.tsx @@ -18,4 +18,3 @@ export function UnsubscribeLink({ email, unsubscribeUrl }: UnsubscribeLinkProps) ); } - diff --git a/packages/email/emails/all-policy-notification.tsx b/packages/email/emails/all-policy-notification.tsx index d2273acac..1472d65ef 100644 --- a/packages/email/emails/all-policy-notification.tsx +++ b/packages/email/emails/all-policy-notification.tsx @@ -71,16 +71,15 @@ export const AllPolicyNotificationEmail = ({ {subjectText} - - Hi {userName}, - + Hi {userName}, All policies have been published and require your review. - Your organization {organizationName} requires all employees to review and accept these policies. + Your organization {organizationName} requires all employees to review + and accept these policies.
@@ -119,4 +118,3 @@ export const AllPolicyNotificationEmail = ({ }; export default AllPolicyNotificationEmail; - diff --git a/packages/email/emails/policy-notification.tsx b/packages/email/emails/policy-notification.tsx index d06b28fb5..0d34de028 100644 --- a/packages/email/emails/policy-notification.tsx +++ b/packages/email/emails/policy-notification.tsx @@ -87,16 +87,13 @@ export const PolicyNotificationEmail = ({ {subjectText} - - Hi {userName}, - + Hi {userName}, - - {getBodyText()} - + {getBodyText()} - Your organization {organizationName} requires all employees to review and accept this policy. + Your organization {organizationName} requires all employees to review + and accept this policy.
diff --git a/packages/email/emails/unassigned-items-notification.tsx b/packages/email/emails/unassigned-items-notification.tsx index 6d0d93f54..f9f542b70 100644 --- a/packages/email/emails/unassigned-items-notification.tsx +++ b/packages/email/emails/unassigned-items-notification.tsx @@ -5,8 +5,8 @@ import { Heading, Html, Link, - Section, Preview, + Section, Tailwind, Text, } from '@react-email/components'; @@ -41,7 +41,6 @@ export const UnassignedItemsNotificationEmail = ({ const baseUrl = process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? 'https://app.trycomp.ai'; const link = `${baseUrl}/${organizationId}`; - const getItemTypeLabel = (type: UnassignedItem['type']) => { switch (type) { case 'task': @@ -118,13 +117,12 @@ export const UnassignedItemsNotificationEmail = ({ Member Removed - Items Require Reassignment - - Hi {userName}, - + Hi {userName}, - {removedMemberName} has been removed from {organizationName}. - As a result, the following items that were previously assigned to them now require a new assignee: + {removedMemberName} has been removed from{' '} + {organizationName}. As a result, the following items that were + previously assigned to them now require a new assignee: {Object.entries(groupedItems).map(([type, items]) => ( @@ -170,4 +168,3 @@ export const UnassignedItemsNotificationEmail = ({ }; export default UnassignedItemsNotificationEmail; - diff --git a/packages/email/lib/all-policy-notification.ts b/packages/email/lib/all-policy-notification.ts index 06c3a2015..c70fde9ba 100644 --- a/packages/email/lib/all-policy-notification.ts +++ b/packages/email/lib/all-policy-notification.ts @@ -7,12 +7,7 @@ export const sendAllPolicyNotificationEmail = async (params: { organizationName: string; organizationId: string; }) => { - const { - email, - userName, - organizationName, - organizationId, - } = params; + const { email, userName, organizationName, organizationId } = params; const subjectText = 'Please review and accept the policies'; try { @@ -39,4 +34,3 @@ export const sendAllPolicyNotificationEmail = async (params: { return { success: false }; } }; - diff --git a/packages/email/lib/check-unsubscribe.ts b/packages/email/lib/check-unsubscribe.ts index d79c2a43f..5cc3b7296 100644 --- a/packages/email/lib/check-unsubscribe.ts +++ b/packages/email/lib/check-unsubscribe.ts @@ -5,12 +5,16 @@ const DEFAULT_PREFERENCES = { unassignedItemsNotifications: true, }; -type EmailPreferenceType = 'policyNotifications' | 'taskReminders' | 'weeklyTaskDigest' | 'unassignedItemsNotifications'; +type EmailPreferenceType = + | 'policyNotifications' + | 'taskReminders' + | 'weeklyTaskDigest' + | 'unassignedItemsNotifications'; /** * Helper function to check if a user is unsubscribed from a specific type of email notification * This should be called before sending any notification/reminder emails - * + * * @param db - Prisma database client * @param email - User's email address * @param preferenceType - Type of email preference to check @@ -62,4 +66,3 @@ export async function isUserUnsubscribed( return false; } } - diff --git a/packages/email/lib/policy-notification.ts b/packages/email/lib/policy-notification.ts index 5dfd906c3..9ffd98170 100644 --- a/packages/email/lib/policy-notification.ts +++ b/packages/email/lib/policy-notification.ts @@ -9,14 +9,8 @@ export const sendPolicyNotificationEmail = async (params: { organizationId: string; notificationType: 'new' | 'updated' | 're-acceptance'; }) => { - const { - email, - userName, - policyName, - organizationName, - organizationId, - notificationType, - } = params; + const { email, userName, policyName, organizationName, organizationId, notificationType } = + params; const subjectText = 'Please review and accept this policy'; try { diff --git a/packages/email/lib/resend.ts b/packages/email/lib/resend.ts index adba3caa4..f58cb98a7 100644 --- a/packages/email/lib/resend.ts +++ b/packages/email/lib/resend.ts @@ -25,20 +25,15 @@ export const sendEmail = async ({ throw new Error('Resend not initialized - missing API key'); } - - // 1) Pull each env var into its own constant + // 1) Pull each env var into its own constant const fromMarketing = process.env.RESEND_FROM_MARKETING; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - const toTest = process.env.RESEND_TO_TEST; - const replyMarketing= process.env.RESEND_REPLY_TO_MARKETING; + const fromSystem = process.env.RESEND_FROM_SYSTEM; + const fromDefault = process.env.RESEND_FROM_DEFAULT; + const toTest = process.env.RESEND_TO_TEST; + const replyMarketing = process.env.RESEND_REPLY_TO_MARKETING; // 2) Decide which one you need for this email - const fromAddress = marketing - ? fromMarketing - : system - ? fromSystem - : fromDefault; + const fromAddress = marketing ? fromMarketing : system ? fromSystem : fromDefault; const toAddress = test ? toTest : to; @@ -54,8 +49,8 @@ export const sendEmail = async ({ try { const { data, error } = await resend.emails.send({ - from: fromAddress, // now always a string - to: toAddress, // now always a string + from: fromAddress, // now always a string + to: toAddress, // now always a string cc, replyTo, subject, diff --git a/packages/email/lib/unassigned-items-notification.ts b/packages/email/lib/unassigned-items-notification.ts index d3422572b..6f24a120d 100644 --- a/packages/email/lib/unassigned-items-notification.ts +++ b/packages/email/lib/unassigned-items-notification.ts @@ -15,14 +15,8 @@ export const sendUnassignedItemsNotificationEmail = async (params: { removedMemberName: string; unassignedItems: UnassignedItem[]; }) => { - const { - email, - userName, - organizationName, - organizationId, - removedMemberName, - unassignedItems, - } = params; + const { email, userName, organizationName, organizationId, removedMemberName, unassignedItems } = + params; if (unassignedItems.length === 0) { return { success: true }; @@ -56,4 +50,3 @@ export const sendUnassignedItemsNotificationEmail = async (params: { return { success: false }; } }; - diff --git a/packages/email/lib/weekly-task-digest.ts b/packages/email/lib/weekly-task-digest.ts index c5e0a2db7..030875515 100644 --- a/packages/email/lib/weekly-task-digest.ts +++ b/packages/email/lib/weekly-task-digest.ts @@ -45,4 +45,3 @@ export const sendWeeklyTaskDigestEmail = async (params: { return { success: false }; } }; - diff --git a/packages/integration-platform/src/manifests/github/index.ts b/packages/integration-platform/src/manifests/github/index.ts index 20a6b5863..55f83e1de 100644 --- a/packages/integration-platform/src/manifests/github/index.ts +++ b/packages/integration-platform/src/manifests/github/index.ts @@ -32,6 +32,15 @@ export const manifest: IntegrationManifest = { scopes: ['read:org', 'repo', 'read:user'], pkce: false, clientAuthMethod: 'body', + revoke: { + // Revoke the *grant* (app authorization), not just a single token. + // This forces GitHub to show a fresh authorization flow on reconnect. + url: 'https://api.github.com/applications/{CLIENT_ID}/grant', + method: 'DELETE', + auth: 'basic', + body: 'json', + tokenField: 'access_token', + }, // GitHub tokens don't expire - they're valid until revoked supportsRefreshToken: false, authorizationParams: { diff --git a/packages/integration-platform/src/types.ts b/packages/integration-platform/src/types.ts index 2ce6c81ec..78e6c9ccf 100644 --- a/packages/integration-platform/src/types.ts +++ b/packages/integration-platform/src/types.ts @@ -57,6 +57,36 @@ export const OAuthConfigSchema = z.object({ }), ) .optional(), + /** + * Optional token revocation config for OAuth providers. + * + * Note: While RFC7009 exists, providers vary widely in practice (method, auth, + * path templating, and payload). This config is intentionally flexible. + */ + revoke: z + .object({ + /** Revocation URL. Supports templating with '{CLIENT_ID}'. */ + url: z.string(), + /** HTTP method to use. */ + method: z.enum(['POST', 'DELETE']).default('POST'), + /** + * How to authenticate the revocation request. + * - basic: Basic {client_id}:{client_secret} + * - bearer: Authorization: Bearer {access_token} + * - none: no auth header + */ + auth: z.enum(['basic', 'bearer', 'none']).default('basic'), + /** + * Body format. Many providers use x-www-form-urlencoded (RFC7009), + * some use JSON (e.g. GitHub token revoke endpoint). + */ + body: z.enum(['form', 'json']).default('form'), + /** Field name for the token in the body. */ + tokenField: z.string().default('token'), + /** Optional additional static fields to include in the body. */ + extraBodyFields: z.record(z.string(), z.string()).optional(), + }) + .optional(), }); export type OAuthConfig = z.infer; diff --git a/packages/ui/package.json b/packages/ui/package.json index 4f3c43235..eb9ba659a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -549,4 +549,4 @@ "sideEffects": false, "type": "module", "types": "./dist/index.d.ts" -} \ No newline at end of file +} diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index dae14a069..a6f023480 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -17,7 +17,8 @@ const badgeVariants = cva( marketing: "flex items-center opacity-80 px-3 font-mono gap-2 whitespace-nowrap border border bg-primary/10 text-primary hover:bg-primary/5 before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-primary", warning: 'border-transparent bg-warning text-white hover:bg-warning/80', - success: 'border-transparent bg-green-600 text-white hover:bg-green-600/80 dark:bg-green-600 dark:text-white', + success: + 'border-transparent bg-green-600 text-white hover:bg-green-600/80 dark:bg-green-600 dark:text-white', }, }, defaultVariants: { @@ -27,8 +28,7 @@ const badgeVariants = cva( ); export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 824124d29..5080d52a0 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -32,8 +32,7 @@ const buttonVariants = cva( ); interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } diff --git a/packages/ui/src/components/diff/index.tsx b/packages/ui/src/components/diff/index.tsx index 60d8b9929..48355f1fe 100644 --- a/packages/ui/src/components/diff/index.tsx +++ b/packages/ui/src/components/diff/index.tsx @@ -1,17 +1,11 @@ -"use client"; +'use client'; -import React from "react"; -import { refractor } from "refractor/all"; -import "./theme.css"; -import { ChevronsUpDown } from "lucide-react"; -import { cn } from "../../utils"; -import { - guessLang, - Hunk as HunkType, - SkipBlock, - File, - Line as LineType, -} from "./utils"; +import { ChevronsUpDown } from 'lucide-react'; +import React from 'react'; +import { refractor } from 'refractor/all'; +import { cn } from '../../utils'; +import './theme.css'; +import { File, guessLang, Hunk as HunkType, Line as LineType, SkipBlock } from './utils'; /* -------------------------------------------------------------------------- */ /* — Context — */ @@ -26,7 +20,7 @@ const DiffContext = React.createContext(null); function useDiffContext() { const context = React.useContext(DiffContext); if (!context) { - throw new Error("useDiffContext must be used within a Diff component"); + throw new Error('useDiffContext must be used within a Diff component'); } return context; } @@ -36,19 +30,19 @@ function useDiffContext() { /* -------------------------------------------------------------------------- */ function hastToReact( - node: ReturnType["children"][number], - key: string + node: ReturnType['children'][number], + key: string, ): React.ReactNode { - if (node.type === "text") return node.value; - if (node.type === "element") { + if (node.type === 'text') return node.value; + if (node.type === 'element') { const { tagName, properties, children } = node; return React.createElement( tagName, { key, - className: (properties.className as string[] | undefined)?.join(" "), + className: (properties.className as string[] | undefined)?.join(' '), }, - children.map((c, i) => hastToReact(c, `${key}-${i}`)) + children.map((c, i) => hastToReact(c, `${key}-${i}`)), ); } return null; @@ -70,14 +64,13 @@ export interface DiffSelectionRange { } export interface DiffProps - extends React.TableHTMLAttributes, - Pick { + extends React.TableHTMLAttributes, Pick { fileName?: string; language?: string; } export const Hunk = ({ hunk }: { hunk: HunkType | SkipBlock }) => { - return hunk.type === "hunk" ? ( + return hunk.type === 'hunk' ? ( <> {hunk.lines.map((line, index) => ( @@ -101,13 +94,12 @@ export const Diff: React.FC = ({ - {children ?? - hunks.map((hunk, index) => )} + {children ?? hunks.map((hunk, index) => )}
@@ -120,7 +112,7 @@ const SkipBlockRow: React.FC<{ }> = ({ lines, content }) => ( <> - + @@ -139,30 +131,28 @@ const Line: React.FC<{ line: LineType; }> = ({ line }) => { const { language } = useDiffContext(); - const Tag = - line.type === "insert" ? "ins" : line.type === "delete" ? "del" : "span"; - const lineNumberNew = - line.type === "normal" ? line.newLineNumber : line.lineNumber; - const lineNumberOld = line.type === "normal" ? line.oldLineNumber : undefined; + const Tag = line.type === 'insert' ? 'ins' : line.type === 'delete' ? 'del' : 'span'; + const lineNumberNew = line.type === 'normal' ? line.newLineNumber : line.lineNumber; + const lineNumberOld = line.type === 'normal' ? line.oldLineNumber : undefined; return ( - {line.type === "delete" ? "–" : lineNumberNew} + {line.type === 'delete' ? '–' : lineNumberNew} @@ -170,8 +160,8 @@ const Line: React.FC<{ {highlight(seg.value, language).map((n, idx) => ( diff --git a/packages/ui/src/components/diff/theme.css b/packages/ui/src/components/diff/theme.css index 254b5fa69..3d82e1fbc 100644 --- a/packages/ui/src/components/diff/theme.css +++ b/packages/ui/src/components/diff/theme.css @@ -29,12 +29,11 @@ * --syntax-cursor-line: hsla(230, 8%, 24%, 0.05); */ - code[class*="language-"], - pre[class*="language-"] { + code[class*='language-'], + pre[class*='language-'] { background: hsl(230, 1%, 98%); color: hsl(230, 8%, 24%); - font-family: - "Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace; direction: ltr; text-align: left; white-space: pre; @@ -51,22 +50,22 @@ } /* Selection */ - code[class*="language-"]::-moz-selection, - code[class*="language-"] *::-moz-selection, - pre[class*="language-"] *::-moz-selection { + code[class*='language-']::-moz-selection, + code[class*='language-'] *::-moz-selection, + pre[class*='language-'] *::-moz-selection { background: hsl(230, 1%, 90%); color: inherit; } - code[class*="language-"]::selection, - code[class*="language-"] *::selection, - pre[class*="language-"] *::selection { + code[class*='language-']::selection, + code[class*='language-'] *::selection, + pre[class*='language-'] *::selection { background: hsl(230, 1%, 90%); color: inherit; } /* Code blocks */ - pre[class*="language-"] { + pre[class*='language-'] { padding: 1em; margin: 0.5em 0; overflow: auto; @@ -74,7 +73,7 @@ } /* Inline code */ - :not(pre) > code[class*="language-"] { + :not(pre) > code[class*='language-'] { padding: 0.2em 0.3em; border-radius: 0.3em; white-space: normal; @@ -303,9 +302,7 @@ /* Hovering over a linkable line number (in the gutter area) */ /* Requires Line Numbers plugin as well */ - pre[id].linkable-line-numbers.linkable-line-numbers - span.line-numbers-rows - > span:hover:before { + pre[id].linkable-line-numbers.linkable-line-numbers span.line-numbers-rows > span:hover:before { background-color: hsla(230, 8%, 24%, 0.05); } @@ -358,10 +355,7 @@ pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, - pre - > code.diff-highlight - .token.token.deleted:not(.prefix) - *::-moz-selection { + pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { background-color: hsla(353, 95%, 66%, 0.25); } @@ -378,15 +372,9 @@ } pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, - pre.diff-highlight - > code - .token.token.inserted:not(.prefix) - *::-moz-selection, + pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, - pre - > code.diff-highlight - .token.token.inserted:not(.prefix) - *::-moz-selection { + pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { background-color: hsla(135, 73%, 55%, 0.25); } @@ -480,13 +468,12 @@ * --syntax-cursor-line: hsla(220, 100%, 80%, 0.04); */ - code[class*="language-"], - pre[class*="language-"] { + code[class*='language-'], + pre[class*='language-'] { background: hsl(220, 13%, 18%); color: hsl(220, 14%, 71%); text-shadow: 0 1px rgba(0, 0, 0, 0.3); - font-family: - "Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace; direction: ltr; text-align: left; white-space: pre; @@ -503,24 +490,24 @@ } /* Selection */ - code[class*="language-"]::-moz-selection, - code[class*="language-"] *::-moz-selection, - pre[class*="language-"] *::-moz-selection { + code[class*='language-']::-moz-selection, + code[class*='language-'] *::-moz-selection, + pre[class*='language-'] *::-moz-selection { background: hsl(220, 13%, 28%); color: inherit; text-shadow: none; } - code[class*="language-"]::selection, - code[class*="language-"] *::selection, - pre[class*="language-"] *::selection { + code[class*='language-']::selection, + code[class*='language-'] *::selection, + pre[class*='language-'] *::selection { background: hsl(220, 13%, 28%); color: inherit; text-shadow: none; } /* Code blocks */ - pre[class*="language-"] { + pre[class*='language-'] { padding: 1em; margin: 0.5em 0; overflow: auto; @@ -528,7 +515,7 @@ } /* Inline code */ - :not(pre) > code[class*="language-"] { + :not(pre) > code[class*='language-'] { padding: 0.2em 0.3em; border-radius: 0.3em; white-space: normal; @@ -536,8 +523,8 @@ /* Print */ @media print { - code[class*="language-"], - pre[class*="language-"] { + code[class*='language-'], + pre[class*='language-'] { text-shadow: none; } } @@ -761,9 +748,7 @@ /* Hovering over a linkable line number (in the gutter area) */ /* Requires Line Numbers plugin as well */ - pre[id].linkable-line-numbers.linkable-line-numbers - span.line-numbers-rows - > span:hover:before { + pre[id].linkable-line-numbers.linkable-line-numbers span.line-numbers-rows > span:hover:before { background-color: hsla(220, 100%, 80%, 0.04); } @@ -816,10 +801,7 @@ pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, - pre - > code.diff-highlight - .token.token.deleted:not(.prefix) - *::-moz-selection { + pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { background-color: hsla(353, 95%, 66%, 0.25); } @@ -836,15 +818,9 @@ } pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, - pre.diff-highlight - > code - .token.token.inserted:not(.prefix) - *::-moz-selection, + pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, - pre - > code.diff-highlight - .token.token.inserted:not(.prefix) - *::-moz-selection { + pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { background-color: hsla(135, 73%, 55%, 0.25); } @@ -907,20 +883,20 @@ } } -tr[data-comment-highlight="true"] { +tr[data-comment-highlight='true'] { @apply !bg-[var(--color-yellow)]/20; transition: background-color 150ms ease; } -.dark tr[data-comment-highlight="true"] { +.dark tr[data-comment-highlight='true'] { @apply !bg-[var(--color-yellow)]/20; background-color: rgba(188, 133, 255, 0.22); } -tr[data-comment-draft="true"] { +tr[data-comment-draft='true'] { background-color: rgba(59, 130, 246, 0.12); } -tr[data-comment-draft="true"] td:first-child { +tr[data-comment-draft='true'] td:first-child { border-left-color: rgba(59, 130, 246, 0.4); } diff --git a/packages/ui/src/components/diff/utils/guess-lang.ts b/packages/ui/src/components/diff/utils/guess-lang.ts index 908f9f5c8..214b60f99 100644 --- a/packages/ui/src/components/diff/utils/guess-lang.ts +++ b/packages/ui/src/components/diff/utils/guess-lang.ts @@ -1,122 +1,122 @@ const extToLang: Record = { // JavaScript/TypeScript - js: "javascript", - jsx: "jsx", - ts: "typescript", - tsx: "tsx", - mjs: "javascript", - cjs: "javascript", + js: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + mjs: 'javascript', + cjs: 'javascript', // Web - html: "markup", - htm: "markup", - xml: "markup", - svg: "markup", - css: "css", - scss: "scss", - sass: "sass", - less: "less", - stylus: "stylus", + html: 'markup', + htm: 'markup', + xml: 'markup', + svg: 'markup', + css: 'css', + scss: 'scss', + sass: 'sass', + less: 'less', + stylus: 'stylus', // Python - py: "python", - pyw: "python", - pyi: "python", + py: 'python', + pyw: 'python', + pyi: 'python', // Java/JVM - java: "java", - kt: "kotlin", - kts: "kotlin", - scala: "scala", - groovy: "groovy", + java: 'java', + kt: 'kotlin', + kts: 'kotlin', + scala: 'scala', + groovy: 'groovy', // C/C++ - c: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - h: "cpp", - hpp: "cpp", - hh: "cpp", - hxx: "cpp", + c: 'c', + cpp: 'cpp', + cc: 'cpp', + cxx: 'cpp', + h: 'cpp', + hpp: 'cpp', + hh: 'cpp', + hxx: 'cpp', // C#/.NET - cs: "csharp", - vb: "vbnet", - fs: "fsharp", + cs: 'csharp', + vb: 'vbnet', + fs: 'fsharp', // Rust - rs: "rust", + rs: 'rust', // Go - go: "go", + go: 'go', // Ruby - rb: "ruby", - rake: "ruby", + rb: 'ruby', + rake: 'ruby', // PHP - php: "php", - phtml: "php", + php: 'php', + phtml: 'php', // Shell - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "bash", + sh: 'bash', + bash: 'bash', + zsh: 'bash', + fish: 'bash', // Data formats - json: "json", - json5: "json5", - yml: "yaml", - yaml: "yaml", - toml: "toml", - ini: "ini", - csv: "csv", + json: 'json', + json5: 'json5', + yml: 'yaml', + yaml: 'yaml', + toml: 'toml', + ini: 'ini', + csv: 'csv', // Markdown/Docs - md: "markdown", - markdown: "markdown", - tex: "latex", + md: 'markdown', + markdown: 'markdown', + tex: 'latex', // Swift/Objective-C - swift: "swift", - m: "objectivec", - mm: "objectivec", + swift: 'swift', + m: 'objectivec', + mm: 'objectivec', // SQL - sql: "sql", + sql: 'sql', // Other languages - r: "r", - lua: "lua", - perl: "perl", - pl: "perl", - dart: "dart", - elm: "elm", - ex: "elixir", - exs: "elixir", - erl: "erlang", - clj: "clojure", - cljs: "clojure", - lisp: "lisp", - hs: "haskell", - ml: "ocaml", + r: 'r', + lua: 'lua', + perl: 'perl', + pl: 'perl', + dart: 'dart', + elm: 'elm', + ex: 'elixir', + exs: 'elixir', + erl: 'erlang', + clj: 'clojure', + cljs: 'clojure', + lisp: 'lisp', + hs: 'haskell', + ml: 'ocaml', // Config files - dockerfile: "docker", - gitignore: "ignore", + dockerfile: 'docker', + gitignore: 'ignore', // Other - graphql: "graphql", - proto: "protobuf", - wasm: "wasm", - vim: "vim", - zig: "zig", - mermaid: "mermaid", + graphql: 'graphql', + proto: 'protobuf', + wasm: 'wasm', + vim: 'vim', + zig: 'zig', + mermaid: 'mermaid', }; export const guessLang = (filename?: string): string => { - const ext = filename?.split(".").pop()?.toLowerCase() ?? ""; - return extToLang[ext] ?? "tsx"; + const ext = filename?.split('.').pop()?.toLowerCase() ?? ''; + return extToLang[ext] ?? 'tsx'; }; diff --git a/packages/ui/src/components/diff/utils/index.ts b/packages/ui/src/components/diff/utils/index.ts index 8d7f972be..ac2d221ad 100644 --- a/packages/ui/src/components/diff/utils/index.ts +++ b/packages/ui/src/components/diff/utils/index.ts @@ -1,2 +1,2 @@ -export * from "./parse"; -export * from "./guess-lang"; +export * from './guess-lang'; +export * from './parse'; diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index 0db0b13a8..3c9966b7f 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -48,7 +48,8 @@ const sheetVariants = cva( ); interface SheetContentProps - extends React.ComponentPropsWithoutRef, + extends + React.ComponentPropsWithoutRef, VariantProps { stack?: boolean; } diff --git a/packages/ui/src/utils/clamp.ts b/packages/ui/src/utils/clamp.ts index b39e68c70..827f4928d 100644 --- a/packages/ui/src/utils/clamp.ts +++ b/packages/ui/src/utils/clamp.ts @@ -1,4 +1,3 @@ export function clamp(val: number, [min, max]: [number, number]): number { - return Math.min(Math.max(val, min), max); - } - \ No newline at end of file + return Math.min(Math.max(val, min), max); +}