├── .env.example ├── .gitignore ├── LICENSE.md ├── README.md ├── components.json ├── docker-compose.yaml ├── drizzle.config.ts ├── echo.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public └── favicon.ico ├── screenshot.png ├── src ├── app │ ├── [orgSlug] │ │ ├── _components │ │ │ ├── actions.ts │ │ │ ├── destinations.tsx │ │ │ ├── endpoints.tsx │ │ │ ├── new-destination.tsx │ │ │ ├── new-endpoint.tsx │ │ │ └── user-dropdown.tsx │ │ ├── dashboard │ │ │ ├── messages │ │ │ │ └── [endpointId] │ │ │ │ │ ├── _components │ │ │ │ │ ├── endpoint-header.tsx │ │ │ │ │ └── endpoint-messages.tsx │ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── endpoint │ │ │ └── [slug] │ │ │ │ └── [[...path]] │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── route.ts │ ├── api │ │ ├── login │ │ │ ├── callback │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── layout.tsx │ └── page.tsx ├── components │ └── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── table.tsx │ │ └── tooltip.tsx ├── env.js ├── lib │ ├── trpc.server.ts │ ├── trpc.tsx │ └── utils.ts ├── server │ ├── auth │ │ ├── adapter.ts │ │ └── index.ts │ ├── db │ │ ├── index.ts │ │ └── schema.ts │ ├── trpc │ │ ├── index.ts │ │ ├── routers │ │ │ ├── destinations.ts │ │ │ ├── endpoints.ts │ │ │ └── messages.ts │ │ └── trpc.ts │ └── utils │ │ ├── send-to-destinations.ts │ │ └── typeid.ts └── styles │ └── globals.css ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # This is setup to use the local docker container 2 | 3 | DATABASE_URL="postgresql://postgres:secretpassword@localhost:5432/unwebhook" 4 | 5 | GITHUB_CLIENT_ID="your-github-client-id" 6 | GITHUB_CLIENT_SECRET="your-github-client-secret" 7 | 8 | NEXT_PUBLIC_PRIMARY_DOMAIN="localhost:3000" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | next-env.d.ts 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Unproprietary Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | --- 8 | 9 | ![Screenshot of UnWebhook](screenshot.png) 10 | 11 | ## About 12 | 13 | A simple tool for working with webhooks. 14 | Great for teams and staging environments. 15 | 16 | Watch the introduction video [on youtube](https://youtu.be/q3dS1leG1wQ) 17 | 18 | Capabilities 19 | 20 | - add multiple endpoints 21 | - save received messages (for 7 days) 22 | - automatically forward incoming messages to one or more destinations 23 | - choose forwarding strategy (send to: first in list, all in list) 24 | - support fallback forwarding (if first is down, forward to next) 25 | - replay webhook delivery (resend the data to destinations) 26 | 27 | _Want to send messages to your local machine and need a tunnel? 28 | Check out [untun](https://github.com/unjs/untun) by the UnJs team_ 29 | 30 | --- 31 | 32 | ## Tech Stack 33 | 34 | `UnWebhook` is built with the following epic technologies & tools: 35 | 36 | - [Next JS](https://nextjs.com) React based FrontEnd & Backend 37 | - [Tailwind](https://tailwindcss.com/) CSS Engine 38 | - [tRPC](https://trpc.io/) Typesafe APIs 39 | - [DrizzleORM](https://orm.drizzle.team/) ORM + MySQL 40 | 41 | ## Running Locally 42 | 43 | To get a local copy up and running, follow these simple steps. 44 | 45 | ### Prerequisites 46 | 47 | Here is what you need to be able to run UnInbox locally. 48 | 49 | - A Supabase database 50 | - Node.js (Version: >=20.x) 51 | - NVM (Node Version Manager) (see https://github.com/nvm-sh/nvm) 52 | - pnpm (see https://pnpm.io/installation) 53 | 54 | ### Setup 55 | 56 | 1. Clone the repo into a public GitHub repository (or fork https://github.com/un/webhook-proxy/fork). 57 | 58 | ```sh 59 | git clone https://github.com/un/webhook-proxy.git UnWebhook 60 | ``` 61 | 62 | > If you are on Windows, run the following command on `gitbash` with admin privileges:
> `git clone -c core.symlinks=true https://github.com/un/webhook-proxy.git`
63 | > See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details. 64 | 65 | 2. Go to the project folder 66 | 67 | ```sh 68 | cd UnWebhook 69 | ``` 70 | 71 | 3. Check and install the correct node/pnpm versions 72 | 73 | ```sh 74 | nvm install 75 | ``` 76 | 77 | 4. Install packages with pnpm 78 | 79 | ```sh 80 | pnpm i 81 | ``` 82 | 83 | 5. Set up your `.env` file 84 | 85 | - Duplicate `.env.example` to `.env`. This file is already pre-configured for use with the local docker containers 86 | 87 | mac 88 | 89 | ```sh 90 | cp .env.example .env 91 | ``` 92 | 93 | windows 94 | 95 | ```sh 96 | copy .env.example .env 97 | ``` 98 | 99 | 6. Set your env variables 100 | 101 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 102 | 103 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 104 | 105 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 106 | 107 | ## Learn More 108 | 109 | To learn more about Next.js, take a look at the following resources: 110 | 111 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 112 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 113 | 114 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 115 | 116 | ## Deploy on Vercel 117 | 118 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 119 | 120 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 121 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: secretpassword 7 | POSTGRES_DB: unwebhook 8 | ports: 9 | - "5432:5432" 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config" 2 | import { type Config } from "drizzle-kit"; 3 | 4 | export default { 5 | schema: "./src/server/db/schema.ts", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | tablesFilter: ["unwebhook_*"], 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /echo.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | const server = http.createServer(); 3 | 4 | server 5 | .on("request", (request, response) => { 6 | let body: any[] = []; 7 | request 8 | .on("data", (chunk) => { 9 | body.push(chunk); 10 | }) 11 | .on("end", () => { 12 | const wholeBody = Buffer.concat(body).toString(); 13 | console.log(`[${new Date().toLocaleString()}]`); 14 | console.log(`==== ${request.method} ${request.url}`); 15 | console.log("> Headers"); 16 | console.log(request.headers); 17 | console.log("> Body"); 18 | console.log(wholeBody); 19 | response.end(); 20 | }); 21 | }) 22 | .listen(3001) 23 | .once("listening", () => { 24 | console.log("Test server is listening on port 3001"); 25 | }); 26 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | await import("./src/env.js"); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | redirects: async () => [ 6 | { source: "/github", destination: "https://github.com/un/webhook-proxy", permanent: true }, 7 | ], 8 | }; 9 | 10 | export default nextConfig; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u22n/webhook", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev --turbo", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "db:studio": "drizzle-kit studio", 12 | "db:push": "drizzle-kit push", 13 | "format": "prettier --write ." 14 | }, 15 | "dependencies": { 16 | "@hookform/resolvers": "^3.6.0", 17 | "@lucia-auth/adapter-drizzle": "^1.0.7", 18 | "@phosphor-icons/react": "^2.1.5", 19 | "@radix-ui/react-avatar": "^1.0.4", 20 | "@radix-ui/react-dialog": "^1.0.5", 21 | "@radix-ui/react-dropdown-menu": "^2.0.6", 22 | "@radix-ui/react-label": "^2.0.2", 23 | "@radix-ui/react-scroll-area": "^1.0.5", 24 | "@radix-ui/react-select": "^2.0.0", 25 | "@radix-ui/react-slot": "^1.0.2", 26 | "@radix-ui/react-tooltip": "^1.0.7", 27 | "@t3-oss/env-nextjs": "^0.10.1", 28 | "@tanstack/react-query": "^5.40.1", 29 | "@trpc/client": "11.0.0-rc.377", 30 | "@trpc/react-query": "11.0.0-rc.377", 31 | "@trpc/server": "11.0.0-rc.377", 32 | "arctic": "^1.9.0", 33 | "class-variance-authority": "^0.7.0", 34 | "clsx": "^2.1.1", 35 | "date-fns": "^3.6.0", 36 | "drizzle-orm": "^0.31.1", 37 | "lucia": "^3.2.0", 38 | "nanoid": "^5.0.7", 39 | "next": "14.2.3", 40 | "next-themes": "^0.3.0", 41 | "nuqs": "^1.17.4", 42 | "postgres": "^3.4.4", 43 | "react": "^18.3.1", 44 | "react-code-blocks": "^0.1.6", 45 | "react-dom": "^18.3.1", 46 | "react-hook-form": "^7.51.5", 47 | "server-only": "^0.0.1", 48 | "sonner": "^1.4.41", 49 | "superjson": "^2.2.1", 50 | "tailwind-merge": "^2.3.0", 51 | "tailwindcss-animate": "^1.0.7", 52 | "typeid-js": "^0.7.0", 53 | "zod": "^3.23.8" 54 | }, 55 | "devDependencies": { 56 | "@types/node": "^20.14.2", 57 | "@types/react": "^18.3.3", 58 | "@types/react-dom": "^18.3.0", 59 | "@u22n/tsconfig": "^0.0.2", 60 | "dotenv": "^16.4.5", 61 | "drizzle-kit": "^0.22.2", 62 | "postcss": "^8.4.38", 63 | "prettier": "^3.3.1", 64 | "prettier-plugin-sort-imports": "^1.8.5", 65 | "prettier-plugin-tailwindcss": "^0.6.1", 66 | "tailwindcss": "^3.4.4", 67 | "typescript": "^5.4.5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | const config = { 3 | printWidth: 100, 4 | tabWidth: 2, 5 | useTabs: false, 6 | singleQuote: false, 7 | plugins: ["prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 |  (F  (n00 (-� ��F(  $]��]$ �������� 8����������8��������������������������#���OOO�������������������������ggg����#Y����������������������������555����Y�����kkk������������������������������� �����������������������Y�����JJJ���������kkk������Y#�������������� ������#������111�DDD�������������������8����������8 �������� $]��]$( @ ,U����U,*������������*����������������Q������������������Qr��������������������rr����������������������rO������������������������O������������������������������������������������������(����������������������������'�������888���������������������������������������������������������___������������������������������������������������������������������������SSS��������+��������hhh�������������������������������������������������������������+T���������������������������������������������������������,,,���������T����������GGG��������������������������������������������������������������������������������������������������������������������������������+++���������������������������������jjj��������������������������������������������������������������������T������������������������������������III������������T+������������hhh���������������������������������+�����������������������������,,,��������������������������GGG��������������������������'����������������������������������(�������������333�___����������������������������������������O������������������������Or����������������������rr��������������������rQ������������������Q����������������*������������*,U����U,(0` - (Lj����jK( V��������������U%��������������������&������������������������Q��������������������������R��������������������������������������������������������������������������������������������������������������������������������������������������������������������������P��������������������������������������O����������������������������������������������������������������������������������#������������������������������������������#������������������������������������������������������$$$�hhh�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�eee�PPP�����������U���������������������������������������������������������������������������������������������������sss�����������U������������eee������������������������������������������������������������������������������������������������� ���������������������������������������������������������������������������������������������HHH������������� (�������������EEE������������������������������������������������������������������������������������������(K��������������������������������������������������������������������������������������,,,��������������Lj��������������)))�����������������������������������������������������������������������������������j�������������������������������������������������������������������������������������������������������������������������������������������������������������������������iii����������������������������������eee����������������������������������������������������������������������������������������������������������������������������������������HHH������������������j�����������������EEE��������������������������������������������������������������jL����������������������������������������������������������,,,������������������K(������������������)))�������������������������������������������������������( ���������������������������������������������������������������������� ��������������������������������������������iii��������������������U�������������������eee����������������������������������������U������������������������������������HHH����������������������������������������EEE���������������������������������#����������������������������,,,��������������������#��������������������222�}}}�������������������������������������������������������������O��������������������������������������P��������������������������������������������������������������������������������������������������������������������������������������������������������������������������R��������������������������Q������������������������&��������������������%U��������������V (Kj����jL( �PNG 2 |  3 | IHDR\r�fsRGB���8eXIfMM*�i��D"8sIDATx�] �ՙn�]<QVA���h$ �N��13*�q��d�č�I���D�L2��(�(Ԙ2�ę�G ��q_@屈���xț�Џ��{o�������U�{}�O��;������9�‘d���(Dg��8 ��N�]��@ �hx�?v �N�3�=`;�6�.�&� �u�� ��6� P��н��@�àR�P�iZq�^DN���wp� � ��X�hИH g@�� 4 | :��|�5` p"@�'�ɲ�s{ �p�*�2����� dү���|(0� 5 | 0��>K� 6 | �xX�6IJ ��C|?$KE N�}ϓ|������h $ 2 � �|/�.Nz�# ���W�e� 7 | �5�� ����ܶ�� �;�y�� �g�s�h^I� �DL(�;�8�� Hjg�cH|x�1��R"�a���Ӂ�G��@��9`/`%0� H�@j �~,���K 8 | �,t).��I���D�T�O�)~��V�u$b 誛 �U%�7������_�$b 8A������J�3` 510wQ�?��vr���:�2�K�@ ��v*{%#��A�Z�咁^(��=�g\��W�����!:��,`�6��643�:@�c.Fٟ����u?�<��'������_܏vp: �8Q�� 9 | I�Ł� p{3���kHȢ�G�����c�Ѽ <�62&� 10 | ��2uC�����敭��T�3� 11 | �� ���;���d�/~m��.��X�@{�w.��d]G��{lK��Eb���(P�RuM�T�C���� �d��])��_Lm�=��=@b���K��GUk�^�U�������)1����g�T�Š��m`9�\����Q��@����Ⱆ6�:ڞ�^�w�����E�D ��� �5����F�,�� 12 | �X"�d�m�<�nB~�� @����t�t�x�� �;�f�>����I8����8��C1۪$B���e���+��jl��EZ��& ��S:�:�6�m����\G1��`���!�nl�l�Ɗ�^�Q`��@Oc�S��@e�ͷ���qb�p���S��@u p���F�D@�Г������2@#����L3�A��$H2�_h��FH #rq(��O�D�򤬈���runGOWa�b�&�SgD�3�ED�to�*Ǥ����9k��~)���,$�x�R�1�v�K ��9�D 䍁U(�w�&LE��ꩻ� S)��3�Y8x8$.i�(��K�ŀY ����a�]��� �4��ǀ c����@3�f����4�Ƣ�� �/*b������$!I�~� �7�B*-1` o �� �$��ǡD�����L�������J"���OQ��)��2@#�x4�"$e���I�8��Oi��8�"��G��8[x�t<�.��7&�m&؎R�^��tq�ؕ�.���Y�-2��d���*_��&d|j\�W�b � �G����*g����釁�F4�"I�؃�/b1q�N����Y�D ��p ���9���p�}w\��Ԥ���1 j`��O���xK=��H���A��1 �#� 13 | D:U8j���t���$b b�A||�U�Q��26%��)1 ��_ �ꢳ!~D�����+b >A��:]�E$��50��GDhR�t����ݻwR�)�� P���n$� 3���@bS�Nu�,Y�j�ʲ��:����;�����@�`�|�-[)�'OV��Ն�sFxڮ��ۥ�n}͛7�����~��ƺ�:���Q��J_��UKj8�q0x���;v4̞=[�hW=� �� �&�!e5�8hѢE��w�]�����6���_�iW}�SZ�? �/`�;vl�}��2<�h�"� ���A�܁�X,�m۶�+V�(��<�w���#F�^���;���aH�c� ��)S�*�{a���p ��c89(�^����4�&E��oÆ ��W�/��u�=�^���*?{k^�_E�����z���g��UI-���{WU* 14 | �:p�9 .tڷo(/ݺus>��3�'�^�Rg���ڞG��I_D���� ���~~���{ ���?N0�7�S��.ƍ׸�~?}/y]nA;�أ���2]�FOB2C?�_I����[�:�:�=#�OzK�-� ��ϣ�%����?j��I���P�ۯ��{N�-hU��t�:�������,���G�K�-hU���c�hP7 �� �˜�@�n?�\�-�k�.���2�:�� �`��F��=�-�V�_�G��܂V���}�0WI����F��ʭ���sM�r Z�8pJ�Q�*@OK8��� 15 | r Z� �ݖa,��w��S�W^y����.��5�at7��ݏ���Tv#�~7n ��A"�����+��W��pM��/�hK8����g��F/^������M{e��R�|�)q��7�t��?8'���K��P~���瞰�\��r ��>�ǷUk�eP��|�^x���� 16 | �/V/��v������ ���*�p�v������ʟ]J��}��k8(������ĉ�ѣGǗ�O�mڴq,X�o ���e. �^ �Qx���p�t����4^_�N�{�����y�2�s������-عsg�s���i�v��Z 8 17 | !~PJ?�c�������|�]�ܽ{��z�긓R��1pn���z�����tlp�9�f�r�v�jT殿�z�4*O�L�~����ԕ3��4�~~�r �;�m�xY�+��� ������3r �;�m�x�4���:7]Ձq L�4)U��!r �1��u�6���$� �7����8�w��̙3Ǹ|5�>?�\z��O� ��͆���,�E����3�����2���[����2Wu:E�����^p. H1cJ�t�]}��B�u��SOu�����I c�O�����%� �AZ������k����D?�5�@Q�� ���3�w�+��"��T��S��Uޥ�13��?� �5M'݋��>p��Z�j�~fj� ׈�סԐ�n�����>���i5D�[bf ��~a�'�`Xc���-�1�k����āI�������k��Q�ů|�k�M��(92�@�t�����݂X-�Lדa��N4��qܞ'$f0@� @V�nA�ܘY�L9:�|/^s��� ��)0`�j��T\w�uZ-����¨\� @�:��c�t���{�-��Rb��1%��I,Y%T���~ ��r �1����C��,�$��*ˀ���f<��0z����h�F��������| ���8Z-�CR����Tg��HRf��glY����s��-��p��'+����m�_ؒg������C�{� ����Ȫ�ϏΙ3g�-�GR|׹7`G��񥡘�0�U��_ٵZЏ�د�D�)���\>����ʗ������zN���@��~~��-��P��{rs���@�<����|.]�Ը|��m|g����_��y�W�KD1�b�M���%�s\����r �1��n�\ �ƒ�"-��` .4��~%3��I}[0A��$��=-�>BH"G�ۏ�^r��<�EBG�i�%���9�@^�~~ @�����1�� ��@�t�-[����{%@C�$�mAg���Κ5kʆх����/双O��l��ӿ��B�@.X���u�p�O��6��x�9MPn�`߷o_���^n�`t� 18 | ��(�����\r��s�A�y���ۂ�T��@h 19 | �E0l�0��;�tڵӘkƸN����Y�jU�� 20 | S#�|^㽺- |��p� N�.���ޥ`�^{�zL�6��4�ě�b��e�]&"�d�sΜ9Uޥ�U0�! ��*nP�*`���o֨v����i8G�����hh��m������ɓ�s�=�{J�U0�Ղ���wZ������������8bEz���,Y�D��![C�>}��7:k׮ �no��f�>jvR?#b��X �(��F�AT�F��i��[�{��zv��>��C���a+�[0B2�D��=��G~�( 21 | �ĺ������LO�\s�܂>"8|�`[) 22 | &Lp8�'��������4oGe�#�ۏ�lْ_\�D̀܂�2Z�l��i�9�� t�ȑ9f ޢ�-����=���Y�y��n?uQ�}Xͬ �sA�i>=��1�=R��+� + �܂��.2� �K������CƢۃ20h� �˫%53�5@�MA�%���̣������j[��9�;��_(�����0��~r���\�{�m�P����x#TT9��n?����N#��ץ&� }���) 23 | �T�VL�!���j���` �p �8@Rr�UAV�A����=��-����pLH�`@n�*Ȋ1�܂U���?}w ]�H2@�ߴi��V���[�˯%�������5�8�)Э 24 | T`��|rZbZ-�.�!da+@����ߞ�Z�gf�[0p������ I��gr �$��o%P�_rCy �V�|߽����"m�Y���-�[ l��kxA���ۯ9]�[pҤI�Ȩ�pP���k��Feِ���gHE�d�nAm"Z�$��5} ���z�8����2r�X�|� ��Sܻw��r�J�s�J�~�T�f�z{�ͫ��x�j?j��Q�E�n��js���|G�xз���󕾤�rzr�� ��`���V{���u��4448�V��ra��p���QRZ�<{�dK.F9��#~T���s.����N%*� ���Ýu�8G&����/W:*x%�{�}@� ��l���Nc#�AI�������i����*?�د�0}�g���C"Ā pۯ������4薒ҏ(b�8�_Q�Y� ���r7'��� `��� �j�6�� *��3�W�g��"��l� �ˆ1�:�Sg}%� � ��P?����1`�����Y���"��D�0b@ �������9������[t��F1���p`k�\U�`��R��A#W81 e`)R�ZM�����[ u��F0� rq.�����#^�=C"Ā9 P'�R~f��� 27 | pn�zdC"�e���?�\K����@&$b }jz�3۵�x/{ ��1 Ra�#�|��ƟUK�=&�^��TM�n�2�9�5)?s���{O'�D ��D���o[kM�oK0�x���Td�_@]b r� �G�����;����D ��D���1�gaR �`��'`0�  �> \��/���f��������ŀ����!fn�Z�|b����U�.t���ट���r�9�+�������� �b rnE�Dk�=��8�����!b R�Cl�P�E�`�܌�K�'~�@���}*�!`�@��6L� �;�� $b@ D��?#��g�F� 28 | �� V��1�v��;�Es��Q����=ɮ�4���b@ T��n��!��3q�0^�V��c��1�ܶ��[����M�=8I����1@�څ@Cu��`N�o��WJĀ�W����e��I��n��N�mீ��ܴ�_ d��(�4`E܅I�� ��"̵�1 *3�+\�E� �\M���)g r��� 29 | ���8�>��p�?vI��0�ǀ~�!b������$'�%"I����R��i�1 �0� �?S~&���r��� ��{ n�_ �����L�?��T�e��Ǝ�7�C"r��OQ~"qI���O 8�?$b � ܋r�#@�_�v�J̙��/��3�'d�/����W[����o'N� �l� �-2����@j�O~��0���2`H�@�؄��+����p OB��uO��(l�S�ԕ���9����~�c�:x/�X d�.���Ɣ�d��V�y@F$H2�����+M*�i��l8O@F$H2����2�4&r� PO��֢��€��7N�YS ����Y�1`��;�JS3n� g[�'��@W@"la`32�n?'�HB2p 30 | �hām�mu �����j@F@��V����Z!��xI���H�y�ѱ) ��>��Z!6���a�`�����dDV$9f��� pM�6�I�!LG:\LdrwPy�~�P�%��L3��7�TK��Am�mo|�6�� 3��-�hJ3��?�67 �yr���"�� ��g��4.$�1���_�[*��&���S/�dq������� C��h�3��>�6Ŷ%������\�#�RZq� � =lK|ŔX��X�WS�ej5/����$���:��v@������8�� �d��1(�z2~F�)���3��͋���l��C�������#����=�.\Lt? %�N$9b�%�:���2��u �1|-� ld�����t$b��@?���@� �F�c��ρ^�D �d�[9�ࠐz�����: 31 | H�@ ��P2v)~���@����z5��|����R�ֵ���|`#�W39؂��<�"-�0��\<�d ��u�oGLz1��Gp����e�倯d�.�j H�@j �F�3��@ c{s<��J& �@�����b���w�� �� ��n���v��< �����,M;��*p>p!0hH��{=�����x�]I� �DLh����<'��h8�@V �#��J���f�I� ��Hn����W�} �N�t[u�$��������� @� 2 �]&)� �#�3���, =%�T���k�&� I�����I��ӳ��[8 � �L�]�]t�T�g���6�-@b2U�OV��: A?�� } .i�| �xC���rv�w;��#�>�i8_b82�WP�������{'n���8�z;�Ƥy��s���@���P��o|�S�ih$3��@߹j��IEND�B`� -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un/webhook-proxy/0257bcfb3ea0dd97f9cb5da3cb25cc5bb6a3941d/screenshot.png -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { lucia, validateRequest } from "~/server/auth"; 4 | import { redirect } from "next/navigation"; 5 | import { cookies } from "next/headers"; 6 | 7 | export async function logout() { 8 | const { session } = await validateRequest(); 9 | if (!session) { 10 | return { 11 | error: "Unauthorized", 12 | }; 13 | } 14 | 15 | await lucia.invalidateSession(session.id); 16 | 17 | const sessionCookie = lucia.createBlankSessionCookie(); 18 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); 19 | return redirect("/"); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/destinations.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableHead, 6 | TableHeader, 7 | TableRow, 8 | } from "~/components/ui/table"; 9 | import { NewDestinationDialog } from "./new-destination"; 10 | import { validateRequest } from "~/server/auth"; 11 | import { trpcServer } from "~/lib/trpc.server"; 12 | import { format } from "date-fns"; 13 | 14 | export default async function DestinationsTable() { 15 | const { user } = await validateRequest(); 16 | if (!user) return null; 17 | const destinations = await trpcServer.destinations.getAllDestinations({ 18 | orgSlug: user.username, 19 | }); 20 | return ( 21 |
22 |
Destinations
23 | 24 | 25 | 26 | Name 27 | Url 28 | Created 29 | 30 | 31 | 32 | {destinations.map((destination) => ( 33 | 34 | {destination.name} 35 | {destination.url} 36 | {format(destination.createdAt, "HH:mm 'on' do MMM yyy")} 37 | 38 | ))} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/endpoints.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableHead, 6 | TableHeader, 7 | TableRow, 8 | } from "~/components/ui/table"; 9 | import { NewEndpointDialog } from "./new-endpoint"; 10 | import { validateRequest } from "~/server/auth"; 11 | import { trpcServer } from "~/lib/trpc.server"; 12 | import { Badge } from "~/components/ui/badge"; 13 | import { format } from "date-fns"; 14 | import Link from "next/link"; 15 | 16 | export default async function EndpointsTable() { 17 | const { user } = await validateRequest(); 18 | if (!user) return null; 19 | const endpoints = await trpcServer.endpoints.getAllEndpoints({ 20 | orgSlug: user.username, 21 | }); 22 | return ( 23 |
24 |
Endpoints
25 | 26 | 27 | 28 | Name 29 | Endpoint 30 | Routing Strategy 31 | Created 32 | 33 | 34 | 35 | {endpoints.map((endpoint) => ( 36 | 37 | 38 | 42 | {endpoint.name} 43 | 44 | 45 | {endpoint.slug} 46 | 47 | {endpoint.routingStrategy} 48 | 49 | {format(endpoint.createdAt, "HH:mm 'on' do MMM yyy")} 50 | 51 | ))} 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/new-destination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogHeader, 6 | DialogPortal, 7 | DialogTrigger, 8 | DialogTitle, 9 | DialogDescription, 10 | } from "~/components/ui/dialog"; 11 | import { 12 | Form, 13 | FormControl, 14 | FormDescription, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "~/components/ui/form"; 20 | import { zodResolver } from "@hookform/resolvers/zod"; 21 | import { Button } from "~/components/ui/button"; 22 | import { Input } from "~/components/ui/input"; 23 | import { Plus } from "@phosphor-icons/react"; 24 | import { useRouter } from "next/navigation"; 25 | import { useForm } from "react-hook-form"; 26 | import { trpc } from "~/lib/trpc"; 27 | import { useState } from "react"; 28 | import { toast } from "sonner"; 29 | import { z } from "zod"; 30 | 31 | const LOCALHOST_REGEX = 32 | /^https?:\/\/(localhost|0|10|127|192(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|\[::1?\])/i; 33 | 34 | const formSchema = z.object({ 35 | name: z 36 | .string() 37 | .min(3, "Destination name must be more than 3 letters") 38 | .max(64, "Destination name cant be less than 64 letters"), 39 | url: z 40 | .string() 41 | .url("Destination url must be a valid URL") 42 | .max(1024, "Destination url cant be less than 1024 letters") 43 | .refine( 44 | (url) => !LOCALHOST_REGEX.test(url), 45 | `Destination Url can't be a local url as the relay calls are done on server. You can use a service like cloudflare/ngrok to tunnel your localhost to a public url`, 46 | ), 47 | }); 48 | 49 | export function NewDestinationDialog({ orgSlug }: { orgSlug: string }) { 50 | const [open, setOpen] = useState(false); 51 | const router = useRouter(); 52 | 53 | const { mutateAsync: createDestination, isPending } = trpc.destinations.create.useMutation({ 54 | onSuccess: (_, { name }) => { 55 | toast.success(`Destination named ${name} has been created`); 56 | router.refresh(); 57 | }, 58 | onError: (error) => { 59 | toast.error("Something Went Wrong", { description: error.message }); 60 | }, 61 | onSettled: () => setOpen(false), 62 | }); 63 | 64 | const form = useForm>({ 65 | resolver: zodResolver(formSchema), 66 | defaultValues: { 67 | name: "New Destination", 68 | url: "", 69 | }, 70 | }); 71 | 72 | return ( 73 | { 76 | open && form.reset(); 77 | setOpen(open); 78 | }} 79 | > 80 | 81 | 85 | 86 | 87 | 88 | 89 | Create New Destination 90 | 91 | A destination is a URL where the events would be forwarded to. 92 | 93 | 94 |
95 | 97 | createDestination({ ...values, orgSlug }).catch(() => null), 98 | )} 99 | className="space-y-8" 100 | > 101 | ( 105 | 106 | Destination Name 107 | 108 | 109 | 110 | 111 | The name of the destination, this will be displayed in the table. 112 | 113 | 114 | 115 | )} 116 | /> 117 | ( 121 | 122 | Destination URL 123 | 124 | 125 | 126 | The URL where the message will be relayed to. 127 | 128 | 129 | )} 130 | /> 131 | 134 | 135 | 136 |
137 |
138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/new-endpoint.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogHeader, 6 | DialogPortal, 7 | DialogTrigger, 8 | DialogTitle, 9 | DialogDescription, 10 | } from "~/components/ui/dialog"; 11 | import { 12 | Form, 13 | FormControl, 14 | FormDescription, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "~/components/ui/form"; 20 | import { 21 | Select, 22 | SelectContent, 23 | SelectItem, 24 | SelectTrigger, 25 | SelectValue, 26 | } from "~/components/ui/select"; 27 | import { zodResolver } from "@hookform/resolvers/zod"; 28 | import { Button } from "~/components/ui/button"; 29 | import { Input } from "~/components/ui/input"; 30 | import { Plus } from "@phosphor-icons/react"; 31 | import { useRouter } from "next/navigation"; 32 | import { useForm } from "react-hook-form"; 33 | import { trpc } from "~/lib/trpc"; 34 | import { useState } from "react"; 35 | import { toast } from "sonner"; 36 | import { z } from "zod"; 37 | 38 | const formSchema = z.object({ 39 | name: z 40 | .string() 41 | .min(3, "Endpoint name must be more than 3 letters") 42 | .max(64, "Endpoint name cant be less than 64 letters"), 43 | slug: z 44 | .string() 45 | .min(3, "Endpoint url must be more than 3 letters") 46 | .max(64, "Endpoint url cant be less than 64 letters"), 47 | routingStrategy: z.enum(["first", "all"], { message: "Select a valid routing strategy" }), 48 | }); 49 | 50 | export function NewEndpointDialog({ orgSlug }: { orgSlug: string }) { 51 | const [open, setOpen] = useState(false); 52 | const router = useRouter(); 53 | 54 | const { mutateAsync: createEndpoint, isPending } = trpc.endpoints.create.useMutation({ 55 | onSuccess: (_, { name }) => { 56 | toast.success(`Endpoint named ${name} has been created`); 57 | router.refresh(); 58 | }, 59 | onError: (error) => { 60 | toast.error("Something Went Wrong", { description: error.message }); 61 | }, 62 | onSettled: () => setOpen(false), 63 | }); 64 | 65 | const form = useForm>({ 66 | resolver: zodResolver(formSchema), 67 | defaultValues: { 68 | name: "New Endpoint", 69 | slug: "", 70 | routingStrategy: "first", 71 | }, 72 | }); 73 | 74 | return ( 75 | { 78 | open && form.reset(); 79 | setOpen(open); 80 | }} 81 | > 82 | 83 | 87 | 88 | 89 | 90 | 91 | Create New Endpoint 92 | 93 | A endpoint is a URL which will be used with the Webhooks you want to catch. 94 | 95 | 96 |
97 | 99 | createEndpoint({ ...values, orgSlug }).catch(() => null), 100 | )} 101 | className="space-y-8" 102 | > 103 | ( 107 | 108 | Endpoint Name 109 | 110 | 111 | 112 | 113 | The name of the endpoint, this will be displayed in the table. 114 | 115 | 116 | 117 | )} 118 | /> 119 | ( 123 | 124 | Endpoint URL 125 | 126 | 127 | 128 | The URL where the endpoint will be available. 129 | 130 | 131 | )} 132 | /> 133 | ( 137 | 138 | Routing Strategy 139 | 150 | 151 | The routing strategy determines how the endpoint will be routed. 152 | 153 | First: will deliver to the first 154 | available destination. 155 | 156 | 157 | All: will deliver to all available 158 | destinations. 159 | 160 | 161 | 162 | 163 | )} 164 | /> 165 | 168 | 169 | 170 |
171 |
172 |
173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/_components/user-dropdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuLabel, 7 | DropdownMenuTrigger, 8 | DropdownMenuSeparator, 9 | DropdownMenuItem, 10 | } from "~/components/ui/dropdown-menu"; 11 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; 12 | import { SignOut } from "@phosphor-icons/react"; 13 | import { logout } from "./actions"; 14 | 15 | type UserDropdownProps = { 16 | username: string; 17 | }; 18 | 19 | export function UserDropdown({ username }: UserDropdownProps) { 20 | return ( 21 | 22 | 23 | 29 | 30 | 31 | 32 | Signed in as 33 | {username} 34 | 35 | 36 | logout()}> 37 | Logout 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/dashboard/messages/[endpointId]/_components/endpoint-header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogHeader, 6 | DialogPortal, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "~/components/ui/dialog"; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "~/components/ui/select"; 17 | import { ArrowLeft, Plus } from "@phosphor-icons/react"; 18 | import { Skeleton } from "~/components/ui/skeleton"; 19 | import type { TypeId } from "~/server/utils/typeid"; 20 | import { Button } from "~/components/ui/button"; 21 | import { Label } from "~/components/ui/label"; 22 | import { Badge } from "~/components/ui/badge"; 23 | import { useParams } from "next/navigation"; 24 | import { useMemo, useState } from "react"; 25 | import { trpc } from "~/lib/trpc"; 26 | import { cn } from "~/lib/utils"; 27 | import { toast } from "sonner"; 28 | import Link from "next/link"; 29 | import { env } from "~/env"; 30 | 31 | export function EndpointHeader() { 32 | const { endpointId, orgSlug } = useParams<{ 33 | endpointId: TypeId<"endpoint">; 34 | orgSlug: string; 35 | }>(); 36 | const utils = trpc.useUtils(); 37 | const { 38 | data: endpoint, 39 | isLoading, 40 | error, 41 | } = trpc.endpoints.getEndpoint.useQuery({ 42 | orgSlug, 43 | publicId: endpointId, 44 | }); 45 | 46 | const { mutateAsync: changeStrategy, isPending } = trpc.endpoints.setEndpointStrategy.useMutation( 47 | { 48 | onSuccess: () => { 49 | utils.endpoints.getEndpoint.setData({ orgSlug, publicId: endpointId }, (updater) => { 50 | if (!updater) return; 51 | return { 52 | ...updater, 53 | routingStrategy: endpoint?.routingStrategy === "first" ? "all" : "first", 54 | }; 55 | }); 56 | }, 57 | onError: (err) => { 58 | toast.error("Failed to change routing strategy", { 59 | description: err.message, 60 | }); 61 | }, 62 | }, 63 | ); 64 | 65 | const { mutateAsync: removeEndpoint, isPending: isRemovingEndpoint } = 66 | trpc.endpoints.removeEndpointDestination.useMutation({ 67 | onSuccess: (_, { destinationPublicId }) => { 68 | utils.endpoints.getEndpoint.setData({ orgSlug, publicId: endpointId }, (updater) => { 69 | if (!updater) return; 70 | return { 71 | ...updater, 72 | destinations: updater.destinations.filter( 73 | (d) => d.destination.publicId !== destinationPublicId, 74 | ), 75 | }; 76 | }); 77 | }, 78 | onError: (err) => { 79 | toast.error("Failed to remove destination", { 80 | description: err.message, 81 | }); 82 | }, 83 | }); 84 | 85 | if ((!isLoading && !endpoint) || error) 86 | return ( 87 |
88 |
Error
89 |
{error?.message ?? "Endpoint not found"}
90 |
91 | ); 92 | 93 | return ( 94 |
95 |
96 |
97 | 102 | {isLoading ? ( 103 | 104 | ) : ( 105 |
106 |
{endpoint?.name}
107 |
{`https://${env.NEXT_PUBLIC_PRIMARY_DOMAIN}/${orgSlug}/endpoint/${endpoint?.slug}`}
108 |
109 | )} 110 |
111 |
112 |
113 | 114 | {isLoading ? ( 115 | 116 | ) : ( 117 | 136 | )} 137 |
138 |
139 | 140 | {isLoading ? ( 141 | 142 | ) : ( 143 |
144 | {endpoint?.destinations 145 | .sort((a, b) => a.order - b.order) 146 | .map((destination) => ( 147 | 155 | {destination.destination.name} 156 | 169 | 170 | ))} 171 | d.destination.publicId) ?? []} 175 | /> 176 |
177 | )} 178 |
179 |
180 |
181 |
182 | ); 183 | } 184 | 185 | function AddDestinationDialog({ 186 | orgSlug, 187 | alreadyAdded, 188 | publicId, 189 | }: { 190 | orgSlug: string; 191 | alreadyAdded: string[]; 192 | publicId: TypeId<"endpoint">; 193 | }) { 194 | const { 195 | data: destinations, 196 | isLoading, 197 | error, 198 | } = trpc.destinations.getAllDestinations.useQuery({ 199 | orgSlug, 200 | }); 201 | const filteredDestinations = useMemo( 202 | () => destinations?.filter((endpoint) => !alreadyAdded.includes(endpoint.publicId)) ?? [], 203 | [destinations, alreadyAdded], 204 | ); 205 | 206 | const utils = trpc.useUtils(); 207 | 208 | const { mutateAsync: addDestination, isPending } = 209 | trpc.endpoints.addEndpointDestination.useMutation({ 210 | onSuccess: (_, { destinationPublicId, publicId }) => { 211 | utils.endpoints.getEndpoint.setData({ orgSlug, publicId }, (updater) => { 212 | if (!updater || !destinations) return; 213 | return { 214 | ...updater, 215 | destinations: [ 216 | ...updater.destinations, 217 | { 218 | destination: destinations.find((d) => d.publicId === destinationPublicId)!, 219 | order: updater.destinations.length, 220 | enabled: true, 221 | }, 222 | ], 223 | }; 224 | }); 225 | }, 226 | onError: (err) => { 227 | toast.error("Failed to add destination", { 228 | description: err.message, 229 | }); 230 | }, 231 | onSettled: () => setOpen(false), 232 | }); 233 | 234 | const [open, setOpen] = useState(false); 235 | const [selectedEndpoint, setSelectedEndpoint] = useState(""); 236 | 237 | return ( 238 | { 241 | if (isPending) return; 242 | if (open) setSelectedEndpoint(""); 243 | setOpen(open); 244 | }} 245 | > 246 | 247 | 250 | 251 | 252 | 253 | 254 | Add New Destination 255 | 256 | {isLoading ? ( 257 | 258 | ) : error ? ( 259 |
260 | Something Went Wrong 261 | {error.message} 262 |
263 | ) : filteredDestinations.length === 0 ? ( 264 | No more endpoints available 265 | ) : ( 266 | 278 | )} 279 | 288 |
289 |
290 |
291 | ); 292 | } 293 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/dashboard/messages/[endpointId]/_components/endpoint-messages.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuPortal, 7 | DropdownMenuTrigger, 8 | } from "~/components/ui/dropdown-menu"; 9 | import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; 10 | import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area"; 11 | import { Dialog, DialogTrigger } from "~/components/ui/dialog"; 12 | import { ArrowClockwise } from "@phosphor-icons/react"; 13 | import { CodeBlock, dracula } from "react-code-blocks"; 14 | import { Skeleton } from "~/components/ui/skeleton"; 15 | import type { TypeId } from "~/server/utils/typeid"; 16 | import { parseAsString, useQueryState } from "nuqs"; 17 | import { Button } from "~/components/ui/button"; 18 | import { useParams } from "next/navigation"; 19 | import { trpc } from "~/lib/trpc"; 20 | import { format } from "date-fns"; 21 | import { cn } from "~/lib/utils"; 22 | import { useMemo } from "react"; 23 | import { toast } from "sonner"; 24 | 25 | export function EndpointMessages() { 26 | const { endpointId, orgSlug } = useParams<{ 27 | endpointId: TypeId<"endpoint">; 28 | orgSlug: string; 29 | }>(); 30 | const [selectedMessage, setSelectedMessage] = useQueryState( 31 | "message", 32 | parseAsString.withDefault(""), 33 | ); 34 | const { 35 | data: messages, 36 | isLoading, 37 | error, 38 | refetch, 39 | isRefetching, 40 | } = trpc.messages.getEndpointMessages.useQuery({ 41 | orgSlug, 42 | publicId: endpointId, 43 | }); 44 | 45 | const selectedMessageObj = useMemo( 46 | () => messages?.find((m) => m.publicId === selectedMessage) ?? null, 47 | [messages, selectedMessage], 48 | ); 49 | 50 | const { 51 | data: messageDeliveries, 52 | isLoading: isDeliveriesLoading, 53 | refetch: refetchDeliveries, 54 | } = trpc.messages.getMessagesDeliveries.useQuery( 55 | { 56 | messagePublicId: selectedMessage, 57 | orgSlug, 58 | }, 59 | { 60 | enabled: !!selectedMessage, 61 | }, 62 | ); 63 | 64 | const { mutateAsync: resendMessage, isPending: isResending } = 65 | trpc.messages.replayMessage.useMutation({ 66 | onSuccess: () => refetchDeliveries(), 67 | onError: (error) => { 68 | toast.error(error.message); 69 | }, 70 | }); 71 | 72 | if (!isLoading && !messages) return null; 73 | if (error && error.data?.code !== "NOT_FOUND") 74 | return
{error?.message}
; 75 | return ( 76 |
77 |
78 |
79 | {isLoading ? ( 80 | 81 | ) : ( 82 |
83 |
84 | Messages 85 | 93 |
94 | {messages?.length === 0 ? ( 95 |
96 | No Messages Yet, Try hitting the endpoint 97 |
98 | ) : ( 99 | 100 |
101 | {messages?.map((message) => ( 102 | 120 | ))} 121 |
122 |
123 | )} 124 |
125 | )} 126 |
127 |
128 | {isLoading ? ( 129 | 130 | ) : !selectedMessageObj ? ( 131 |
132 | Select a message from the messages to view 133 |
134 | ) : ( 135 |
136 |
137 |
138 | Headers 139 | 140 |
141 | Header 142 | Value 143 |
144 | {Object.entries(selectedMessageObj.headers).map(([key, value]) => ( 145 |
149 | {key}: 150 | {value} 151 |
152 | ))} 153 |
154 |
155 |
156 |
157 | Payload 158 |
159 | 160 | 161 | 162 | 163 | 169 | 170 | 171 | Resend this message 172 | 173 | 174 | 175 | { 177 | if (!selectedMessageObj) return; 178 | await resendMessage({ 179 | orgSlug, 180 | messagePublicId: selectedMessageObj.publicId, 181 | }); 182 | }} 183 | > 184 | Resend to Destinations 185 | 186 | 187 | Send to a custom url (Soon) 188 | 189 | 190 | 191 | 192 |
193 |
194 | 195 | 201 | 202 |
203 |
204 |
205 | Deliveries 206 |
207 | {isDeliveriesLoading ? ( 208 |
209 | 210 |
211 | ) : ( 212 | 213 |
214 | {messageDeliveries?.map((delivery, i) => ( 215 |
219 |
220 | 221 | Destination: 222 | 223 | {delivery.destination.name} 224 |
225 |
226 | Status: 227 | {delivery.success ? "Delivered" : "Not Delivered"} 228 |
229 |
230 | 231 | Status Code: 232 | 233 | {delivery.response.code} 234 |
235 |
236 | Response: 237 | 254 |
255 |
256 | 257 | Delivered At: 258 | 259 | 260 | {format(delivery.createdAt, "do MMM yyyy, HH:mm:ss")} 261 | 262 |
263 |
264 | ))} 265 |
266 | 267 |
268 | )} 269 |
270 |
271 |
272 | )} 273 |
274 |
275 |
276 | ); 277 | } 278 | 279 | function tryBeautifyMessage(message: string) { 280 | try { 281 | return JSON.stringify(JSON.parse(message), null, 2); 282 | } catch (e) { 283 | return message; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/dashboard/messages/[endpointId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { EndpointMessages } from "./_components/endpoint-messages"; 2 | import { EndpointHeader } from "./_components/endpoint-header"; 3 | import type { TypeId } from "~/server/utils/typeid"; 4 | 5 | export default function Page({ 6 | params, 7 | }: { 8 | params: { endpointId: TypeId<"endpoint">; orgSlug: string }; 9 | }) { 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import DestinationsTable from "../_components/destinations"; 2 | import EndpointsTable from "../_components/endpoints"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 | {/*

Dashboard

*/} 8 |
9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/endpoint/[slug]/[[...path]]/route.ts: -------------------------------------------------------------------------------- 1 | import { endpoints, messageDeliveries, messages, orgs } from "~/server/db/schema"; 2 | import { sendToDestinations } from "~/server/utils/send-to-destinations"; 3 | import { typeIdGenerator } from "~/server/utils/typeid"; 4 | import { NextResponse } from "next/server"; 5 | import { and, eq } from "drizzle-orm"; 6 | import { db } from "~/server/db"; 7 | 8 | const handler = async ( 9 | req: Request, 10 | { params }: { params: { slug: string; orgSlug: string; path: string[] } }, 11 | ) => { 12 | const { slug, orgSlug, path = [] } = params; 13 | const { method } = req; 14 | const org = await db.query.orgs.findFirst({ 15 | columns: { id: true }, 16 | where: eq(orgs.slug, orgSlug), 17 | }); 18 | if (!org) return new NextResponse("Not Found", { status: 404 }); 19 | const endpoint = await db.query.endpoints.findFirst({ 20 | columns: { 21 | id: true, 22 | routingStrategy: true, 23 | }, 24 | with: { 25 | destinations: { 26 | columns: { 27 | destinationId: true, 28 | enabled: true, 29 | order: true, 30 | }, 31 | with: { 32 | destination: { 33 | columns: { 34 | url: true, 35 | publicId: true, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | where: and(eq(endpoints.slug, slug), eq(endpoints.orgId, org.id)), 42 | }); 43 | if (!endpoint) return new NextResponse("Not Found", { status: 404 }); 44 | 45 | const body = await req.text(); 46 | const headers = Object.fromEntries(req.headers.entries()); 47 | const fullPath = `/${path.join("/")}`; 48 | const messagePublicId = typeIdGenerator("message"); 49 | 50 | const [messageInsert] = await db 51 | .insert(messages) 52 | .values({ 53 | orgId: org.id, 54 | endpointId: endpoint.id, 55 | method, 56 | path: fullPath, 57 | headers, 58 | body, 59 | publicId: messagePublicId, 60 | }) 61 | .returning(); 62 | if (!messageInsert) { 63 | console.error("Failed to insert message into database."); 64 | return new NextResponse("Internal Server Error", { status: 500 }); 65 | } 66 | 67 | const destinationsToSend = endpoint.destinations 68 | .filter((d) => d.enabled) 69 | .sort((a, b) => a.order - b.order) 70 | .map((d) => ({ 71 | publicId: d.destination.publicId, 72 | url: d.destination.url, 73 | id: d.destinationId, 74 | })); 75 | 76 | const results = await sendToDestinations({ 77 | routingStrategy: endpoint.routingStrategy, 78 | destinations: destinationsToSend, 79 | message: { 80 | body, 81 | headers, 82 | method, 83 | path: fullPath, 84 | }, 85 | }).catch((e) => { 86 | console.error(e); 87 | return []; 88 | }); 89 | 90 | await Promise.all( 91 | results.map(async (result) => { 92 | const body = (await result.response?.text()) ?? ""; 93 | const success = result.response?.ok ?? false; 94 | const status = result.response?.status ?? -1; 95 | await db.insert(messageDeliveries).values({ 96 | destinationId: result.id, 97 | messageId: messageInsert.id, 98 | success, 99 | orgId: org.id, 100 | publicId: typeIdGenerator("messageDelivery"), 101 | response: { 102 | code: status, 103 | content: body, 104 | }, 105 | }); 106 | }), 107 | ); 108 | 109 | return new NextResponse("OK"); 110 | }; 111 | 112 | export { handler as POST }; 113 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { UserDropdown } from "./_components/user-dropdown"; 2 | import { validateRequest } from "~/server/auth"; 3 | import type { PropsWithChildren } from "react"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export default async function Layout({ children }: Readonly) { 7 | const { user } = await validateRequest(); 8 | if (!user) redirect("/"); 9 | 10 | return ( 11 |
12 |
13 |
UnWebhook
14 | 15 |
16 |
{children}
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/[orgSlug]/route.ts: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "~/server/auth"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export const GET = async () => { 5 | const { user } = await validateRequest(); 6 | if (!user) return redirect("/"); 7 | return redirect(`/${user.username}/dashboard`); 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/api/login/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { orgMembers, orgs, users } from "~/server/db/schema"; 2 | import { typeIdGenerator } from "~/server/utils/typeid"; 3 | import { github, lucia } from "~/server/auth"; 4 | import { OAuth2RequestError } from "arctic"; 5 | import { cookies } from "next/headers"; 6 | import { db } from "~/server/db"; 7 | import { eq } from "drizzle-orm"; 8 | 9 | export async function GET(request: Request) { 10 | const url = new URL(request.url); 11 | const code = url.searchParams.get("code"); 12 | const state = url.searchParams.get("state"); 13 | const storedState = cookies().get("github_oauth_state")?.value ?? null; 14 | 15 | if (!code || !state || !storedState || state !== storedState) { 16 | return new Response(null, { 17 | status: 400, 18 | }); 19 | } 20 | 21 | try { 22 | const tokens = await github.validateAuthorizationCode(code); 23 | const githubUserResponse = await fetch("https://api.github.com/user", { 24 | headers: { 25 | Authorization: `Bearer ${tokens.accessToken}`, 26 | }, 27 | }); 28 | const githubUser: GitHubUser = await githubUserResponse.json(); 29 | 30 | const existingUser = await db.query.users.findFirst({ 31 | where: eq(users.githubId, githubUser.id), 32 | }); 33 | if (existingUser) { 34 | const session = await lucia.createSession(existingUser.id, {}); 35 | const sessionCookie = lucia.createSessionCookie(session.id); 36 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); 37 | return new Response(null, { 38 | status: 302, 39 | headers: { 40 | Location: `/${existingUser.username}/dashboard`, 41 | }, 42 | }); 43 | } 44 | 45 | const [user] = await db 46 | .insert(users) 47 | .values({ 48 | githubId: githubUser.id, 49 | username: githubUser.login, 50 | publicId: typeIdGenerator("user"), 51 | }) 52 | .returning(); 53 | 54 | if (!user) { 55 | throw new Error("Failed to create user"); 56 | } 57 | 58 | const [newOrg] = await db 59 | .insert(orgs) 60 | .values({ 61 | name: `${user.username}'s Organization`, 62 | slug: user.username, 63 | publicId: typeIdGenerator("org"), 64 | }) 65 | .returning(); 66 | 67 | if (!newOrg) { 68 | throw new Error("Failed to create org"); 69 | } 70 | 71 | await db.insert(orgMembers).values({ 72 | orgId: newOrg.id, 73 | userId: user.id, 74 | }); 75 | 76 | const session = await lucia.createSession(user.id, {}); 77 | const sessionCookie = lucia.createSessionCookie(session.id); 78 | cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); 79 | return new Response(null, { 80 | status: 302, 81 | headers: { 82 | Location: `/${user.username}/dashboard`, 83 | }, 84 | }); 85 | } catch (e) { 86 | console.error(e); 87 | if (e instanceof OAuth2RequestError) { 88 | return new Response(null, { 89 | status: 400, 90 | }); 91 | } 92 | return new Response(null, { 93 | status: 500, 94 | }); 95 | } 96 | } 97 | 98 | interface GitHubUser { 99 | id: string; 100 | login: string; 101 | } 102 | -------------------------------------------------------------------------------- /src/app/api/login/route.ts: -------------------------------------------------------------------------------- 1 | import { github } from "~/server/auth"; 2 | import { cookies } from "next/headers"; 3 | import { generateState } from "arctic"; 4 | import { env } from "~/env"; 5 | 6 | export async function GET() { 7 | const state = generateState(); 8 | const url = await github.createAuthorizationURL(state); 9 | 10 | cookies().set("github_oauth_state", state, { 11 | path: "/", 12 | secure: env.NODE_ENV === "production", 13 | httpOnly: true, 14 | maxAge: 60 * 10, 15 | sameSite: "lax", 16 | }); 17 | 18 | return Response.redirect(url); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { appRouter } from "~/server/trpc"; 3 | import { db } from "~/server/db"; 4 | 5 | export const dynamic = "force-dynamic"; 6 | 7 | const handler = (req: Request) => 8 | fetchRequestHandler({ 9 | req, 10 | endpoint: "/api/trpc", 11 | router: appRouter, 12 | createContext: () => ({ db }), 13 | }); 14 | 15 | export { handler as GET, handler as POST }; 16 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { TooltipProvider } from "~/components/ui/tooltip"; 2 | import { Inter, JetBrains_Mono } from "next/font/google"; 3 | import { Toaster } from "~/components/ui/sonner"; 4 | import type { PropsWithChildren } from "react"; 5 | import { ThemeProvider } from "next-themes"; 6 | import { TrpcProvider } from "~/lib/trpc"; 7 | import type { Metadata } from "next"; 8 | import { cn } from "~/lib/utils"; 9 | import "../styles/globals.css"; 10 | 11 | const inter = Inter({ subsets: ["latin"], weight: "variable", variable: "--font-inter" }); 12 | const jetBrainsMono = JetBrains_Mono({ 13 | subsets: ["latin"], 14 | weight: "variable", 15 | variable: "--font-jetbrains-mono", 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "UnWebhook", 20 | description: "A Webhook Request Catcher by u22n", 21 | }; 22 | 23 | export default function RootLayout({ children }: Readonly) { 24 | return ( 25 | 26 | 27 | 34 | 35 | {children} 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight, ArrowUpRight, GithubLogo } from "@phosphor-icons/react/dist/ssr"; 2 | import { Button } from "~/components/ui/button"; 3 | import { validateRequest } from "~/server/auth"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export default async function Home() { 7 | const { user } = await validateRequest(); 8 | if (user) redirect("/dashboard"); 9 | 10 | return ( 11 |
12 |

UnWebhook

13 |

14 | A Webhook Request Catcher/Relayer/Replayer by{" "} 15 | 16 | u22n 17 | 18 |

19 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 4 | import { cn } from "~/lib/utils"; 5 | import * as React from "react"; 6 | 7 | const Avatar = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 16 | )); 17 | Avatar.displayName = AvatarPrimitive.Root.displayName; 18 | 19 | const AvatarImage = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef 22 | >(({ className, ...props }, ref) => ( 23 | 28 | )); 29 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 30 | 31 | const AvatarFallback = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, ...props }, ref) => ( 35 | 43 | )); 44 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 45 | 46 | export { Avatar, AvatarImage, AvatarFallback }; 47 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import { cn } from "~/lib/utils"; 3 | import * as React from "react"; 4 | 5 | const badgeVariants = cva( 6 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 7 | { 8 | variants: { 9 | variant: { 10 | default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 11 | secondary: 12 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 13 | destructive: 14 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 15 | outline: "text-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | }, 22 | ); 23 | 24 | export interface BadgeProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | function Badge({ className, variant, ...props }: BadgeProps) { 29 | return
; 30 | } 31 | 32 | export { Badge, badgeVariants }; 33 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cn } from "~/lib/utils"; 4 | import * as React from "react"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 13 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 14 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | ghost: "hover:bg-accent hover:text-accent-foreground", 16 | link: "text-primary underline-offset-4 hover:underline", 17 | }, 18 | size: { 19 | default: "h-10 px-4 py-2", 20 | sm: "h-9 rounded-md px-3", 21 | lg: "h-11 rounded-md px-8", 22 | icon: "h-10 w-10", 23 | }, 24 | }, 25 | defaultVariants: { 26 | variant: "default", 27 | size: "default", 28 | }, 29 | }, 30 | ); 31 | 32 | export interface ButtonProps 33 | extends React.ButtonHTMLAttributes, 34 | VariantProps { 35 | asChild?: boolean; 36 | } 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, asChild = false, ...props }, ref) => { 40 | const Comp = asChild ? Slot : "button"; 41 | return ( 42 | 43 | ); 44 | }, 45 | ); 46 | Button.displayName = "Button"; 47 | 48 | export { Button, buttonVariants }; 49 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 4 | import { X } from "@phosphor-icons/react"; 5 | import { cn } from "~/lib/utils"; 6 | import * as React from "react"; 7 | 8 | const Dialog = DialogPrimitive.Root; 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger; 11 | 12 | const DialogPortal = DialogPrimitive.Portal; 13 | 14 | const DialogClose = DialogPrimitive.Close; 15 | 16 | const DialogOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )); 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 36 | 37 | 45 | {children} 46 | 47 | 48 | Close 49 | 50 | 51 | 52 | )); 53 | DialogContent.displayName = DialogPrimitive.Content.displayName; 54 | 55 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 56 |
57 | ); 58 | DialogHeader.displayName = "DialogHeader"; 59 | 60 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 61 |
65 | ); 66 | DialogFooter.displayName = "DialogFooter"; 67 | 68 | const DialogTitle = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, ...props }, ref) => ( 72 | 77 | )); 78 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 79 | 80 | const DialogDescription = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 89 | )); 90 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 91 | 92 | export { 93 | Dialog, 94 | DialogPortal, 95 | DialogOverlay, 96 | DialogClose, 97 | DialogTrigger, 98 | DialogContent, 99 | DialogHeader, 100 | DialogFooter, 101 | DialogTitle, 102 | DialogDescription, 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 4 | import { Check, CaretRight, Circle } from "@phosphor-icons/react"; 5 | import { cn } from "~/lib/utils"; 6 | import * as React from "react"; 7 | 8 | const DropdownMenu = DropdownMenuPrimitive.Root; 9 | 10 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 11 | 12 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 13 | 14 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 15 | 16 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 17 | 18 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 19 | 20 | const DropdownMenuSubTrigger = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef & { 23 | inset?: boolean; 24 | } 25 | >(({ className, inset, children, ...props }, ref) => ( 26 | 35 | {children} 36 | 37 | 38 | )); 39 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )); 54 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; 55 | 56 | const DropdownMenuContent = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, sideOffset = 4, ...props }, ref) => ( 60 | 61 | 70 | 71 | )); 72 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 73 | 74 | const DropdownMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean; 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )); 90 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 91 | 92 | const DropdownMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )); 113 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; 114 | 115 | const DropdownMenuRadioItem = React.forwardRef< 116 | React.ElementRef, 117 | React.ComponentPropsWithoutRef 118 | >(({ className, children, ...props }, ref) => ( 119 | 127 | 128 | 129 | 130 | 131 | 132 | {children} 133 | 134 | )); 135 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 136 | 137 | const DropdownMenuLabel = React.forwardRef< 138 | React.ElementRef, 139 | React.ComponentPropsWithoutRef & { 140 | inset?: boolean; 141 | } 142 | >(({ className, inset, ...props }, ref) => ( 143 | 148 | )); 149 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 150 | 151 | const DropdownMenuSeparator = React.forwardRef< 152 | React.ElementRef, 153 | React.ComponentPropsWithoutRef 154 | >(({ className, ...props }, ref) => ( 155 | 160 | )); 161 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 162 | 163 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { 164 | return ( 165 | 166 | ); 167 | }; 168 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 169 | 170 | export { 171 | DropdownMenu, 172 | DropdownMenuTrigger, 173 | DropdownMenuContent, 174 | DropdownMenuItem, 175 | DropdownMenuCheckboxItem, 176 | DropdownMenuRadioItem, 177 | DropdownMenuLabel, 178 | DropdownMenuSeparator, 179 | DropdownMenuShortcut, 180 | DropdownMenuGroup, 181 | DropdownMenuPortal, 182 | DropdownMenuSub, 183 | DropdownMenuSubContent, 184 | DropdownMenuSubTrigger, 185 | DropdownMenuRadioGroup, 186 | }; 187 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | type ControllerProps, 4 | type FieldPath, 5 | type FieldValues, 6 | FormProvider, 7 | useFormContext, 8 | } from "react-hook-form"; 9 | import * as LabelPrimitive from "@radix-ui/react-label"; 10 | import { Label } from "~/components/ui/label"; 11 | import { Slot } from "@radix-ui/react-slot"; 12 | import { cn } from "~/lib/utils"; 13 | import * as React from "react"; 14 | 15 | const Form = FormProvider; 16 | 17 | type FormFieldContextValue< 18 | TFieldValues extends FieldValues = FieldValues, 19 | TName extends FieldPath = FieldPath, 20 | > = { 21 | name: TName; 22 | }; 23 | 24 | const FormFieldContext = React.createContext({} as FormFieldContextValue); 25 | 26 | const FormField = < 27 | TFieldValues extends FieldValues = FieldValues, 28 | TName extends FieldPath = FieldPath, 29 | >({ 30 | ...props 31 | }: ControllerProps) => { 32 | return ( 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | const useFormField = () => { 40 | const fieldContext = React.useContext(FormFieldContext); 41 | const itemContext = React.useContext(FormItemContext); 42 | const { getFieldState, formState } = useFormContext(); 43 | 44 | const fieldState = getFieldState(fieldContext.name, formState); 45 | 46 | if (!fieldContext) { 47 | throw new Error("useFormField should be used within "); 48 | } 49 | 50 | const { id } = itemContext; 51 | 52 | return { 53 | id, 54 | name: fieldContext.name, 55 | formItemId: `${id}-form-item`, 56 | formDescriptionId: `${id}-form-item-description`, 57 | formMessageId: `${id}-form-item-message`, 58 | ...fieldState, 59 | }; 60 | }; 61 | 62 | type FormItemContextValue = { 63 | id: string; 64 | }; 65 | 66 | const FormItemContext = React.createContext({} as FormItemContextValue); 67 | 68 | const FormItem = React.forwardRef>( 69 | ({ className, ...props }, ref) => { 70 | const id = React.useId(); 71 | 72 | return ( 73 | 74 |
75 | 76 | ); 77 | }, 78 | ); 79 | FormItem.displayName = "FormItem"; 80 | 81 | const FormLabel = React.forwardRef< 82 | React.ElementRef, 83 | React.ComponentPropsWithoutRef 84 | >(({ className, ...props }, ref) => { 85 | const { error, formItemId } = useFormField(); 86 | 87 | return ( 88 |