diff --git a/.vscode/settings.json b/.vscode/settings.json index f0c71441..7ff5277c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ "editor.codeActionsOnSave": { "quickfix.biome": "explicit", "source.organizeImports.biome": "explicit" + }, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" } } \ No newline at end of file diff --git a/docs/docs/Authentication/_category_.json b/docs/docs/Authentication/_category_.json new file mode 100644 index 00000000..94fd5d1d --- /dev/null +++ b/docs/docs/Authentication/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Authentication", + "position": 2, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Authentication/oauth.md b/docs/docs/Authentication/oauth.md index 8a00300f..e54bdd27 100644 --- a/docs/docs/Authentication/oauth.md +++ b/docs/docs/Authentication/oauth.md @@ -3,7 +3,7 @@ id: oauth title: Oauth slug: /authentication/oauth/ description: OAuth Configuration Guide -sidebar_position: 6 +sidebar_position: 2 --- # OAuth Configuration Guide diff --git a/docs/docs/Basics/_category_.json b/docs/docs/Basics/_category_.json new file mode 100644 index 00000000..855bdb83 --- /dev/null +++ b/docs/docs/Basics/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Basics", + "position": 7, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Contribute/_category_.json b/docs/docs/Contribute/_category_.json new file mode 100644 index 00000000..7cadb19b --- /dev/null +++ b/docs/docs/Contribute/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Contribute", + "position": 8, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Licensing Notice/_category_.json b/docs/docs/Licensing Notice/_category_.json new file mode 100644 index 00000000..a3feff0c --- /dev/null +++ b/docs/docs/Licensing Notice/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Licensing Notes", + "position": 9, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Rest Api/_category_.json b/docs/docs/Rest Api/_category_.json new file mode 100644 index 00000000..da09ca1c --- /dev/null +++ b/docs/docs/Rest Api/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Rest API", + "position": 4, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Showcase/_category_.json b/docs/docs/Showcase/_category_.json new file mode 100644 index 00000000..dfc20e1d --- /dev/null +++ b/docs/docs/Showcase/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Showcase", + "position": 6, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Usage/_category_.json b/docs/docs/Usage/_category_.json new file mode 100644 index 00000000..fb83a98c --- /dev/null +++ b/docs/docs/Usage/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Configuration & Tools", + "position": 5, + "link": { + "type": "generated-index" + } +} \ No newline at end of file diff --git a/docs/docs/Usage/webhooks.md b/docs/docs/Usage/webhooks.md new file mode 100644 index 00000000..2a849c0f --- /dev/null +++ b/docs/docs/Usage/webhooks.md @@ -0,0 +1,105 @@ +--- +id: create_webhooks +title: Webhooks +slug: /usage/webhooks/ +description: Webhooks are a way for apps to provide other applications with real-time information. A webhook delivers data to other applications as it happens, meaning you get data immediately. +sidebar_position: 1 +--- + +# Configuring Webhooks in ZTNET + +Webhooks in ZTNET empower your organization with real-time event notifications. To set up a webhook, you'll need to specify: + +- **Webhook Name**: Assign a unique and descriptive name to each webhook. +- **Webhook Actions**: Choose the [events](#network-events) that should trigger notifications. Multiple selections are allowed. +- **Endpoint URL (HTTPS)**: This is the receiver URL where ZTNET will send event data. It must be a publicly accessible HTTPS URL to ensure security. + + +## Data Structure + +Each webhook event in ZTNET includes a JSON payload sent to the configured endpoint URL. Here are examples of the JSON content for several webhook types: + +## Member Configuration Changed (`MEMBER_CONFIG_CHANGED`) + +When a member's configuration changes, the webhook will contain the following data: + +```json +{ + // HookType is the type of hook being fired. + "hookType": "MEMBER_CONFIG_CHANGED", + // organizationId is the internal ID the hook belongs to + "organizationId": "org_123456", + // NetworkID is the network the member belongs to + "networkId": "network_12345", + // MemberID is the network member that was changed + "memberId": "mem_112233", + // UserID is the ID of the user that modified the network member + "userId": "user_445566", + // UserEmail is the email address of the user that modified the network member + "userEmail": "user@example.com", + // Changes is a map of the changes that were made to the network member + "changes": { + "authorized": true, + } +} +``` + +### Network Created (NETWORK_CREATED) +After a new network is created, the webhook payload will contain the following data: +```json +{ + "hookType": "NETWORK_CREATED", + "organizationId": "org_123456", + "networkId": "net_78910", + "userId": "user_445566", + "userEmail": "user@example.com" +} +``` + +## Webhook Events + +Webhooks in ZTNET allow you to set up automated notifications for specific events within your networks and organization. Below you'll find the available webhook events and their descriptions: + +## Network Events + +- **Network Join (`NETWORK_JOIN`)** + Fired when a new member requests to join a network. This event is triggered once when the network controller receives the join request from the member. + +- **Network Created (`NETWORK_CREATED`)** + Fired when a network is created within the organization. + +- **Network Configuration Changed (`NETWORK_CONFIG_CHANGED`)** + Fired when there is a change in the organization network's configuration settings. + +- **Network Deleted (`NETWORK_DELETED`)** + Fired when a network is permanently deleted from the organization. + +## Member Events + +- **Member Configuration Changed (`MEMBER_CONFIG_CHANGED`)** + Triggered when a member's configuration in a organization network is altered. + +- **Member Deleted (`MEMBER_DELETED`)** + Fired when a member is removed from a organization network. + +## Organization Events + +- **Organization Member Removed (`ORG_MEMBER_REMOVED`)** + Fired when a member is removed from the organization, whether by an administrator or by the member themselves. + + +## Example of Webhook Receiver + +To experiment with webhooks or for development purposes, you can use services like [Zapier](https://zapier.com/) to quickly establish a webhook receiver. For instance, to receive an email for each new member request: + +1. Set up a "Webhooks by Zapier" trigger, and choose the "Catch Raw Hook" event to capture the raw POST data from ZTNET. +![zapier triggers](../../images/webhooks/zapier_hook.jpg) + +2. Add an "Send Outbound Email" action in Zapier, fill in your email address, and attach the raw POST data as the email content. +![zapier actions](../../images/webhooks/zapier_actions.jpg) + +3. Copy the webhook URL generated by Zapier. +4. Go to your ZTNET organization's settings, create a new webhook receiver using the copied URL in the `Endpoint URL` field, and select the "NETWORK_JOIN" event type. +5. Enable your webhook in ZTNET and activate your Zapier workflow. + +Now, whenever a new member tries to join your network in ZTNET, you'll receive an email notification through Zapier. diff --git a/docs/images/webhooks/zapier_actions.jpg b/docs/images/webhooks/zapier_actions.jpg new file mode 100644 index 00000000..eb673c4f Binary files /dev/null and b/docs/images/webhooks/zapier_actions.jpg differ diff --git a/docs/images/webhooks/zapier_hook.jpg b/docs/images/webhooks/zapier_hook.jpg new file mode 100644 index 00000000..269d8e47 Binary files /dev/null and b/docs/images/webhooks/zapier_hook.jpg differ diff --git a/prisma/migrations/20231229104733_webhooks/migration.sql b/prisma/migrations/20231229104733_webhooks/migration.sql new file mode 100644 index 00000000..288700fb --- /dev/null +++ b/prisma/migrations/20231229104733_webhooks/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "url" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "eventTypes" JSONB NOT NULL, + "secret" TEXT DEFAULT '', + "lastDelivery" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "organizationId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b76850e5..19b38d0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,13 +2,13 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "debian-openssl-1.1.x", "linux-arm64-openssl-1.1.x"] + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-1.1.x", "linux-arm64-openssl-1.1.x"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") shadowDatabaseUrl = env("MIGRATE_DATABASE_URL") } @@ -20,105 +20,103 @@ enum Role { } model GlobalOptions { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // Registration - enableRegistration Boolean @default(true) - firstUserRegistration Boolean @default(true) // not in use, will be removed at a later stage + enableRegistration Boolean @default(true) + firstUserRegistration Boolean @default(true) // not in use, will be removed at a later stage // Email configuration - smtpHost String? - smtpPort String @default("587") - smtpEmail String? - smtpUsername String? - smtpPassword String? - smtpUseSSL Boolean @default(false) - smtpSecure Boolean @default(false) - smtpRequireTLS Boolean @default(false) - smtpIgnoreTLS Boolean @default(false) - inviteUserTemplate Json? - inviteAdminTemplate Json? - inviteOrganizationTemplate Json? - forgotPasswordTemplate Json? - verifyEmailTemplate Json? - notificationTemplate Json? - + smtpHost String? + smtpPort String @default("587") + smtpEmail String? + smtpUsername String? + smtpPassword String? + smtpUseSSL Boolean @default(false) + smtpSecure Boolean @default(false) + smtpRequireTLS Boolean @default(false) + smtpIgnoreTLS Boolean @default(false) + inviteUserTemplate Json? + inviteAdminTemplate Json? + inviteOrganizationTemplate Json? + forgotPasswordTemplate Json? + verifyEmailTemplate Json? + notificationTemplate Json? // Notifications - userRegistrationNotification Boolean @default(false) - + userRegistrationNotification Boolean @default(false) // mkworld - customPlanetUsed Boolean @default(false) - plID BigInt @default(0) - plBirth BigInt @default(0) - plRecommend Boolean @default(false) - plComment String? - plIdentity String? - plEndpoints String? - - welcomeMessageEnabled Boolean @default(false) - welcomeMessageTitle String? - welcomeMessageBody String? + customPlanetUsed Boolean @default(false) + plID BigInt @default(0) + plBirth BigInt @default(0) + plRecommend Boolean @default(false) + plComment String? + plIdentity String? + plEndpoints String? + // Welcome message + welcomeMessageEnabled Boolean @default(false) + welcomeMessageTitle String? + welcomeMessageBody String? } - model network_members { - nodeid Int @id @default(autoincrement()) - id String - nwid_ref network @relation(fields: [nwid], references: [nwid], onDelete: Cascade) - nwid String - lastSeen DateTime? - online Boolean? @default(false) - deleted Boolean? @default(false) - name String? - address String? @default("") - creationTime DateTime - notations NetworkMemberNotation[] + nodeid Int @id @default(autoincrement()) + id String + nwid_ref network @relation(fields: [nwid], references: [nwid], onDelete: Cascade) + nwid String + lastSeen DateTime? + online Boolean? @default(false) + deleted Boolean? @default(false) + name String? + address String? @default("") + creationTime DateTime + notations NetworkMemberNotation[] @@unique([id, nwid]) } + model network { - nwid String @id - name String? - description String? - creationTime DateTime? - lastModifiedTime DateTime? - flowRule String? - autoAssignIp Boolean? @default(true) - nw_userid User? @relation(fields: [authorId], references: [id], onDelete: Cascade) - authorId String? - tagsByName Json? - capabilitiesByName Json? - - organizationId String? - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) - - networkMembers network_members[] - notations Notation[] + nwid String @id + name String? + description String? + creationTime DateTime? + lastModifiedTime DateTime? + flowRule String? + autoAssignIp Boolean? @default(true) + nw_userid User? @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String? + tagsByName Json? + capabilitiesByName Json? + // Relationships + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + networkMembers network_members[] + notations Notation[] } model Notation { - id Int @id @default(autoincrement()) - name String - color String? - description String? - creationTime DateTime @default(now()) - updatedTime DateTime @updatedAt - isActive Boolean @default(true) - nwid String - network network @relation(fields: [nwid], references: [nwid], onDelete: Cascade) - networkMembers NetworkMemberNotation[] - icon String? - orderIndex Int? - visibility String? + id Int @id @default(autoincrement()) + name String + color String? + description String? + creationTime DateTime @default(now()) + updatedTime DateTime @updatedAt + isActive Boolean @default(true) + nwid String + network network @relation(fields: [nwid], references: [nwid], onDelete: Cascade) + networkMembers NetworkMemberNotation[] + icon String? + orderIndex Int? + visibility String? @@unique([name, nwid]) } model NetworkMemberNotation { - notationId Int - nodeid Int - label Notation @relation(fields: [notationId], references: [id]) - member network_members @relation(fields: [nodeid], references: [nodeid], onDelete: Cascade) + notationId Int + nodeid Int + label Notation @relation(fields: [notationId], references: [id]) + member network_members @relation(fields: [nodeid], references: [nodeid], onDelete: Cascade) + @@id([notationId, nodeid]) } @@ -129,13 +127,13 @@ model Account { type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? @db.Text + access_token String? @db.Text expires_at Int? refresh_expires_in Int? token_type String? scope String? - id_token String? @db.Text + id_token String? @db.Text session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -143,7 +141,6 @@ model Account { @@unique([provider, providerAccountId]) } - model Session { id String @id @default(cuid()) sessionToken String @unique @@ -151,26 +148,23 @@ model Session { expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } -model UserOptions { - id Int @id @default(autoincrement()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +model UserOptions { + id Int @id @default(autoincrement()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) //networks - useNotationColorAsBg Boolean? @default(false) - showNotationMarkerInTableRow Boolean? @default(true) - + useNotationColorAsBg Boolean? @default(false) + showNotationMarkerInTableRow Boolean? @default(true) //zt central - ztCentralApiKey String? @default("") - ztCentralApiUrl String? @default("https://api.zerotier.com/api/v1") - + ztCentralApiKey String? @default("") + ztCentralApiUrl String? @default("https://api.zerotier.com/api/v1") // local controller - localControllerUrl String? @default("http://zerotier:9993") - localControllerSecret String? @default("") - + localControllerUrl String? @default("http://zerotier:9993") + localControllerSecret String? @default("") // member table - deAuthorizeWarning Boolean? @default(false) - addMemberIdAsName Boolean? @default(false) + deAuthorizeWarning Boolean? @default(false) + addMemberIdAsName Boolean? @default(false) } enum AccessLevel { @@ -180,66 +174,63 @@ enum AccessLevel { } model UserGroup { - id Int @id @default(autoincrement()) - name String @unique - description String? - maxNetworks Int @default(5) - accessLevel AccessLevel @default(WRITE) - isDefault Boolean @default(false) - users User[] + id Int @id @default(autoincrement()) + name String @unique + description String? + maxNetworks Int @default(5) + accessLevel AccessLevel @default(WRITE) + isDefault Boolean @default(false) + users User[] } model APIToken { - id Int @id @default(autoincrement()) - name String - token String @unique - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - expiresAt DateTime? // null means it never expires - isActive Boolean @default(true) + id Int @id @default(autoincrement()) + name String + token String @unique + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + expiresAt DateTime? // null means it never expires + isActive Boolean @default(true) } model User { - id String @id @default(cuid()) - name String - email String @unique - emailVerified DateTime? - lastLogin DateTime - lastseen DateTime? - expirationDate String @default("") - online Boolean? @default(false) - role Role @default(USER) - image String? - hash String? - licenseStatus String? - orderStatus String? - orderId Int @default(0) - product_id Int? @default(0) - licenseKey String? @default("") - tempPassword String? - firstTime Boolean @default(true) - userGroupId Int? - - memberOfOrgs Organization[] @relation("MemberRelation") // user can be member of multiple organizations - organizationRoles UserOrganizationRole[] - membershipRequests MembershipRequest[] @relation("MembershipRequestsForUser") - messages Messages[] - lastReadByUsers LastReadMessage[] - ActivityLog ActivityLog[] - expiresAt DateTime? // null means it never expires - isActive Boolean @default(true) - - userGroup UserGroup? @relation(fields: [userGroupId], references: [id], onDelete: Restrict) - options UserOptions? - accounts Account[] - sessions Session[] - network network[] - apiTokens APIToken[] + id String @id @default(cuid()) + name String + email String @unique + emailVerified DateTime? + lastLogin DateTime + lastseen DateTime? + expirationDate String @default("") + online Boolean? @default(false) + role Role @default(USER) + image String? + hash String? + licenseStatus String? + orderStatus String? + orderId Int @default(0) + product_id Int? @default(0) + licenseKey String? @default("") + tempPassword String? + firstTime Boolean @default(true) + userGroupId Int? + memberOfOrgs Organization[] @relation("MemberRelation") // user can be member of multiple organizations + organizationRoles UserOrganizationRole[] + membershipRequests MembershipRequest[] @relation("MembershipRequestsForUser") + messages Messages[] + lastReadByUsers LastReadMessage[] + ActivityLog ActivityLog[] + expiresAt DateTime? // null means it never expires + isActive Boolean @default(true) + userGroup UserGroup? @relation(fields: [userGroupId], references: [id], onDelete: Restrict) + options UserOptions? + accounts Account[] + sessions Session[] + network network[] + apiTokens APIToken[] + webhooks Webhook[] } - - model VerificationToken { identifier String token String @unique @@ -249,110 +240,127 @@ model VerificationToken { } model UserInvitation { - id Int @id @default(autoincrement()) - token String @unique - used Boolean @default(false) - email String? - secret String - url String - expires DateTime - timesCanUse Int @default(1) - timesUsed Int @default(0) - createdBy String - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + token String @unique + used Boolean @default(false) + email String? + secret String + url String + expires DateTime + timesCanUse Int @default(1) + timesUsed Int @default(0) + createdBy String + createdAt DateTime @default(now()) } - // // ORGANIZATION // model Organization { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - ownerId String - orgName String - description String? - users User[] @relation("MemberRelation") - networks network[] - settings OrganizationSettings? - invitations OrganizationInvitation[] - membershipRequests MembershipRequest[] @relation("MembershipRequestsForOrganization") - isActive Boolean @default(true) - userRoles UserOrganizationRole[] - messages Messages[] - lastReadByUsers LastReadMessage[] - ActivityLog ActivityLog[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) + ownerId String + orgName String + description String? + users User[] @relation("MemberRelation") + networks network[] + settings OrganizationSettings? + invitations OrganizationInvitation[] + membershipRequests MembershipRequest[] @relation("MembershipRequestsForOrganization") + isActive Boolean @default(true) + userRoles UserOrganizationRole[] + messages Messages[] + lastReadByUsers LastReadMessage[] + ActivityLog ActivityLog[] + webhooks Webhook[] +} + +model Webhook { + id String @id @default(cuid()) + name String + description String + url String + enabled Boolean @default(false) + eventTypes Json + secret String? @default("") + lastDelivery DateTime? @default(now()) + // Relationship with Organization + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? // Foreign key + createdAt DateTime @default(now()) + // Relationship with User + User User? @relation(fields: [userId], references: [id]) + userId String? } model UserOrganizationRole { userId String organizationId String role Role - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@id([userId, organizationId]) } model Messages { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) content String - createdAt DateTime @default(now()) - userId String // Reference to the User model - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - organizationId String // Reference to the Organization model - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + userId String // Reference to the User model + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId String // Reference to the Organization model + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) lastReadByUsers LastReadMessage[] } model LastReadMessage { - id Int @id @default(autoincrement()) - lastMessageId Int // ID of the last read message - userId String - organizationId String + id Int @id @default(autoincrement()) + lastMessageId Int // ID of the last read message + userId String + organizationId String - lastMessage Messages @relation(fields: [lastMessageId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + lastMessage Messages @relation(fields: [lastMessageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@unique([userId, organizationId]) } model OrganizationSettings { - id Int @id @default(autoincrement()) - organizationId String @unique - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) // Add specific settings fields here } model OrganizationInvitation { - id Int @id @default(autoincrement()) - token String @unique - email String - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + token String @unique + email String + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) } model MembershipRequest { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) userId String organizationId String - user User @relation(fields: [userId], references: [id], name: "MembershipRequestsForUser", onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], name: "MembershipRequestsForOrganization", onDelete: Cascade) + user User @relation(fields: [userId], references: [id], name: "MembershipRequestsForUser", onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], name: "MembershipRequestsForOrganization", onDelete: Cascade) } model ActivityLog { - id Int @id @default(autoincrement()) - action String - createdAt DateTime @default(now()) - performedById String - performedBy User @relation(fields: [performedById], references: [id], onDelete: Cascade) - organizationId String? // Make this optional - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + action String + createdAt DateTime @default(now()) + performedById String + performedBy User @relation(fields: [performedById], references: [id], onDelete: Cascade) + organizationId String? // Make this optional + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) } - // To map your data model to the database schema, you need to use the prisma migrate CLI commands: // npx prisma migrate dev --name (NAME) @@ -369,4 +377,3 @@ model ActivityLog { // generate local draft // npx prisma migrate dev --create-only --preview-feature - diff --git a/src/components/adminPage/organization/organizationInviteModal.tsx b/src/components/adminPage/organization/organizationInviteModal.tsx index 36635428..2b6b6f8d 100644 --- a/src/components/adminPage/organization/organizationInviteModal.tsx +++ b/src/components/adminPage/organization/organizationInviteModal.tsx @@ -98,7 +98,7 @@ const OrganizationInviteModal = ({ organizationId }: Iprops) => { }, ) } - className="btn btn-sm" + className="btn btn-sm btn-primary" > {b("inviteUser")} diff --git a/src/components/elements/multiSelect.tsx b/src/components/elements/multiSelect.tsx new file mode 100644 index 00000000..52cfdbf6 --- /dev/null +++ b/src/components/elements/multiSelect.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; + +export default function MultiSelectDropdown({ + formFieldName, + value = [], + options, + onChange, + prompt = "Select one or more options", +}) { + const [isJsEnabled, setIsJsEnabled] = useState(false); + const [selectedOptions, setSelectedOptions] = useState([]); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const optionsListRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + setIsJsEnabled(true); + }, []); + + useEffect(() => { + setSelectedOptions(value); + }, [value]); + + // Event listener to close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const handleChange = (e) => { + const isChecked = e.target.checked; + const option = e.target.value; + + const selectedOptionSet = new Set(selectedOptions); + + if (isChecked) { + selectedOptionSet.add(option); + } else { + selectedOptionSet.delete(option); + } + + const newSelectedOptions = Array.from(selectedOptionSet); + + setSelectedOptions(newSelectedOptions); + onChange(newSelectedOptions); + }; + + const isSelectAllEnabled = selectedOptions.length < options.length; + + const handleSelectAllClick = (e) => { + e.preventDefault(); + + const optionsInputs = optionsListRef.current.querySelectorAll("input"); + // biome-ignore lint/complexity/noForEach: + optionsInputs.forEach((input) => { + input.checked = true; + }); + + setSelectedOptions([...options]); + onChange([...options]); + }; + + const isClearSelectionEnabled = selectedOptions.length > 0; + + const handleClearSelectionClick = (e) => { + e.preventDefault(); + + const optionsInputs = optionsListRef.current.querySelectorAll("input"); + // biome-ignore lint/complexity/noForEach: + optionsInputs.forEach((input) => { + input.checked = false; + }); + + setSelectedOptions([]); + onChange([]); + }; + const toggleDropdown = () => setIsDropdownOpen(!isDropdownOpen); + return ( + + ); +} diff --git a/src/components/layouts/sidebar.tsx b/src/components/layouts/sidebar.tsx index 7e65b5bb..111e0786 100644 --- a/src/components/layouts/sidebar.tsx +++ b/src/components/layouts/sidebar.tsx @@ -457,7 +457,7 @@ const Sidebar = (): JSX.Element => { void signOut({ callbackUrl: "/" })} - className="flex h-10 flex-row items-center rounded-lg px-3 text-gray-300 hover:bg-gray-100 hover:text-gray-700" + className="flex h-10 flex-row items-center rounded-lg px-3 hover:bg-gray-100 hover:text-gray-700" > { setOpenOrgId(orgId); } }; + return (
{userOrgs?.map((org) => (
-

- {t("organization.listOrganization.organizationName")}{" "} - {org.orgName} -

-

- {t("organization.listOrganization.description")}{" "} - {org.description} -

-

- {t("organization.listOrganization.numberOfMembers")}{" "} - {org?.users?.length} -

-

- {org?.invitations?.length > 0 ? ( -

- {t("organization.listOrganization.pendingInvitations")} +
+

+ {t("organization.listOrganization.organizationName")}{" "} + {org.orgName} +

+

+ {t("organization.listOrganization.description")}{" "} + {org.description} +

+

+ {t("organization.listOrganization.numberOfMembers")}{" "} + {org?.users?.length} +

+

+ {org?.invitations?.length > 0 ? ( +

+ {t("organization.listOrganization.pendingInvitations")} +
+ {org?.invitations?.map((invite) => ( + + ))} +
+
+ ) : null} +

+
+
+ {org?.webhooks?.length > 0 ? ( +
+ {t("organization.listOrganization.activeWebhooks")}
- {org?.invitations?.map((invite) => ( + {org?.webhooks?.map((hook) => ( ))}
) : null} -

-
-
+
+
+
+
+ + + + +
+
+
- -
-
{openOrgId === org.id ? ( diff --git a/src/components/networkByIdPage/table/deletedNetworkMembersTable.tsx b/src/components/networkByIdPage/table/deletedNetworkMembersTable.tsx index 52bde8c9..d1cdd638 100644 --- a/src/components/networkByIdPage/table/deletedNetworkMembersTable.tsx +++ b/src/components/networkByIdPage/table/deletedNetworkMembersTable.tsx @@ -15,7 +15,12 @@ import { } from "@tanstack/react-table"; import { type MemberEntity } from "~/types/local/member"; -export const DeletedNetworkMembersTable = ({ nwid }) => { +interface IProps { + nwid: string; + organizationId?: string; +} + +export const DeletedNetworkMembersTable = ({ nwid, organizationId }: IProps) => { const { query } = useRouter(); const [sorting, setSorting] = useState([ { @@ -103,6 +108,7 @@ export const DeletedNetworkMembersTable = ({ nwid }) => { yesAction: () => { void deleteMember( { + organizationId, nwid, id, }, diff --git a/src/components/organization/editOrgModal.tsx b/src/components/organization/editOrgModal.tsx index cc34f667..35b2f6b4 100644 --- a/src/components/organization/editOrgModal.tsx +++ b/src/components/organization/editOrgModal.tsx @@ -84,7 +84,7 @@ const EditOrganizationModal = ({ organizationId }: Iprops) => { ); }} type="submit" - className="btn btn-sm" + className="btn btn-sm btn-primary" > {b("submit")} diff --git a/src/components/organization/webhookModal.tsx b/src/components/organization/webhookModal.tsx new file mode 100644 index 00000000..b15ab3aa --- /dev/null +++ b/src/components/organization/webhookModal.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from "react"; +import { api } from "~/utils/api"; +import toast from "react-hot-toast"; +import { useModalStore } from "~/utils/store"; +import { useTranslations } from "next-intl"; +import Input from "../elements/input"; +import MultiSelectDropdown from "../elements/multiSelect"; +import { Webhook } from "@prisma/client"; +import { HookType } from "~/types/webhooks"; +import { handleErrors } from "~/utils/errors"; + +interface Iprops { + organizationId: string; + hook?: Webhook; +} + +const OrganizationWebhook = ({ organizationId, hook }: Iprops) => { + const b = useTranslations("commonButtons"); + const t = useTranslations("admin"); + const [input, setInput] = useState({ + webhookId: "", + webhookUrl: "", + webhookDescription: "", + webhookName: "", + hookType: [], + }); + + const { closeModal } = useModalStore((state) => state); + const { refetch: refecthAllOrg } = api.org.getAllOrg.useQuery(); + + const { mutate: addWebhook } = api.org.addOrgWebhooks.useMutation(); + const { mutate: deleteWebhook } = api.org.deleteOrgWebhooks.useMutation(); + + useEffect(() => { + if (!hook) return; + setInput({ + webhookId: hook.id, + webhookUrl: hook.url, + webhookName: hook.name, + webhookDescription: "", + hookType: hook.eventTypes as string[], + }); + }, [hook]); + + const inputHandler = (e: React.ChangeEvent) => { + setInput({ + ...input, + [e.target.name]: e.target.value, + }); + }; + + const selectHandler = (e: string[]) => { + setInput({ + ...input, + hookType: e, + }); + }; + + const submitHandler = async (e: React.MouseEvent) => { + e.preventDefault(); + try { + addWebhook( + { + organizationId: organizationId, + webhookUrl: input.webhookUrl, + webhookName: input.webhookName, + hookType: input.hookType, + webhookId: input.webhookId, + }, + { + onSuccess: () => { + toast.success(`Webhook ${hook ? "updated" : "added"} successfully`); + closeModal(); + refecthAllOrg(); + }, + onError: (error) => { + handleErrors(error); + }, + }, + ); + + refecthAllOrg(); + } catch (_err) { + toast.error("Error adding webhook"); + } + }; + const deleteHandler = async (e: React.MouseEvent) => { + e.preventDefault(); + try { + deleteWebhook( + { + organizationId: organizationId, + webhookId: input.webhookId, + }, + { + onSuccess: () => { + closeModal(); + refecthAllOrg(); + }, + onError: (error) => { + handleErrors(error); + }, + }, + ); + + refecthAllOrg(); + } catch (_err) { + toast.error("Error deleting webhook"); + } + }; + return ( +
+
+

+ {t("organization.listOrganization.webhookModal.webhookName")} +

+ + +
+
+

+ {t("organization.listOrganization.webhookModal.selectWebhookActions")} +

+ +
+ +
+
+
+

+ {t("organization.listOrganization.webhookModal.webhookUrl")} +

+ + +
+ +
+ + {hook ? ( + + ) : null} +
+
+ ); +}; + +export default OrganizationWebhook; diff --git a/src/locales/en/common.json b/src/locales/en/common.json index f29e47a0..d722b835 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -9,6 +9,7 @@ "submit": "Submit", "cancel": "Cancel", "delete": "Delete", + "update": "Update", "close": "Close", "stash": "Stash", "yes": "Yes", @@ -25,6 +26,7 @@ "kickUser": "Kick User", "deleteOrganization": "Delete Organization", "userActions": "User Actions", + "addWebhooks": "Add Webhooks", "options": "Options" }, "commonTable": { @@ -546,6 +548,7 @@ "description": "Description:", "numberOfMembers": "Number of Members:", "pendingInvitations": "Pending Invitations:", + "activeWebhooks": "Active Webhooks:", "invitationModal": { "title": "Organization Invites", "description": "Before you can add users to your organization, it's important to note that they must already be registered in the application", @@ -558,6 +561,16 @@ "title": "Select the role of the user" } } + }, + "webhookModal": { + "editWebhookTitle": "Edit Webhook", + "createWebhookTitle": "Create Webhook for Organization {organization}", + "webhookName": "Webhook Name", + "webhookNameDescription": "This field is for entering the name of the webhook. The name is used to identify the webhook and should be descriptive enough to distinguish it from other webhooks.", + "selectWebhookActions": "Select webhook actions", + "selectWebhookActionsDescription": "This dropdown menu allows you to choose the specific actions your webhook should perform. Each option represents a different type of action.", + "webhookUrl": "Webhook URL ( HTTPS )", + "webhookUrlDescription": "This field is for entering the URL where the webhook will send data. It must be a valid and accessible URL endpoint that can receive and process incoming webhook data." } } } diff --git a/src/locales/es/common.json b/src/locales/es/common.json index aaa8dac4..d72690ac 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -9,6 +9,7 @@ "submit": "Enviar", "cancel": "Cancelar", "delete": "Eliminar", + "update": "Actualizar", "close": "Cerrar", "stash": "Guardar", "yes": "Sí", @@ -25,6 +26,7 @@ "kickUser": "Expulsar usuario", "deleteOrganization": "Eliminar organización", "userActions": "Acciones del usuario", + "addWebhooks": "Añadir Webhooks", "options": "Opciones" }, "commonTable": { @@ -546,6 +548,7 @@ "description": "Descripción:", "numberOfMembers": "Número de Miembros:", "pendingInvitations": "Invitaciones Pendientes:", + "activeWebhooks": "Webhooks Activos:", "invitationModal": { "title": "Invitaciones de la Organización", "description": "Antes de que puedas agregar usuarios a tu organización, es importante tener en cuenta que deben estar ya registrados en la aplicación", @@ -558,6 +561,15 @@ "title": "Selecciona el rol del usuario" } } + }, + "webhookModal": { + "webhookName": "Webhook Name", + "createWebhookTitle": "Crear Webhook para la Organización {organization}", + "webhookNameDescription": "This field is for entering the name of the webhook. The name is used to identify the webhook and should be descriptive enough to distinguish it from other webhooks.", + "selectWebhookActions": "Select webhook actions", + "selectWebhookActionsDescription": "This dropdown menu allows you to choose the specific actions your webhook should perform. Each option represents a different type of action.", + "webhookUrl": "Webhook URL ( HTTPS )", + "webhookUrlDescription": "Este campo es para ingresar la URL donde el webhook enviará los datos. Debe ser un punto final de URL válido y accesible que pueda recibir y procesar los datos del webhook entrante." } } } diff --git a/src/locales/no/common.json b/src/locales/no/common.json index 021ea963..c7be842d 100644 --- a/src/locales/no/common.json +++ b/src/locales/no/common.json @@ -9,6 +9,7 @@ "submit": "Send inn", "cancel": "Avbryt", "delete": "Slett", + "update": "Oppdater", "close": "Lukk", "stash": "Stash", "yes": "Ja", @@ -25,6 +26,7 @@ "kickUser": "Spark Bruker", "deleteOrganization": "Slett Organisasjon", "userActions": "Brukerhandlinger", + "addWebhooks": "Legg til Webhooks", "options": "Alternativer" }, "commonTable": { @@ -546,6 +548,7 @@ "description": "Beskrivelse:", "numberOfMembers": "Antall Medlemmer:", "pendingInvitations": "Ventende Invitasjoner:", + "activeWebhooks": "Aktive Webhooks:", "invitationModal": { "title": "Organisasjonsinvitasjoner", "description": "Før du kan legge til brukere i organisasjonen din, er det viktig å merke seg at de allerede må være registrert i applikasjonen", @@ -558,6 +561,15 @@ "title": "Velg brukerens rolle" } } + }, + "webhookModal": { + "webhookName": "Webhook-navn", + "createWebhookTitle": "Opprett Webhook for Organisasjonen {organization}", + "webhookNameDescription": "Dette feltet er for å angi navnet på webhook. Navnet brukes til å identifisere webhook og bør være beskrivende nok til å skille den fra andre webhooks.", + "selectWebhookActions": "Velg webhook-handlinger", + "selectWebhookActionsDescription": "Denne rullegardinmenyen lar deg velge de spesifikke handlingene din webhook skal utføre. Hver valgmulighet representerer en forskjellig type handling.", + "webhookUrl": "Webhook URL ( HTTPS )", + "webhookUrlDescription": "Dette feltet er for å angi URL-en hvor webhook vil sende data. Det må være et gyldig og tilgjengelig URL-endepunkt som kan motta og behandle innkommende webhook-data." } } } diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index c0338807..b69d717b 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -9,6 +9,7 @@ "submit": "提交", "cancel": "取消", "delete": "删除", + "update": "更新", "close": "关闭", "stash": "隐藏", "yes": "是", @@ -25,6 +26,7 @@ "kickUser": "踢出用户", "deleteOrganization": "删除组织", "userActions": "用户操作", + "addWebhooks": "添加 Webhooks", "options": "选项" }, "commonTable": { @@ -546,6 +548,7 @@ "description": "描述:", "numberOfMembers": "成员数量:", "pendingInvitations": "待处理的邀请:", + "activeWebhooks": "活动的 Webhooks:", "invitationModal": { "title": "组织邀请", "description": "在将用户添加到你的组织之前,请注意他们必须已经在应用中注册", @@ -558,6 +561,15 @@ "title": "选择用户角色" } } + }, + "webhookModal": { + "webhookName": "Webhook 名称", + "createWebhookTitle": "为组织创建 Webhook {organization}", + "webhookNameDescription": "此字段用于输入 webhook 的名称。名称用于识别 webhook,并应足够具有描述性以将其与其他 webhook 区分开。", + "selectWebhookActions": "选择 webhook 操作", + "selectWebhookActionsDescription": "此下拉菜单允许您选择 webhook 应执行的特定操作。每个选项代表一种不同类型的操作。", + "webhookUrl": "Webhook URL ( HTTPS )", + "webhookUrlDescription": "此字段用于输入 webhook 将发送数据的 URL。它必须是一个有效且可访问的 URL 端点,能够接收和处理传入的 webhook 数据。" } } } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cb622ae3..92ed75db 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -76,11 +76,12 @@ const App: AppType<{ session: Session | null }> = ({ diff --git a/src/pages/organization/[orgid]/[id].tsx b/src/pages/organization/[orgid]/[id].tsx index c2726e28..0cc90d80 100644 --- a/src/pages/organization/[orgid]/[id].tsx +++ b/src/pages/organization/[orgid]/[id].tsx @@ -285,7 +285,10 @@ const OrganizationNetworkById = ({ orgIds }: IProps) => { {state.viewZombieTable ? (
- +
) : null} diff --git a/src/server/api/networkService.ts b/src/server/api/networkService.ts index e1ffd776..ce1cc5c5 100644 --- a/src/server/api/networkService.ts +++ b/src/server/api/networkService.ts @@ -3,6 +3,9 @@ import { prisma } from "../db"; import { MemberEntity, Paths, Peers } from "~/types/local/member"; import { UserContext } from "~/types/ctx"; import { network_members } from "@prisma/client"; +import { sendWebhook } from "~/utils/webhook"; +import { HookType, MemberJoined } from "~/types/webhooks"; +import { throwError } from "../helpers/errorHandler"; // This function checks if the given IP address is likely a private IP address function isPrivateIP(ip: string): boolean { @@ -199,6 +202,28 @@ const psql_addMember = async (ctx, member: MemberEntity) => { name: user.options?.addMemberIdAsName ? member.id : null, }; + // check if the new member is joining a organization network + const org = await prisma.network.findFirst({ + where: { nwid: member.nwid }, + select: { organizationId: true }, + }); + + // send webhook if the new member is joining a organization network + if (org) { + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_JOIN, + organizationId: org.organizationId, + memberId: member.id, + networkId: member.nwid, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + } + return await prisma.network_members.create({ data: { ...memberData, diff --git a/src/server/api/routers/memberRouter.ts b/src/server/api/routers/memberRouter.ts index 5ceaeb6b..2df2787e 100644 --- a/src/server/api/routers/memberRouter.ts +++ b/src/server/api/routers/memberRouter.ts @@ -5,6 +5,14 @@ import { TRPCError } from "@trpc/server"; import { type MemberEntity } from "~/types/local/member"; import { checkUserOrganizationRole } from "~/utils/role"; import { Role } from "@prisma/client"; +import { + HookType, + MemberConfigChanged, + MemberDeleted, + MemberJoined, +} from "~/types/webhooks"; +import { sendWebhook } from "~/utils/webhook"; +import { throwError } from "~/server/helpers/errorHandler"; const isValidZeroTierNetworkId = (id: string) => { const hexRegex = /^[0-9a-fA-F]{10}$/; @@ -115,6 +123,19 @@ export const networkMemberRouter = createTRPCRouter({ }); } + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_JOIN, + organizationId: input?.organizationId, + memberId: input.id, + networkId: input.nwid, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // if not, create new member await ctx.prisma.network_members.create({ data: { @@ -228,6 +249,22 @@ export const networkMemberRouter = createTRPCRouter({ }); }); + try { + // Send webhook + await sendWebhook({ + hookType: HookType.MEMBER_CONFIG_CHANGED, + organizationId: input?.organizationId, + memberId: input.memberId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: payload, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + if (input.central) return updatedMember; }), Tags: protectedProcedure @@ -287,6 +324,21 @@ export const networkMemberRouter = createTRPCRouter({ }); }); + try { + // Send webhook + await sendWebhook({ + hookType: HookType.MEMBER_CONFIG_CHANGED, + organizationId: input?.organizationId, + memberId: input.memberId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: payload, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } return updatedMember; }), UpdateDatabaseOnly: protectedProcedure @@ -368,6 +420,22 @@ export const networkMemberRouter = createTRPCRouter({ }, }, }); + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.MEMBER_CONFIG_CHANGED, + organizationId: input?.organizationId, + memberId: input.id, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } return { member: response.networkMembers[0] }; }), stash: protectedProcedure @@ -409,7 +477,7 @@ export const networkMemberRouter = createTRPCRouter({ } // Set member with deleted status in database. - await ctx.prisma.network + const memberUpdate = await ctx.prisma.network .update({ where: { nwid: input.nwid, @@ -435,6 +503,24 @@ export const networkMemberRouter = createTRPCRouter({ }) // biome-ignore lint/suspicious/noConsoleLog: .catch((err: string) => console.log(err)); + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.MEMBER_CONFIG_CHANGED, + organizationId: input?.organizationId, + memberId: input.id, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: { stashed: true }, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + + return memberUpdate; }), delete: protectedProcedure .input( @@ -480,6 +566,21 @@ export const networkMemberRouter = createTRPCRouter({ }, }, }); + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.MEMBER_DELETED, + organizationId: input?.organizationId, + deletedMemberId: input.id, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } }), getMemberAnotations: protectedProcedure .input( diff --git a/src/server/api/routers/networkRouter.ts b/src/server/api/routers/networkRouter.ts index d15d86dc..fe2bc71e 100644 --- a/src/server/api/routers/networkRouter.ts +++ b/src/server/api/routers/networkRouter.ts @@ -21,6 +21,8 @@ import { type CentralNetwork } from "~/types/central/network"; import { createNetworkService } from "../services/networkService"; import { checkUserOrganizationRole } from "~/utils/role"; import { Role } from "@prisma/client"; +import { HookType, NetworkConfigChanged, NetworkDeleted } from "~/types/webhooks"; +import { sendWebhook } from "~/utils/webhook"; export const customConfig: Config = { dictionaries: [adjectives, animals], @@ -282,6 +284,22 @@ export const networkRouter = createTRPCRouter({ } throw error; } + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_DELETED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + + return true; }), ipv6: protectedProcedure @@ -330,6 +348,21 @@ export const networkRouter = createTRPCRouter({ } : { v6AssignMode: { ...network.v6AssignMode, ...input.v6AssignMode } }; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // update network return ztController.network_update({ ctx, @@ -376,6 +409,21 @@ export const networkRouter = createTRPCRouter({ ? { config: { v4AssignMode } } : { v4AssignMode }; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // update network return ztController.network_update({ ctx, @@ -419,6 +467,21 @@ export const networkRouter = createTRPCRouter({ // prepare update params const updateParams = input.central ? { config: { routes } } : { routes }; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // update network return ztController.network_update({ ctx, @@ -467,6 +530,20 @@ export const networkRouter = createTRPCRouter({ ? { config: { ipAssignmentPools, routes, v4AssignMode } } : { ipAssignmentPools, routes, v4AssignMode }; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } // update network return ztController.network_update({ ctx, @@ -519,6 +596,20 @@ export const networkRouter = createTRPCRouter({ ? { config: { ipAssignmentPools } } : { ipAssignmentPools }; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } // update network return ztController.network_update({ ctx, @@ -530,7 +621,7 @@ export const networkRouter = createTRPCRouter({ privatePublicNetwork: protectedProcedure .input( z.object({ - nwid: z.string().nonempty(), + nwid: z.string(), central: z.boolean().optional().default(false), organizationId: z.string().optional(), updateParams: z.object({ @@ -547,6 +638,7 @@ export const networkRouter = createTRPCRouter({ organizationId: input?.organizationId || null, // Use null if organizationId is not provided }, }); + // Check if the user has permission to update the network if (input.organizationId) { await checkUserOrganizationRole({ @@ -555,6 +647,7 @@ export const networkRouter = createTRPCRouter({ requiredRole: Role.USER, }); } + const updateParams = input.central ? { config: { private: input.updateParams.private } } : { private: input.updateParams.private }; @@ -571,6 +664,22 @@ export const networkRouter = createTRPCRouter({ const { id: nwid, config, ...otherProps } = updated as CentralNetwork; return { nwid, ...config, ...otherProps } as Partial; } + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + return updated as NetworkEntity; }), networkName: protectedProcedure @@ -626,6 +735,21 @@ export const networkRouter = createTRPCRouter({ }, }); + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + return updated; }), networkDescription: protectedProcedure @@ -680,6 +804,21 @@ export const networkRouter = createTRPCRouter({ }, }); + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + return { description: updated.description, }; @@ -745,6 +884,21 @@ export const networkRouter = createTRPCRouter({ ztControllerUpdates = { config: { ...ztControllerUpdates } }; } + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // Send the request to update the network return await ztController.network_update({ ctx, @@ -791,7 +945,7 @@ export const networkRouter = createTRPCRouter({ : { ...input.updateParams }; try { - return await ztController.network_update({ + await ztController.network_update({ ctx, nwid: input.nwid, central: input.central, @@ -804,6 +958,21 @@ export const networkRouter = createTRPCRouter({ throw error; } } + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: input.updateParams, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } }), createNetwork: protectedProcedure .input( @@ -912,6 +1081,21 @@ export const networkRouter = createTRPCRouter({ // update network in prisma const { prisma } = ctx; + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CONFIG_CHANGED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: input.nwid, + changes: { rulesSource: input.updateParams.flowRoute }, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // Start a transaction return await prisma.$transaction([ // Update network diff --git a/src/server/api/routers/organizationRouter.ts b/src/server/api/routers/organizationRouter.ts index c73a828a..fdbe45d3 100644 --- a/src/server/api/routers/organizationRouter.ts +++ b/src/server/api/routers/organizationRouter.ts @@ -17,6 +17,12 @@ import { createTransporter, inviteOrganizationTemplate, sendEmail } from "~/util import ejs from "ejs"; import { Role } from "@prisma/client"; import { checkUserOrganizationRole } from "~/utils/role"; +import { HookType, NetworkCreated, OrgMemberRemoved } from "~/types/webhooks"; +import { throwError } from "~/server/helpers/errorHandler"; +import { sendWebhook } from "~/utils/webhook"; + +// Create a Zod schema for the HookType enum +const HookTypeEnum = z.enum(Object.values(HookType) as [HookType, ...HookType[]]); export const organizationRouter = createTRPCRouter({ createOrg: adminRoleProtectedRoute @@ -168,6 +174,7 @@ export const organizationRouter = createTRPCRouter({ userRoles: true, users: true, invitations: true, + webhooks: true, }, //order by desc orderBy: { @@ -339,6 +346,20 @@ export const organizationRouter = createTRPCRouter({ organizationId: input.organizationId || null, // Use null if organizationId is not provided }, }); + + try { + // Send webhook + await sendWebhook({ + hookType: HookType.NETWORK_CREATED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + networkId: newNw.nwid, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } return newNw; }), changeUserRole: protectedProcedure @@ -631,15 +652,46 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - // make sure the user is not the owner of the organization const org = await ctx.prisma.organization.findUnique({ where: { id: input.organizationId, }, + include: { + users: { + select: { + email: true, + id: true, + }, + }, + }, }); + + // make sure the user is not the owner of the organization if (org?.ownerId === input.userId) { throw new Error("You cannot leave an organization you own."); } + + // Find the email of the user + const user = org.users.find((user) => user.id === input.userId); + if (!user) { + throw new Error("User not found in organization."); + } + + // Send webhook + try { + await sendWebhook({ + hookType: HookType.ORG_MEMBER_REMOVED, + organizationId: input?.organizationId, + userId: ctx.session.user.id, + userEmail: ctx.session.user.email, + removedUserId: user.id, + removedUserEmail: user.email, + }); + } catch (error) { + // add error messge that webhook failed + throwError(error.message); + } + // leave organization return await ctx.prisma.organization.update({ where: { @@ -820,4 +872,98 @@ export const organizationRouter = createTRPCRouter({ }, }); }), + deleteOrgWebhooks: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + webhookId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + // make sure the user is member of the organization + await checkUserOrganizationRole({ + ctx, + organizationId: input.organizationId, + requiredRole: Role.ADMIN, + }); + + // create webhook + return await ctx.prisma.webhook.deleteMany({ + where: { + id: input.webhookId, + organizationId: input.organizationId, + }, + }); + }), + addOrgWebhooks: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + webhookUrl: z.string(), + webhookName: z.string(), + hookType: z.array(HookTypeEnum), + webhookId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + // make sure the user is member of the organization + await checkUserOrganizationRole({ + ctx, + organizationId: input.organizationId, + requiredRole: Role.ADMIN, + }); + // Validate the URL to be HTTPS + if (!input.webhookUrl.startsWith("https://")) { + // throw error + console.error("Webhook URL is not HTTPS"); + return throwError(`Webhook URL needs to be HTTPS: ${input.webhookUrl}`); + } + + if (input.hookType.length === 0) { + // throw error + return throwError("Webhook needs to have at least one action type"); + } + // create webhook + return await ctx.prisma.webhook.upsert({ + where: { + id: input.webhookId, + }, + create: { + url: input.webhookUrl, + description: "", + name: input.webhookName, + eventTypes: input.hookType, + organization: { + connect: { id: input.organizationId }, + }, + }, + update: { + url: input.webhookUrl, + description: "", + name: input.webhookName, + eventTypes: input.hookType, + }, + }); + }), + getOrgWebhooks: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + // make sure the user is member of the organization + await checkUserOrganizationRole({ + ctx, + organizationId: input.organizationId, + requiredRole: Role.ADMIN, + }); + + // get all organizations related to the user + return await ctx.prisma.webhook.findMany({ + where: { + id: input.organizationId, + }, + }); + }), }); diff --git a/src/types/local/member.d.ts b/src/types/local/member.d.ts index 3fd7fcdb..3548a691 100644 --- a/src/types/local/member.d.ts +++ b/src/types/local/member.d.ts @@ -37,6 +37,7 @@ export interface MemberEntity { accessorFn: () => void; config?: CentralMemberConfig; V6AssignMode?: V6AssignMode; + stashed?: boolean; } type NumberPairArray = [number, number][]; diff --git a/src/types/webhooks.ts b/src/types/webhooks.ts new file mode 100644 index 00000000..abe10ab6 --- /dev/null +++ b/src/types/webhooks.ts @@ -0,0 +1,88 @@ +import { network_members } from "@prisma/client"; +import { NetworkEntity } from "./local/network"; +import { MemberEntity } from "./local/member"; + +// Define Hook Types +export enum HookType { + // UNKNOWN = "UNKNOWN", + NETWORK_JOIN = "NETWORK_JOIN", + // NETWORK_AUTH = "NETWORK_AUTH", + // NETWORK_DEAUTH = "NETWORK_DEAUTH", + // NETWORK_SSO_LOGIN = "NETWORK_SSO_LOGIN", + // NETWORK_SSO_LOGIN_ERROR = "NETWORK_SSO_LOGIN_ERROR", + NETWORK_CREATED = "NETWORK_CREATED", + NETWORK_CONFIG_CHANGED = "NETWORK_CONFIG_CHANGED", + NETWORK_DELETED = "NETWORK_DELETED", + MEMBER_CONFIG_CHANGED = "MEMBER_CONFIG_CHANGED", + MEMBER_DELETED = "MEMBER_DELETED", + // ORG_INVITE_SENT = "ORG_INVITE_SENT", + // ORG_INVITE_ACCEPTED = "ORG_INVITE_ACCEPTED", + // ORG_INVITE_REJECTED = "ORG_INVITE_REJECTED", + ORG_MEMBER_REMOVED = "ORG_MEMBER_REMOVED", +} + +export interface HookBase { + organizationId: string; + hookType: HookType; +} + +export interface MemberConfigChanged extends HookBase { + networkId: string; + memberId: string; + userId: string; + userEmail: string; + changes: Partial; +} + +export interface MemberJoined extends HookBase { + networkId: string; + memberId: string; +} + +export interface NetworkMemberAuth extends HookBase { + networkId: string; + memberId: string; + userId: string; + userEmail: string; + memberMetadata: Record>; +} + +export interface MemberDeleted extends HookBase { + networkId: string; + userId: string; + userEmail: string; + deletedMemberId: string; +} + +export interface OrgMemberRemoved extends HookBase { + userId: string; + userEmail: string; + removedUserId: string; + removedUserEmail: string; +} + +export interface NetworkConfigChanged extends HookBase { + networkId: string; + userId: string; + userEmail: string; + changes: Partial; +} +export interface NetworkMemberDeauth extends HookBase { + networkId: string; + memberId: string; + userId: string; + userEmail: string; + memberMetadata: Record>; +} + +export interface NetworkDeleted extends HookBase { + networkId: string; + userId: string; + userEmail: string; +} + +export interface NetworkCreated extends HookBase { + networkId: string; + userId: string; + userEmail: string; +} diff --git a/src/utils/errors.tsx b/src/utils/errors.tsx new file mode 100644 index 00000000..8eed3525 --- /dev/null +++ b/src/utils/errors.tsx @@ -0,0 +1,17 @@ +import toast from "react-hot-toast"; +import { ErrorData } from "~/types/errorHandling"; + +// biome-ignore lint/suspicious/noExplicitAny: +export const handleErrors = (error: any) => { + if ((error.data as ErrorData)?.zodError) { + const fieldErrors = (error.data as ErrorData)?.zodError.fieldErrors; + for (const field in fieldErrors) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call + toast.error(`${fieldErrors[field].join(", ")}`); + } + } else if (error.message) { + toast.error(error.message); + } else { + toast.error("An unknown error occurred"); + } +}; diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts new file mode 100644 index 00000000..27db5de9 --- /dev/null +++ b/src/utils/webhook.ts @@ -0,0 +1,31 @@ +import { prisma } from "~/server/db"; +import { HookBase } from "~/types/webhooks"; + +// Generic function to send a webhook +export const sendWebhook = async (data: T): Promise => { + if (!data?.organizationId) return; + + const webhookData = await prisma.webhook.findMany({ + where: { organizationId: data.organizationId }, + }); + + for (const webhook of webhookData) { + if ((webhook.eventTypes as string[]).includes(data.hookType)) { + try { + const response = await fetch(webhook.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error( + `Failed to send webhook: ${response.status} ${response.statusText}`, + ); + } + } catch (error) { + throw new Error(`Error sending webhook: ${error.message}`); + } + } + } +};