mirror of
https://github.com/vernu/textbee.git
synced 2026-04-27 18:28:35 -04:00
@@ -27,6 +27,7 @@
|
||||
"@nestjs/mongoose": "^10.0.10",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.5",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
35
api/pnpm-lock.yaml
generated
35
api/pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
'@nestjs/platform-express':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@@ -806,6 +809,12 @@ packages:
|
||||
'@nestjs/common': ^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':
|
||||
resolution: {integrity: sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==}
|
||||
peerDependencies:
|
||||
@@ -1178,6 +1187,9 @@ packages:
|
||||
'@types/long@4.0.2':
|
||||
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
||||
|
||||
'@types/luxon@3.4.2':
|
||||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
@@ -1807,6 +1819,9 @@ packages:
|
||||
create-require@1.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2985,6 +3000,10 @@ packages:
|
||||
lru-memoizer@2.3.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5373,6 +5392,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 17.3.10(chokidar@3.6.0)
|
||||
@@ -5927,6 +5953,8 @@ snapshots:
|
||||
'@types/long@4.0.2':
|
||||
optional: true
|
||||
|
||||
'@types/luxon@3.4.2': {}
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
@@ -6691,6 +6719,11 @@ snapshots:
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cron@3.1.7:
|
||||
dependencies:
|
||||
'@types/luxon': 3.4.2
|
||||
luxon: 3.4.4
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -8219,6 +8252,8 @@ snapshots:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 6.0.0
|
||||
|
||||
luxon@3.4.4: {}
|
||||
|
||||
magic-string@0.30.8:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
@@ -5,7 +5,9 @@ import { AuthModule } from './auth/auth.module'
|
||||
import { UsersModule } from './users/users.module'
|
||||
import { ThrottlerModule } from '@nestjs/throttler'
|
||||
import { APP_GUARD } from '@nestjs/core/constants'
|
||||
import { WebhookModule } from './webhook/webhook.module'
|
||||
import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard'
|
||||
import { ScheduleModule } from '@nestjs/schedule'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,9 +18,11 @@ import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard'
|
||||
limit: 30,
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
GatewayModule,
|
||||
WebhookModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AuthModule } from '../auth/auth.module'
|
||||
import { UsersModule } from '../users/users.module'
|
||||
import { SMS, SMSSchema } from './schemas/sms.schema'
|
||||
import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema'
|
||||
import { WebhookModule } from 'src/webhook/webhook.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema'
|
||||
]),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
WebhookModule,
|
||||
],
|
||||
controllers: [GatewayController],
|
||||
providers: [GatewayService],
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
BatchResponse,
|
||||
Message,
|
||||
} from 'firebase-admin/lib/messaging/messaging-api'
|
||||
import { WebhookEvent } from 'src/webhook/webhook-event.enum'
|
||||
import { WebhookService } from 'src/webhook/webhook.service'
|
||||
@Injectable()
|
||||
export class GatewayService {
|
||||
constructor(
|
||||
@@ -26,6 +28,7 @@ export class GatewayService {
|
||||
@InjectModel(SMS.name) private smsModel: Model<SMS>,
|
||||
@InjectModel(SMSBatch.name) private smsBatchModel: Model<SMSBatch>,
|
||||
private authService: AuthService,
|
||||
private webhookService: WebhookService,
|
||||
) {}
|
||||
|
||||
async registerDevice(
|
||||
@@ -343,7 +346,7 @@ export class GatewayService {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const successCount = fcmResponses.reduce(
|
||||
(acc, m) => acc + m.successCount,
|
||||
0,
|
||||
@@ -411,7 +414,14 @@ export class GatewayService {
|
||||
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
|
||||
}
|
||||
|
||||
41
api/src/webhook/schemas/webhook-notification.schema.ts
Normal file
41
api/src/webhook/schemas/webhook-notification.schema.ts
Normal file
@@ -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
Normal file
43
api/src/webhook/schemas/webhook-subscription.schema.ts
Normal file
@@ -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
Normal file
3
api/src/webhook/webhook-event.enum.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum WebhookEvent {
|
||||
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
|
||||
}
|
||||
68
api/src/webhook/webhook.controller.ts
Normal file
68
api/src/webhook/webhook.controller.ts
Normal file
@@ -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
Normal file
14
api/src/webhook/webhook.dto.ts
Normal file
@@ -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
Normal file
35
api/src/webhook/webhook.module.ts
Normal file
@@ -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
Normal file
270
api/src/webhook/webhook.service.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
'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 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 { useState } from 'react'
|
||||
import Overview from './overview'
|
||||
import DeviceList from './device-list'
|
||||
import ApiKeys from './api-keys'
|
||||
import Messaging from './messaging'
|
||||
import WebhooksSection from './webhooks/webhooks-section'
|
||||
|
||||
export default function DashboardOverview() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const [currentTab, setCurrentTab] = useState('overview')
|
||||
|
||||
@@ -49,19 +42,7 @@ export default function DashboardOverview() {
|
||||
<ApiKeys />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Webhooks (Coming Soon)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Webhook support is coming soon! You'll be able to configure
|
||||
endpoints to receive SMS notifications in real-time.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WebhooksSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='messaging'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Normal file
139
web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx
Normal file
@@ -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
Normal file
169
web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx
Normal file
@@ -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'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't receive a successful response, we'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>
|
||||
)
|
||||
}
|
||||
@@ -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
Normal file
49
web/components/shared/copy-button.tsx
Normal file
@@ -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
Normal file
141
web/components/ui/alert-dialog.tsx
Normal file
@@ -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
Normal file
17
web/components/ui/code.tsx
Normal file
@@ -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
Normal file
15
web/components/ui/skeleton.tsx
Normal file
@@ -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
Normal file
32
web/components/ui/tooltip.tsx
Normal file
@@ -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 }
|
||||
@@ -23,6 +23,9 @@ export const ApiEndpoints = {
|
||||
sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`,
|
||||
getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`,
|
||||
|
||||
getWebhooks: () => '/webhooks',
|
||||
createWebhook: () => '/webhooks',
|
||||
updateWebhook: (id: string) => `/webhooks/${id}`,
|
||||
getStats: () => '/gateway/stats',
|
||||
},
|
||||
}
|
||||
|
||||
3
web/lib/constants.ts
Normal file
3
web/lib/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const WEBHOOK_EVENTS = {
|
||||
MESSAGE_RECEIVED: 'MESSAGE_RECEIVED',
|
||||
} as const
|
||||
17
web/lib/types.ts
Normal file
17
web/lib/types.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@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-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@tanstack/react-query": "^5.61.0",
|
||||
"axios": "^1.6.5",
|
||||
@@ -48,6 +50,7 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
413
web/pnpm-lock.yaml
generated
413
web/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@radix-ui/react-accordion':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@@ -56,6 +59,9 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
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)
|
||||
'@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':
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -116,6 +122,9 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.14)
|
||||
uuid:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
@@ -348,6 +357,9 @@ packages:
|
||||
'@radix-ui/primitive@1.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==}
|
||||
peerDependencies:
|
||||
@@ -361,6 +373,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==}
|
||||
peerDependencies:
|
||||
@@ -374,6 +399,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==}
|
||||
peerDependencies:
|
||||
@@ -435,6 +473,15 @@ packages:
|
||||
'@types/react':
|
||||
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':
|
||||
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
|
||||
peerDependencies:
|
||||
@@ -466,6 +513,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
|
||||
peerDependencies:
|
||||
@@ -488,6 +548,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==}
|
||||
peerDependencies:
|
||||
@@ -523,6 +596,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==}
|
||||
peerDependencies:
|
||||
@@ -589,6 +675,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==}
|
||||
peerDependencies:
|
||||
@@ -602,6 +701,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==}
|
||||
peerDependencies:
|
||||
@@ -615,6 +727,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
|
||||
peerDependencies:
|
||||
@@ -628,6 +753,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
|
||||
peerDependencies:
|
||||
@@ -676,6 +814,15 @@ packages:
|
||||
'@types/react':
|
||||
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':
|
||||
resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==}
|
||||
peerDependencies:
|
||||
@@ -715,6 +862,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
||||
peerDependencies:
|
||||
@@ -791,6 +951,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
|
||||
|
||||
@@ -2081,6 +2254,16 @@ packages:
|
||||
'@types/react':
|
||||
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:
|
||||
resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2091,6 +2274,16 @@ packages:
|
||||
'@types/react':
|
||||
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:
|
||||
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2101,6 +2294,16 @@ packages:
|
||||
'@types/react':
|
||||
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:
|
||||
resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==}
|
||||
peerDependencies:
|
||||
@@ -2384,6 +2587,16 @@ packages:
|
||||
'@types/react':
|
||||
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:
|
||||
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2397,6 +2610,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@11.0.3:
|
||||
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
@@ -2621,6 +2838,8 @@ snapshots:
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
@@ -2638,6 +2857,20 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
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)
|
||||
@@ -2647,6 +2880,15 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
|
||||
@@ -2709,6 +2951,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -2743,6 +2991,28 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -2762,6 +3032,19 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
@@ -2794,6 +3077,17 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -2880,6 +3174,24 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
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)
|
||||
@@ -2890,6 +3202,16 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@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-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)':
|
||||
dependencies:
|
||||
'@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-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)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
@@ -2979,6 +3320,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
@@ -3030,6 +3378,26 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -3085,6 +3453,15 @@ snapshots:
|
||||
'@types/react': 18.2.48
|
||||
'@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': {}
|
||||
|
||||
'@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:
|
||||
'@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):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -4536,6 +4921,17 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -4545,6 +4941,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.8
|
||||
@@ -4878,6 +5282,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
@@ -4888,6 +5299,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.0.3: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
which-boxed-primitive@1.0.2:
|
||||
|
||||
Reference in New Issue
Block a user