committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2159 additions and 24 deletions
-
1api/package.json
-
35api/pnpm-lock.yaml
-
4api/src/app.module.ts
-
2api/src/gateway/gateway.module.ts
-
12api/src/gateway/gateway.service.ts
-
41api/src/webhook/schemas/webhook-notification.schema.ts
-
43api/src/webhook/schemas/webhook-subscription.schema.ts
-
3api/src/webhook/webhook-event.enum.ts
-
68api/src/webhook/webhook.controller.ts
-
14api/src/webhook/webhook.dto.ts
-
35api/src/webhook/webhook.module.ts
-
270api/src/webhook/webhook.service.ts
-
25web/app/(app)/dashboard/(components)/main-dashboard.tsx
-
198web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx
-
50web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx
-
201web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx
-
139web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx
-
169web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx
-
178web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx
-
49web/components/shared/copy-button.tsx
-
141web/components/ui/alert-dialog.tsx
-
17web/components/ui/code.tsx
-
15web/components/ui/skeleton.tsx
-
32web/components/ui/tooltip.tsx
-
3web/config/api.ts
-
3web/lib/constants.ts
-
17web/lib/types.ts
-
3web/package.json
-
413web/pnpm-lock.yaml
@ -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) |
|||
@ -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 }) |
|||
@ -0,0 +1,3 @@ |
|||
export enum WebhookEvent { |
|||
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', |
|||
} |
|||
@ -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 } |
|||
} |
|||
} |
|||
@ -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[] |
|||
} |
|||
@ -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 {} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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, |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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 } |
|||
@ -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 } |
|||
@ -0,0 +1,3 @@ |
|||
export const WEBHOOK_EVENTS = { |
|||
MESSAGE_RECEIVED: 'MESSAGE_RECEIVED', |
|||
} as const |
|||
@ -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 |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue