Browse Source

Merge pull request #37 from vernu/webhooks

Webhooks implementation v1
pull/48/head
Israel Abebe 1 year ago
committed by GitHub
parent
commit
9b7d5cea4b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      api/package.json
  2. 35
      api/pnpm-lock.yaml
  3. 4
      api/src/app.module.ts
  4. 2
      api/src/gateway/gateway.module.ts
  5. 14
      api/src/gateway/gateway.service.ts
  6. 41
      api/src/webhook/schemas/webhook-notification.schema.ts
  7. 43
      api/src/webhook/schemas/webhook-subscription.schema.ts
  8. 3
      api/src/webhook/webhook-event.enum.ts
  9. 68
      api/src/webhook/webhook.controller.ts
  10. 14
      api/src/webhook/webhook.dto.ts
  11. 35
      api/src/webhook/webhook.module.ts
  12. 270
      api/src/webhook/webhook.service.ts
  13. 25
      web/app/(app)/dashboard/(components)/main-dashboard.tsx
  14. 198
      web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx
  15. 50
      web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx
  16. 201
      web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx
  17. 139
      web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx
  18. 169
      web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx
  19. 178
      web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx
  20. 49
      web/components/shared/copy-button.tsx
  21. 141
      web/components/ui/alert-dialog.tsx
  22. 17
      web/components/ui/code.tsx
  23. 15
      web/components/ui/skeleton.tsx
  24. 32
      web/components/ui/tooltip.tsx
  25. 3
      web/config/api.ts
  26. 3
      web/lib/constants.ts
  27. 17
      web/lib/types.ts
  28. 3
      web/package.json
  29. 413
      web/pnpm-lock.yaml

1
api/package.json

@ -27,6 +27,7 @@
"@nestjs/mongoose": "^10.0.10", "@nestjs/mongoose": "^10.0.10",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.5", "@nestjs/platform-express": "^10.4.5",
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^7.4.2", "@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.2.1", "@nestjs/throttler": "^6.2.1",
"axios": "^1.7.7", "axios": "^1.7.7",

35
api/pnpm-lock.yaml

@ -29,6 +29,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^10.4.5 specifier: ^10.4.5
version: 10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) version: 10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)
'@nestjs/schedule':
specifier: ^4.1.1
version: 4.1.1(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^7.4.2 specifier: ^7.4.2
version: 7.4.2(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2) version: 7.4.2(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)
@ -806,6 +809,12 @@ packages:
'@nestjs/common': ^10.0.0 '@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0 '@nestjs/core': ^10.0.0
'@nestjs/schedule@4.1.1':
resolution: {integrity: sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/schematics@10.2.2': '@nestjs/schematics@10.2.2':
resolution: {integrity: sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==} resolution: {integrity: sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==}
peerDependencies: peerDependencies:
@ -1178,6 +1187,9 @@ packages:
'@types/long@4.0.2': '@types/long@4.0.2':
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@ -1807,6 +1819,9 @@ packages:
create-require@1.1.1: create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron@3.1.7:
resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==}
cross-spawn@7.0.3: cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2985,6 +3000,10 @@ packages:
lru-memoizer@2.3.0: lru-memoizer@2.3.0:
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
luxon@3.4.4:
resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==}
engines: {node: '>=12'}
magic-string@0.30.8: magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -5373,6 +5392,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/schedule@4.1.1(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
dependencies:
'@nestjs/common': 10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1)
'@nestjs/core': 10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1)
cron: 3.1.7
uuid: 10.0.0
'@nestjs/schematics@10.2.2(chokidar@3.6.0)(typescript@5.3.3)': '@nestjs/schematics@10.2.2(chokidar@3.6.0)(typescript@5.3.3)':
dependencies: dependencies:
'@angular-devkit/core': 17.3.10(chokidar@3.6.0) '@angular-devkit/core': 17.3.10(chokidar@3.6.0)
@ -5927,6 +5953,8 @@ snapshots:
'@types/long@4.0.2': '@types/long@4.0.2':
optional: true optional: true
'@types/luxon@3.4.2': {}
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
@ -6691,6 +6719,11 @@ snapshots:
create-require@1.1.1: {} create-require@1.1.1: {}
cron@3.1.7:
dependencies:
'@types/luxon': 3.4.2
luxon: 3.4.4
cross-spawn@7.0.3: cross-spawn@7.0.3:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -8219,6 +8252,8 @@ snapshots:
lodash.clonedeep: 4.5.0 lodash.clonedeep: 4.5.0
lru-cache: 6.0.0 lru-cache: 6.0.0
luxon@3.4.4: {}
magic-string@0.30.8: magic-string@0.30.8:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0

4
api/src/app.module.ts

@ -5,7 +5,9 @@ import { AuthModule } from './auth/auth.module'
import { UsersModule } from './users/users.module' import { UsersModule } from './users/users.module'
import { ThrottlerModule } from '@nestjs/throttler' import { ThrottlerModule } from '@nestjs/throttler'
import { APP_GUARD } from '@nestjs/core/constants' import { APP_GUARD } from '@nestjs/core/constants'
import { WebhookModule } from './webhook/webhook.module'
import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard' import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard'
import { ScheduleModule } from '@nestjs/schedule'
@Module({ @Module({
imports: [ imports: [
@ -16,9 +18,11 @@ import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard'
limit: 30, limit: 30,
}, },
]), ]),
ScheduleModule.forRoot(),
AuthModule, AuthModule,
UsersModule, UsersModule,
GatewayModule, GatewayModule,
WebhookModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

2
api/src/gateway/gateway.module.ts

@ -7,6 +7,7 @@ import { AuthModule } from '../auth/auth.module'
import { UsersModule } from '../users/users.module' import { UsersModule } from '../users/users.module'
import { SMS, SMSSchema } from './schemas/sms.schema' import { SMS, SMSSchema } from './schemas/sms.schema'
import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema' import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema'
import { WebhookModule } from 'src/webhook/webhook.module'
@Module({ @Module({
imports: [ imports: [
@ -26,6 +27,7 @@ import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema'
]), ]),
AuthModule, AuthModule,
UsersModule, UsersModule,
WebhookModule,
], ],
controllers: [GatewayController], controllers: [GatewayController],
providers: [GatewayService], providers: [GatewayService],

14
api/src/gateway/gateway.service.ts

@ -19,6 +19,8 @@ import {
BatchResponse, BatchResponse,
Message, Message,
} from 'firebase-admin/lib/messaging/messaging-api' } from 'firebase-admin/lib/messaging/messaging-api'
import { WebhookEvent } from 'src/webhook/webhook-event.enum'
import { WebhookService } from 'src/webhook/webhook.service'
@Injectable() @Injectable()
export class GatewayService { export class GatewayService {
constructor( constructor(
@ -26,6 +28,7 @@ export class GatewayService {
@InjectModel(SMS.name) private smsModel: Model<SMS>, @InjectModel(SMS.name) private smsModel: Model<SMS>,
@InjectModel(SMSBatch.name) private smsBatchModel: Model<SMSBatch>, @InjectModel(SMSBatch.name) private smsBatchModel: Model<SMSBatch>,
private authService: AuthService, private authService: AuthService,
private webhookService: WebhookService,
) {} ) {}
async registerDevice( async registerDevice(
@ -343,7 +346,7 @@ export class GatewayService {
console.log(e) console.log(e)
} }
} }
const successCount = fcmResponses.reduce( const successCount = fcmResponses.reduce(
(acc, m) => acc + m.successCount, (acc, m) => acc + m.successCount,
0, 0,
@ -411,7 +414,14 @@ export class GatewayService {
console.log(e) console.log(e)
}) })
// TODO: Implement webhook to forward received SMS to user's callback URL
this.webhookService.deliverNotification({
sms,
user: device.user,
event: WebhookEvent.MESSAGE_RECEIVED,
})
.catch((e) => {
console.log(e)
})
return sms return sms
} }

41
api/src/webhook/schemas/webhook-notification.schema.ts

@ -0,0 +1,41 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { WebhookSubscription } from './webhook-subscription.schema'
import { SMS } from 'src/gateway/schemas/sms.schema'
export type WebhookNotificationDocument = WebhookNotification & Document
@Schema({ timestamps: true })
export class WebhookNotification {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: WebhookSubscription.name, required: true })
webhookSubscription: WebhookSubscription
@Prop({ type: String, required: true })
event: string
@Prop({ type: Object, required: true })
payload: object
@Prop({ type: Types.ObjectId, ref: SMS.name })
sms: SMS
@Prop({ type: Date })
deliveredAt: Date
@Prop({ type: Date })
lastDeliveryAttemptAt: Date
@Prop({ type: Date })
nextDeliveryAttemptAt: Date
@Prop({ type: Number, default: 0 })
deliveryAttemptCount: number
@Prop({ type: Date })
deliveryAttemptAbortedAt: Date
}
export const WebhookNotificationSchema =
SchemaFactory.createForClass(WebhookNotification)

43
api/src/webhook/schemas/webhook-subscription.schema.ts

@ -0,0 +1,43 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from 'src/users/schemas/user.schema'
import { WebhookEvent } from '../webhook-event.enum'
export type WebhookSubscriptionDocument = WebhookSubscription & Document
@Schema({ timestamps: true })
export class WebhookSubscription {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: User.name, required: true })
user: User
@Prop({ type: Boolean, default: true })
isActive: boolean
@Prop({ type: [String], default: [WebhookEvent.MESSAGE_RECEIVED] })
events: string[]
@Prop({ type: String, required: true })
deliveryUrl: string
@Prop({ type: String, required: true })
signingSecret: string
@Prop({ type: Number, default: 0 })
successfulDeliveryCount: number
@Prop({ type: Number, default: 0 })
deliveryAttemptCount: number
@Prop({ type: Date })
lastDeliveryAttemptAt: Date
@Prop({ type: Date })
lastDeliverySuccessAt: Date
}
export const WebhookSubscriptionSchema =
SchemaFactory.createForClass(WebhookSubscription)
WebhookSubscriptionSchema.index({ user: 1, events: 1 }, { unique: true })

3
api/src/webhook/webhook-event.enum.ts

@ -0,0 +1,3 @@
export enum WebhookEvent {
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
}

68
api/src/webhook/webhook.controller.ts

@ -0,0 +1,68 @@
import {
Body,
Request,
Param,
Post,
Patch,
Controller,
Get,
UseGuards,
} from '@nestjs/common'
import { WebhookService } from './webhook.service'
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'
import { AuthGuard } from 'src/auth/guards/auth.guard'
@ApiTags('webhooks')
@ApiBearerAuth()
@Controller('webhooks')
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}
@Get()
@UseGuards(AuthGuard)
async getWebhooks(@Request() req) {
const data = await this.webhookService.findWebhooksForUser({
user: req.user,
})
return { data }
}
@Get(':webhookId')
@UseGuards(AuthGuard)
async getWebhook(@Request() req, @Param('webhookId') webhookId: string) {
const data = await this.webhookService.findOne({
user: req.user,
webhookId,
})
return { data }
}
@Post()
@UseGuards(AuthGuard)
async createWebhook(
@Request() req,
@Body() createWebhookDto: CreateWebhookDto,
) {
const data = await this.webhookService.create({
user: req.user,
createWebhookDto,
})
return { data }
}
@Patch(':webhookId')
@UseGuards(AuthGuard)
async updateWebhook(
@Request() req,
@Param('webhookId') webhookId: string,
@Body() updateWebhookDto: UpdateWebhookDto,
) {
const data = await this.webhookService.update({
user: req.user,
webhookId,
updateWebhookDto,
})
return { data }
}
}

14
api/src/webhook/webhook.dto.ts

@ -0,0 +1,14 @@
import { WebhookEvent } from './webhook-event.enum'
export class CreateWebhookDto {
deliveryUrl: string
signingSecret?: string
events: WebhookEvent[]
}
export class UpdateWebhookDto {
isActive: boolean
deliveryUrl: string
signingSecret: string
events: WebhookEvent[]
}

35
api/src/webhook/webhook.module.ts

@ -0,0 +1,35 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { WebhookController } from './webhook.controller'
import { WebhookService } from './webhook.service'
import {
WebhookSubscription,
WebhookSubscriptionSchema,
} from './schemas/webhook-subscription.schema'
import {
WebhookNotification,
WebhookNotificationSchema,
} from './schemas/webhook-notification.schema'
import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module'
@Module({
imports: [
MongooseModule.forFeature([
{
name: WebhookSubscription.name,
schema: WebhookSubscriptionSchema,
},
{
name: WebhookNotification.name,
schema: WebhookNotificationSchema,
},
]),
AuthModule,
UsersModule,
],
controllers: [WebhookController],
providers: [WebhookService],
exports: [MongooseModule, WebhookService],
})
export class WebhookModule {}

270
api/src/webhook/webhook.service.ts

@ -0,0 +1,270 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { Model } from 'mongoose'
import {
WebhookSubscription,
WebhookSubscriptionDocument,
} from './schemas/webhook-subscription.schema'
import { InjectModel } from '@nestjs/mongoose'
import { WebhookEvent } from './webhook-event.enum'
import {
WebhookNotification,
WebhookNotificationDocument,
} from './schemas/webhook-notification.schema'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { Cron } from '@nestjs/schedule'
import { CronExpression } from '@nestjs/schedule'
import * as crypto from 'crypto'
@Injectable()
export class WebhookService {
constructor(
@InjectModel(WebhookSubscription.name)
private webhookSubscriptionModel: Model<WebhookSubscriptionDocument>,
@InjectModel(WebhookNotification.name)
private webhookNotificationModel: Model<WebhookNotificationDocument>,
) {}
async findOne({ user, webhookId }) {
const webhook = await this.webhookSubscriptionModel.findOne({
_id: webhookId,
user: user._id,
})
if (!webhook) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND)
}
return webhook
}
async findWebhooksForUser({ user }) {
return await this.webhookSubscriptionModel.find({ user: user._id })
}
async create({ user, createWebhookDto }) {
const { events, deliveryUrl, signingSecret } = createWebhookDto
// Add URL validation
try {
new URL(deliveryUrl)
} catch (e) {
throw new HttpException('Invalid delivery URL', HttpStatus.BAD_REQUEST)
}
// validate signing secret
if (signingSecret.length < 20) {
throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST)
}
const existingSubscription = await this.webhookSubscriptionModel.findOne({
user: user._id,
events,
})
if (existingSubscription) {
throw new HttpException(
'You have already subscribed to this event',
HttpStatus.BAD_REQUEST,
)
}
if (!events.every((event) => Object.values(WebhookEvent).includes(event))) {
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST)
}
// TODO: Encrypt signing secret
// const webhookSignatureKey = process.env.WEBHOOK_SIGNATURE_KEY
// const encryptedSigningSecret = encrypt(signingSecret, webhookSignatureKey)
const webhookSubscription = await this.webhookSubscriptionModel.create({
user: user._id,
events,
deliveryUrl,
signingSecret,
})
return webhookSubscription
}
async update({ user, webhookId, updateWebhookDto }) {
const webhookSubscription = await this.webhookSubscriptionModel.findOne({
_id: webhookId,
user: user._id,
})
if (!webhookSubscription) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND)
}
if (updateWebhookDto.hasOwnProperty('isActive')) {
webhookSubscription.isActive = updateWebhookDto.isActive
}
if (updateWebhookDto.hasOwnProperty('deliveryUrl')) {
webhookSubscription.deliveryUrl = updateWebhookDto.deliveryUrl
}
// if there is a valid uuid signing secret, update it
if (
updateWebhookDto.hasOwnProperty('signingSecret') &&
updateWebhookDto.signingSecret.length < 20
) {
throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST)
} else if (updateWebhookDto.hasOwnProperty('signingSecret')) {
webhookSubscription.signingSecret = updateWebhookDto.signingSecret
}
await webhookSubscription.save()
return webhookSubscription
}
async deliverNotification({ sms, user, event }) {
const webhookSubscription = await this.webhookSubscriptionModel.findOne({
user: user._id,
events: { $in: [event] },
isActive: true,
})
if (!webhookSubscription) {
return
}
if (event === WebhookEvent.MESSAGE_RECEIVED) {
const payload = {
smsId: sms._id,
sender: sms.sender,
message: sms.message,
receivedAt: sms.receivedAt,
deviceId: sms.device,
webhookSubscriptionId: webhookSubscription._id,
webhookEvent: event,
}
const webhookNotification = await this.webhookNotificationModel.create({
webhookSubscription: webhookSubscription._id,
event,
payload,
sms,
})
await this.attemptWebhookDelivery(webhookNotification)
} else {
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST)
}
}
private async attemptWebhookDelivery(
webhookNotification: WebhookNotificationDocument,
) {
const now = new Date()
const webhookSubscription = await this.webhookSubscriptionModel.findById(
webhookNotification.webhookSubscription,
)
if (!webhookSubscription) {
console.error(
`Webhook subscription not found for ${webhookNotification._id}`,
)
return
}
if (!webhookSubscription.isActive) {
webhookNotification.deliveryAttemptAbortedAt = now
await webhookNotification.save()
console.error(
`Webhook subscription is not active for ${webhookNotification._id}, aborting delivery`,
)
return
}
const deliveryUrl = webhookSubscription?.deliveryUrl
const signingSecret = webhookSubscription?.signingSecret
const signature = crypto
.createHmac('sha256', signingSecret)
.update(JSON.stringify(webhookNotification.payload))
.digest('hex')
try {
await axios.post(deliveryUrl, webhookNotification.payload, {
headers: {
'X-Signature': signature,
},
timeout: 10000,
})
webhookNotification.deliveryAttemptCount += 1
webhookNotification.lastDeliveryAttemptAt = now
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt(
webhookNotification.deliveryAttemptCount,
)
webhookNotification.deliveredAt = now
await webhookNotification.save()
webhookSubscription.successfulDeliveryCount += 1
webhookSubscription.lastDeliverySuccessAt = now
} catch (e) {
console.error(
`Failed to deliver webhook notification ${webhookNotification._id}: received response status code ${e.response.status}`,
)
webhookNotification.deliveryAttemptCount += 1
webhookNotification.lastDeliveryAttemptAt = now
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt(
webhookNotification.deliveryAttemptCount,
)
await webhookNotification.save()
} finally {
webhookSubscription.deliveryAttemptCount += 1
await webhookSubscription.save()
}
}
private getNextDeliveryAttemptAt(deliveryAttemptCount: number): Date {
// Delays in minutes after a failed delivery attempt
const delaySequence = [
3, // 3 minutes
5, // 5 minutes
30, // 30 minutes
60, // 1 hour
360, // 6 hours
1440, // 1 day
4320, // 3 days
10080, // 7 days
43200, // 30 days
]
// Get the delay in minutes (use last value if attempt count exceeds sequence length)
const delayInMinutes =
delaySequence[
Math.min(deliveryAttemptCount - 1, delaySequence.length - 1)
] || delaySequence[delaySequence.length - 1]
// Convert minutes to milliseconds and add to current time
return new Date(Date.now() + delayInMinutes * 60 * 1000)
}
// Check for notifications that need to be delivered every 3 minutes
@Cron('0 */3 * * * *')
async checkForNotificationsToDeliver() {
const now = new Date()
const notifications = await this.webhookNotificationModel
.find({
nextDeliveryAttemptAt: { $lte: now },
deliveredAt: null,
deliveryAttemptCount: { $lt: 10 },
deliveryAttemptAbortedAt: null,
})
.sort({ nextDeliveryAttemptAt: 1 })
.limit(30)
if (notifications.length === 0) {
return
}
console.log(`delivering ${notifications.length} webhook notifications`)
for (const notification of notifications) {
await this.attemptWebhookDelivery(notification)
}
}
}

25
web/app/(app)/dashboard/(components)/main-dashboard.tsx

@ -1,23 +1,16 @@
'use client' 'use client'
import { useRouter, usePathname } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
// import Overview from "@/components/overview";
// import DeviceList from "@/components/device-list";
// import ApiKeys from "@/components/api-keys";
// import MessagingPanel from "@/components/messaging-panel";
import { Webhook, MessageSquare } from 'lucide-react' import { Webhook, MessageSquare } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import Overview from './overview' import Overview from './overview'
import DeviceList from './device-list' import DeviceList from './device-list'
import ApiKeys from './api-keys' import ApiKeys from './api-keys'
import Messaging from './messaging' import Messaging from './messaging'
import WebhooksSection from './webhooks/webhooks-section'
export default function DashboardOverview() { export default function DashboardOverview() {
const router = useRouter()
const pathname = usePathname()
const [currentTab, setCurrentTab] = useState('overview') const [currentTab, setCurrentTab] = useState('overview')
@ -49,19 +42,7 @@ export default function DashboardOverview() {
<ApiKeys /> <ApiKeys />
</div> </div>
<Card>
<CardHeader>
<CardTitle>Webhooks (Coming Soon)</CardTitle>
</CardHeader>
<CardContent>
<Alert>
<AlertDescription>
Webhook support is coming soon! You&apos;ll be able to configure
endpoints to receive SMS notifications in real-time.
</AlertDescription>
</Alert>
</CardContent>
</Card>
<WebhooksSection />
</TabsContent> </TabsContent>
<TabsContent value='messaging'> <TabsContent value='messaging'>

198
web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx

@ -0,0 +1,198 @@
'use client'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { v4 as uuidv4 } from 'uuid'
import { WebhookData } from '@/lib/types'
import { WEBHOOK_EVENTS } from '@/lib/constants'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useToast } from '@/hooks/use-toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
const formSchema = z.object({
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }),
events: z.array(z.string()).min(1, { message: 'Select at least one event' }),
isActive: z.boolean().default(true),
signingSecret: z.string().min(1, { message: 'Signing secret is required' }),
})
interface CreateWebhookDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CreateWebhookDialog({
open,
onOpenChange,
}: CreateWebhookDialogProps) {
const { toast } = useToast()
const queryClient = useQueryClient()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
deliveryUrl: '',
events: [WEBHOOK_EVENTS.MESSAGE_RECEIVED],
isActive: true,
signingSecret: uuidv4(),
},
})
const createWebhookMutation = useMutation({
mutationFn: (values: z.infer<typeof formSchema>) =>
httpBrowserClient.post(ApiEndpoints.gateway.createWebhook(), values),
onSuccess: () => {
toast({
title: 'Success',
description: 'Webhook created successfully',
})
queryClient.invalidateQueries({ queryKey: ['webhooks'] })
onOpenChange(false)
form.reset()
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to create webhook',
variant: 'destructive',
})
},
})
const onSubmit = (values: z.infer<typeof formSchema>) => {
createWebhookMutation.mutate(values)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Create Webhook</DialogTitle>
<DialogDescription>
Configure your webhook endpoint to receive real-time SMS
notifications.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='deliveryUrl'
render={({ field }) => (
<FormItem>
<FormLabel>Delivery URL</FormLabel>
<FormControl>
<Input
placeholder='https://api.example.com/webhooks'
{...field}
/>
</FormControl>
<FormDescription>
The URL where webhook notifications will be sent via POST
requests
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='signingSecret'
render={({ field }) => (
<FormItem>
<FormLabel>Signing Secret</FormLabel>
<FormControl>
<div className='flex space-x-2'>
<Input {...field} type='text' />
<Button
type='button'
variant='outline'
onClick={() => field.onChange(uuidv4())}
>
Generate
</Button>
</div>
</FormControl>
<FormDescription>
Used to verify webhook payload authenticity
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='events'
render={({ field }) => (
<FormItem>
<FormLabel>Events</FormLabel>
<Select
value={field.value[0]}
onValueChange={(value) => field.onChange([value])}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select events to subscribe to' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={WEBHOOK_EVENTS.MESSAGE_RECEIVED}>
SMS Received
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the events you want to receive notifications for
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end space-x-2'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type='submit'
disabled={createWebhookMutation.isPending}
>
{createWebhookMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

50
web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx

@ -0,0 +1,50 @@
'use client'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
interface DeleteWebhookButtonProps {
onDelete: () => void
}
export function DeleteWebhookButton({ onDelete }: DeleteWebhookButtonProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='outline' size='sm' disabled>
<Trash2 className='h-4 w-4 text-destructive' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this webhook? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
disabled
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

201
web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx

@ -0,0 +1,201 @@
'use client'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { WebhookData } from '@/lib/types'
import { WEBHOOK_EVENTS } from '@/lib/constants'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useToast } from '@/hooks/use-toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
const formSchema = z.object({
deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }),
events: z.array(z.string()).min(1, { message: 'Select at least one event' }),
isActive: z.boolean().default(true),
signingSecret: z.string().min(1, { message: 'Signing secret is required' }),
})
interface EditWebhookDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
webhook: WebhookData
}
export function EditWebhookDialog({
open,
onOpenChange,
webhook,
}: EditWebhookDialogProps) {
const queryClient = useQueryClient()
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: {
deliveryUrl: webhook.deliveryUrl,
events: webhook.events,
isActive: webhook.isActive,
signingSecret: webhook.signingSecret,
},
})
const { mutate: updateWebhook, isPending } = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) => {
return httpBrowserClient.patch(
ApiEndpoints.gateway.updateWebhook(webhook._id),
values
)
},
onSuccess: () => {
toast({
title: 'Success',
description: 'Webhook updated successfully',
})
// Invalidate and refetch webhooks list
queryClient.invalidateQueries({ queryKey: ['webhooks'] })
onOpenChange(false)
},
onError: () => {
toast({
title: 'Error',
description: 'Failed to update webhook',
variant: 'destructive',
})
},
})
const onSubmit = (values: z.infer<typeof formSchema>) => {
updateWebhook(values)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Edit Webhook</DialogTitle>
<DialogDescription>
Update your webhook configuration.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='deliveryUrl'
render={({ field }) => (
<FormItem>
<FormLabel>Delivery URL</FormLabel>
<FormControl>
<Input
placeholder='https://api.example.com/webhooks'
{...field}
/>
</FormControl>
<FormDescription>
The URL where webhook notifications will be sent via POST
requests
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='signingSecret'
render={({ field }) => (
<FormItem>
<FormLabel>Signing Secret</FormLabel>
<FormControl>
<div className='flex space-x-2'>
<Input {...field} type='text' />
<Button
type='button'
variant='outline'
onClick={() => field.onChange(uuidv4())}
>
Generate
</Button>
</div>
</FormControl>
<FormDescription>
Used to verify webhook payload authenticity
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='events'
render={({ field }) => (
<FormItem>
<FormLabel>Events</FormLabel>
<Select
value={field.value[0]}
onValueChange={(value) => field.onChange([value])}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select events to subscribe to' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={WEBHOOK_EVENTS.MESSAGE_RECEIVED}>
SMS Received
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the events you want to receive notifications for
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end space-x-2'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type='submit' disabled={isPending}>
{isPending ? 'Updating...' : 'Update'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

139
web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx

@ -0,0 +1,139 @@
'use client'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DeleteWebhookButton } from './delete-webhook-button'
import { Edit2, Eye, EyeOff } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { useState } from 'react'
import { useToast } from '@/hooks/use-toast'
import { CopyButton } from '@/components/shared/copy-button'
import { WebhookData } from '@/lib/types'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useQueryClient } from '@tanstack/react-query'
interface WebhookCardProps {
webhook: WebhookData
onEdit: () => void
onDelete?: () => void
}
export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const queryClient = useQueryClient()
const [showSecret, setShowSecret] = useState(false)
const handleToggle = async (checked: boolean) => {
setIsLoading(true)
try {
await httpBrowserClient.patch(
ApiEndpoints.gateway.updateWebhook(webhook._id),
{ isActive: checked }
)
await queryClient.invalidateQueries({
queryKey: ['webhooks']
})
toast({
title: `Webhook ${checked ? 'enabled' : 'disabled'}`,
description: `Webhook notifications are now ${
checked ? 'enabled' : 'disabled'
}.`,
})
} catch (error) {
toast({
title: 'Error',
description: `Failed to ${checked ? 'enable' : 'disable'} webhook`,
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
const maskSecret = (secret: string) => {
// if the secret is less than 18 characters, show all
if (secret.length <= 18) {
return secret.slice(0, 18)
}
return secret.slice(0, 18) + '*'.repeat(secret.length - 24)
}
return (
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div className='space-y-1'>
<div className='flex items-center space-x-2'>
<h3 className='text-lg font-semibold'>Webhook Endpoint</h3>
<Badge variant={webhook.isActive ? 'default' : 'secondary'}>
{webhook.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className='text-sm text-muted-foreground'>
Notifications for SMS events
</p>
</div>
<div className='flex items-center space-x-2'>
<Switch
checked={webhook.isActive}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
<Button variant='outline' size='sm' onClick={onEdit}>
<Edit2 className='h-4 w-4 mr-2' />
Edit
</Button>
<DeleteWebhookButton onDelete={onDelete} />
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div>
<label className='text-sm font-medium'>Delivery URL</label>
<div className='flex items-center mt-1'>
<code className='flex-1 bg-muted px-3 py-2 rounded-md text-sm'>
{webhook.deliveryUrl}
</code>
<CopyButton value={webhook.deliveryUrl} label='Copy URL' />
</div>
</div>
<div>
<label className='text-sm font-medium'>Signing Secret</label>
<div className='flex items-center mt-1'>
<code className='flex-1 bg-muted px-3 py-2 rounded-md text-sm font-mono'>
{showSecret ? webhook.signingSecret : maskSecret(webhook.signingSecret)}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSecret(!showSecret)}
className="mx-2"
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<CopyButton value={webhook.signingSecret} label='Copy Secret' />
</div>
</div>
<div>
<label className='text-sm font-medium'>Events</label>
<div className='flex flex-wrap gap-2 mt-1'>
{webhook.events.map((event) => (
<Badge key={event} variant='secondary'>
{event}
</Badge>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)
}

169
web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx

@ -0,0 +1,169 @@
'use client'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Code } from '@/components/ui/code'
import { AlertCircle } from 'lucide-react'
const SAMPLE_PAYLOAD = {
smsId: 'smsId',
sender: '+123456789',
message: 'message',
receivedAt: 'datetime',
deviceId: 'deviceId',
webhookSubscriptionId: 'webhookSubscriptionId',
webhookEvent: 'sms.received',
}
const VERIFICATION_CODE = `
// Node.js example using crypto
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(JSON.stringify(payload)).digest('hex');
const signatureHash = signature.split('=')[1];
return crypto.timingSafeEqual(
Buffer.from(signatureHash),
Buffer.from(digest)
);
}
// Express middleware example
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.body;
if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the webhook
console.log('Webhook verified:', payload);
res.status(200).send('OK');
});
`
const PYTHON_CODE = `
# Python example using hmac
import hmac
import hashlib
import json
from flask import Flask, request
app = Flask(__name__)
def verify_signature(payload, signature, secret):
expected = hmac.new(
secret.encode('utf-8'),
json.dumps(payload).encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature.split('=')[1], expected)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Signature')
if not verify_signature(request.json, signature, WEBHOOK_SECRET):
return 'Invalid signature', 401
# Process the webhook
print('Webhook verified:', request.json)
return 'OK', 200
`
export function WebhookDocs() {
return (
<Accordion type='multiple' className='w-full space-y-4'>
<AccordionItem value='delivery' className='border rounded-lg'>
<AccordionTrigger className='px-4 hover:no-underline [&[data-state=open]>div]:bg-muted'>
<div className='flex items-center gap-2 py-2 -my-2 px-2 rounded-md'>
<AlertCircle className='h-4 w-4' />
<span>Webhook Delivery Information</span>
</div>
</AccordionTrigger>
<AccordionContent className='px-4 pb-4'>
<div className='space-y-2 mt-2 text-sm text-muted-foreground'>
<p>
When a new SMS is received, we&apos;ll send a POST request to your
webhook URL with the event data. Your endpoint should:
</p>
<ul className='list-disc pl-6 space-y-1'>
<li>Accept POST requests</li>
<li>Return a 2XX status code to acknowledge receipt</li>
<li>Process the request within 10 seconds</li>
</ul>
<p className='mt-2'>
If we don&apos;t receive a successful response, we&apos;ll retry the
delivery at increasing intervals: 3 minutes, 5 minutes, 30 minutes,
1 hour, 6 hours, 1 day, 3 days, 7 days, 30 days.
</p>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value='implementation' className='border rounded-lg'>
<AccordionTrigger className='px-4 hover:no-underline [&[data-state=open]>div]:bg-muted'>
<div className='flex items-center gap-2 py-2 -my-2 px-2 rounded-md'>
<AlertCircle className='h-4 w-4' />
<span>Security & Implementation Guide</span>
</div>
</AccordionTrigger>
<AccordionContent className='px-4 pb-4'>
<Tabs defaultValue='overview' className='w-full mt-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='payload'>Payload</TabsTrigger>
<TabsTrigger value='verification'>Verification</TabsTrigger>
</TabsList>
<TabsContent value='overview'>
<div className='space-y-2 mt-4 text-sm text-muted-foreground'>
<p>Each webhook request includes:</p>
<ul className='list-disc pl-6 space-y-1'>
<li>Payload in JSON format</li>
<li>X-Signature header for verification</li>
<li>
Signature format: sha256=HMAC_SHA256(payload, secret)
</li>
</ul>
</div>
</TabsContent>
<TabsContent value='payload'>
<div className='space-y-4 mt-4'>
<h4 className='text-sm font-medium'>Sample Payload</h4>
<Code>{JSON.stringify(SAMPLE_PAYLOAD, null, 2)}</Code>
</div>
</TabsContent>
<TabsContent value='verification'>
<div className='space-y-4 mt-4'>
<Tabs defaultValue='node'>
<TabsList>
<TabsTrigger value='node'>Node.js</TabsTrigger>
<TabsTrigger value='python'>Python</TabsTrigger>
</TabsList>
<TabsContent value='node'>
<Code>{VERIFICATION_CODE}</Code>
</TabsContent>
<TabsContent value='python'>
<Code>{PYTHON_CODE}</Code>
</TabsContent>
</Tabs>
</div>
</TabsContent>
</Tabs>
</AccordionContent>
</AccordionItem>
</Accordion>
)
}

178
web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx

@ -0,0 +1,178 @@
'use client'
import { Button } from '@/components/ui/button'
import { PlusCircle, Webhook } from 'lucide-react'
import { useState } from 'react'
import { WebhookData } from '@/lib/types'
import { WebhookCard } from './webhook-card'
import { WebhookDocs } from './webhook-docs'
import { CreateWebhookDialog } from './create-webhook-dialog'
import { EditWebhookDialog } from './edit-webhook-dialog'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
function WebhookCardSkeleton() {
return (
<div className='rounded-lg border p-6 space-y-4'>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<Skeleton className='h-5 w-[200px]' />
<Skeleton className='h-4 w-[150px]' />
</div>
<div className='flex space-x-2'>
<Skeleton className='h-9 w-9' />
<Skeleton className='h-9 w-16' />
<Skeleton className='h-9 w-9' />
</div>
</div>
<div className='space-y-4'>
<div>
<Skeleton className='h-4 w-[100px] mb-2' />
<Skeleton className='h-10 w-full' />
</div>
<div>
<Skeleton className='h-4 w-[100px] mb-2' />
<Skeleton className='h-10 w-full' />
</div>
<div>
<Skeleton className='h-4 w-[100px] mb-2' />
<div className='flex gap-2'>
<Skeleton className='h-6 w-20' />
<Skeleton className='h-6 w-20' />
</div>
</div>
</div>
</div>
)
}
export default function WebhooksSection() {
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [selectedWebhook, setSelectedWebhook] = useState<WebhookData | null>(
null
)
const queryClient = useQueryClient()
const {
data: webhooks,
isLoading,
error,
} = useQuery({
queryKey: ['webhooks'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getWebhooks())
.then((res) => res.data),
})
const handleCreateClick = () => {
setCreateDialogOpen(true)
}
const handleEditClick = (webhook: WebhookData) => {
setSelectedWebhook(webhook)
setEditDialogOpen(true)
}
return (
<div className='container mx-auto py-8'>
<div className='flex justify-between items-center mb-8'>
<div>
<h1 className='text-3xl font-bold flex items-center gap-2'>
<Webhook className='h-8 w-8' />
Webhooks
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className='text-xs font-medium px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'>
BETA
</span>
</TooltipTrigger>
<TooltipContent>
<p>This feature is in beta and may undergo changes. Use with caution in production environments.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</h1>
<p className='text-muted-foreground mt-2'>
Manage webhook notifications for your SMS events
</p>
</div>
<Button
onClick={handleCreateClick}
disabled={webhooks?.data?.length > 0 || isLoading}
variant='default'
>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
</div>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-8'>
<div>
{isLoading ? (
<div className='grid gap-4'>
<WebhookCardSkeleton />
<WebhookCardSkeleton />
</div>
) : error ? (
<div className='rounded-lg border border-destructive/50 p-4 text-destructive'>
Error: {error.message}
</div>
) : webhooks?.data?.length > 0 ? (
<div className='grid gap-4'>
{webhooks.data.map((webhook) => (
<WebhookCard
key={webhook._id}
webhook={webhook}
onEdit={() => handleEditClick(webhook)}
/>
))}
</div>
) : (
<div className='bg-muted/50 rounded-lg p-8 text-center'>
<h3 className='text-lg font-medium mb-2'>No webhook configured</h3>
<p className='text-muted-foreground mb-4'>
Create a webhook to receive real-time notifications for SMS events
</p>
<Button onClick={handleCreateClick} variant='default'>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
</div>
)}
</div>
<div className='hidden lg:block sticky top-8 self-start'>
<WebhookDocs />
</div>
</div>
<div className='block lg:hidden mt-8'>
<WebhookDocs />
</div>
<CreateWebhookDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
{selectedWebhook && (
<EditWebhookDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
webhook={selectedWebhook}
/>
)}
</div>
)
}

49
web/components/shared/copy-button.tsx

@ -0,0 +1,49 @@
"use client";
import { Button } from "@/components/ui/button";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
interface CopyButtonProps {
value: string;
label: string;
}
export function CopyButton({ value, label }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast({
title: "Copied!",
description: `${label} copied to clipboard`,
});
setTimeout(() => setCopied(false), 2000);
} catch (err) {
toast({
title: "Failed to copy",
description: "Please try again",
variant: "destructive",
});
}
};
return (
<Button
variant="ghost"
size="sm"
onClick={copyToClipboard}
className="ml-2"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
);
}

141
web/components/ui/alert-dialog.tsx

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

17
web/components/ui/code.tsx

@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
interface CodeProps extends React.HTMLAttributes<HTMLPreElement> {}
export function Code({ className, children, ...props }: CodeProps) {
return (
<pre
className={cn(
"rounded-lg bg-muted p-4 overflow-x-auto text-sm",
className
)}
{...props}
>
<code>{children}</code>
</pre>
);
}

15
web/components/ui/skeleton.tsx

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

32
web/components/ui/tooltip.tsx

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

3
web/config/api.ts

@ -23,6 +23,9 @@ export const ApiEndpoints = {
sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`, sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`,
getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`, getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`,
getWebhooks: () => '/webhooks',
createWebhook: () => '/webhooks',
updateWebhook: (id: string) => `/webhooks/${id}`,
getStats: () => '/gateway/stats', getStats: () => '/gateway/stats',
}, },
} }

3
web/lib/constants.ts

@ -0,0 +1,3 @@
export const WEBHOOK_EVENTS = {
MESSAGE_RECEIVED: 'MESSAGE_RECEIVED',
} as const

17
web/lib/types.ts

@ -0,0 +1,17 @@
export interface WebhookData {
_id?: string
deliveryUrl: string
events: string[]
isActive: boolean
signingSecret: string
}
export interface WebhookPayload {
smsId: string
sender: string
message: string
receivedAt: string
deviceId: string
webhookSubscriptionId: string
webhookEvent: string
}

3
web/package.json

@ -15,6 +15,7 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
@ -28,6 +29,7 @@
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.6",
"@react-oauth/google": "^0.12.1", "@react-oauth/google": "^0.12.1",
"@tanstack/react-query": "^5.61.0", "@tanstack/react-query": "^5.61.0",
"axios": "^1.6.5", "axios": "^1.6.5",
@ -48,6 +50,7 @@
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"engines": { "engines": {

413
web/pnpm-lock.yaml

@ -17,6 +17,9 @@ importers:
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-avatar': '@radix-ui/react-avatar':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -56,6 +59,9 @@ importers:
'@radix-ui/react-toast': '@radix-ui/react-toast':
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 1.2.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-oauth/google': '@react-oauth/google':
specifier: ^0.12.1 specifier: ^0.12.1
version: 0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -116,6 +122,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.14) version: 1.0.7(tailwindcss@3.4.14)
uuid:
specifier: ^11.0.3
version: 11.0.3
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.23.8 version: 3.23.8
@ -348,6 +357,9 @@ packages:
'@radix-ui/primitive@1.1.0': '@radix-ui/primitive@1.1.0':
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
'@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
'@radix-ui/react-accordion@1.2.1': '@radix-ui/react-accordion@1.2.1':
resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==} resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==}
peerDependencies: peerDependencies:
@ -361,6 +373,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-alert-dialog@1.1.4':
resolution: {integrity: sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.0': '@radix-ui/react-arrow@1.1.0':
resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==} resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==}
peerDependencies: peerDependencies:
@ -374,6 +399,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-arrow@1.1.1':
resolution: {integrity: sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.1': '@radix-ui/react-avatar@1.1.1':
resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==}
peerDependencies: peerDependencies:
@ -435,6 +473,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-compose-refs@1.1.1':
resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.0': '@radix-ui/react-context@1.1.0':
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
peerDependencies: peerDependencies:
@ -466,6 +513,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-dialog@1.1.4':
resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.0': '@radix-ui/react-direction@1.1.0':
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies: peerDependencies:
@ -488,6 +548,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-dismissable-layer@1.1.3':
resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.2': '@radix-ui/react-dropdown-menu@2.1.2':
resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==}
peerDependencies: peerDependencies:
@ -523,6 +596,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-focus-scope@1.1.1':
resolution: {integrity: sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-icons@1.3.0': '@radix-ui/react-icons@1.3.0':
resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==} resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==}
peerDependencies: peerDependencies:
@ -589,6 +675,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popper@1.2.1':
resolution: {integrity: sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.2': '@radix-ui/react-portal@1.1.2':
resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==}
peerDependencies: peerDependencies:
@ -602,6 +701,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-portal@1.1.3':
resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.1': '@radix-ui/react-presence@1.1.1':
resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==}
peerDependencies: peerDependencies:
@ -615,6 +727,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-presence@1.1.2':
resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.0.0': '@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies: peerDependencies:
@ -628,6 +753,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-primitive@2.0.1':
resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.0': '@radix-ui/react-roving-focus@1.1.0':
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
peerDependencies: peerDependencies:
@ -676,6 +814,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-slot@1.1.1':
resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.1.1': '@radix-ui/react-switch@1.1.1':
resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==}
peerDependencies: peerDependencies:
@ -715,6 +862,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-tooltip@1.1.6':
resolution: {integrity: sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.0': '@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies: peerDependencies:
@ -791,6 +951,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-visually-hidden@1.1.1':
resolution: {integrity: sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.0': '@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
@ -2081,6 +2254,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.6.0: react-remove-scroll@2.6.0:
resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2091,6 +2274,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-remove-scroll@2.6.2:
resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.1: react-style-singleton@2.2.1:
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2101,6 +2294,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-syntax-highlighter@15.5.0: react-syntax-highlighter@15.5.0:
resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==}
peerDependencies: peerDependencies:
@ -2384,6 +2587,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.2: use-sidecar@1.1.2:
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2397,6 +2610,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.0.3:
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
hasBin: true
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@ -2621,6 +2838,8 @@ snapshots:
'@radix-ui/primitive@1.1.0': {} '@radix-ui/primitive@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
'@radix-ui/react-accordion@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-accordion@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.0 '@radix-ui/primitive': 1.1.0
@ -2638,6 +2857,20 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-alert-dialog@1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-arrow@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -2647,6 +2880,15 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-arrow@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-avatar@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
@ -2709,6 +2951,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@radix-ui/react-compose-refs@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-context@1.1.0(@types/react@18.2.48)(react@18.2.0)': '@radix-ui/react-context@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -2743,6 +2991,28 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-dialog@1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0)
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.6.2(@types/react@18.2.48)(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-direction@1.1.0(@types/react@18.2.48)(react@18.2.0)': '@radix-ui/react-direction@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -2762,6 +3032,19 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.0 '@radix-ui/primitive': 1.1.0
@ -2794,6 +3077,17 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-focus-scope@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-icons@1.3.0(react@18.2.0)': '@radix-ui/react-icons@1.3.0(react@18.2.0)':
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -2880,6 +3174,24 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-popper@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-arrow': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-rect': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-size': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/rect': 1.1.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-portal@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-portal@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -2890,6 +3202,16 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-portal@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-presence@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-presence@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@ -2900,6 +3222,16 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-presence@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.48)(react@18.2.0) '@radix-ui/react-slot': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@ -2909,6 +3241,15 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-primitive@2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.0 '@radix-ui/primitive': 1.1.0
@ -2979,6 +3320,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@radix-ui/react-slot@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-switch@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@radix-ui/react-switch@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.0 '@radix-ui/primitive': 1.1.0
@ -3030,6 +3378,26 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-tooltip@1.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-popper': 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.2.48)(react@18.2.0)': '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -3085,6 +3453,15 @@ snapshots:
'@types/react': 18.2.48 '@types/react': 18.2.48
'@types/react-dom': 18.2.18 '@types/react-dom': 18.2.18
'@radix-ui/react-visually-hidden@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/rect@1.1.0': {} '@radix-ui/rect@1.1.0': {}
'@react-oauth/google@0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@react-oauth/google@0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
@ -4525,6 +4902,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
react-remove-scroll-bar@2.3.8(@types/react@18.2.48)(react@18.2.0):
dependencies:
react: 18.2.0
react-style-singleton: 2.2.3(@types/react@18.2.48)(react@18.2.0)
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.48
react-remove-scroll@2.6.0(@types/react@18.2.48)(react@18.2.0): react-remove-scroll@2.6.0(@types/react@18.2.48)(react@18.2.0):
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -4536,6 +4921,17 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
react-remove-scroll@2.6.2(@types/react@18.2.48)(react@18.2.0):
dependencies:
react: 18.2.0
react-remove-scroll-bar: 2.3.8(@types/react@18.2.48)(react@18.2.0)
react-style-singleton: 2.2.1(@types/react@18.2.48)(react@18.2.0)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@18.2.48)(react@18.2.0)
use-sidecar: 1.1.2(@types/react@18.2.48)(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
react-style-singleton@2.2.1(@types/react@18.2.48)(react@18.2.0): react-style-singleton@2.2.1(@types/react@18.2.48)(react@18.2.0):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@ -4545,6 +4941,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
react-style-singleton@2.2.3(@types/react@18.2.48)(react@18.2.0):
dependencies:
get-nonce: 1.0.1
react: 18.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.48
react-syntax-highlighter@15.5.0(react@18.2.0): react-syntax-highlighter@15.5.0(react@18.2.0):
dependencies: dependencies:
'@babel/runtime': 7.23.8 '@babel/runtime': 7.23.8
@ -4878,6 +5282,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.2.48 '@types/react': 18.2.48
use-callback-ref@1.3.3(@types/react@18.2.48)(react@18.2.0):
dependencies:
react: 18.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.48
use-sidecar@1.1.2(@types/react@18.2.48)(react@18.2.0): use-sidecar@1.1.2(@types/react@18.2.48)(react@18.2.0):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0
@ -4888,6 +5299,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@11.0.3: {}
uuid@8.3.2: {} uuid@8.3.2: {}
which-boxed-primitive@1.0.2: which-boxed-primitive@1.0.2:

Loading…
Cancel
Save