diff --git a/package.json b/package.json index 2809dd6..674c356 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "@ant-design/icons": "^5.2.5", "@prisma/client": "^5.2.0", "@tanstack/react-query": "^4.33.0", + "@trpc/client": "^10.38.4", + "@trpc/react-query": "^10.38.4", + "@trpc/server": "^10.38.4", "@types/uuid": "^9.0.3", "antd": "^5.8.5", "axios": "^1.5.0", @@ -53,6 +56,7 @@ "uuid": "^9.0.0", "vite-express": "^0.10.0", "yup": "^1.2.0", + "zod": "^3.22.2", "zustand": "^4.4.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a182d..5f53aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,15 @@ dependencies: '@tanstack/react-query': specifier: ^4.33.0 version: 4.33.0(react-dom@18.2.0)(react@18.2.0) + '@trpc/client': + specifier: ^10.38.4 + version: 10.38.4(@trpc/server@10.38.4) + '@trpc/react-query': + specifier: ^10.38.4 + version: 10.38.4(@tanstack/react-query@4.33.0)(@trpc/client@10.38.4)(@trpc/server@10.38.4)(react-dom@18.2.0)(react@18.2.0) + '@trpc/server': + specifier: ^10.38.4 + version: 10.38.4 '@types/uuid': specifier: ^9.0.3 version: 9.0.3 @@ -121,6 +130,9 @@ dependencies: yup: specifier: ^1.2.0 version: 1.2.0 + zod: + specifier: ^3.22.2 + version: 3.22.2 zustand: specifier: ^4.4.1 version: 4.4.1(@types/react@18.2.21)(react@18.2.0) @@ -1803,6 +1815,34 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false + /@trpc/client@10.38.4(@trpc/server@10.38.4): + resolution: {integrity: sha512-svpZ9Iq9cnn+XfXQZF8PMt1YxAtNYeGiKZ/pGpcume7RqJrra/kWwU41gbax8d/cAg3YKUgNft9dZFKMYtXuYw==} + peerDependencies: + '@trpc/server': 10.38.4 + dependencies: + '@trpc/server': 10.38.4 + dev: false + + /@trpc/react-query@10.38.4(@tanstack/react-query@4.33.0)(@trpc/client@10.38.4)(@trpc/server@10.38.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-wEreUn9E+ZMKn/oRWlhHzmSgG5SG9WpmE1F27PPjn3I0S92aYRQvVsmV43v2OAL1VUYP2aHLk7gXx2luKLLRyw==} + peerDependencies: + '@tanstack/react-query': ^4.18.0 + '@trpc/client': 10.38.4 + '@trpc/server': 10.38.4 + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@tanstack/react-query': 4.33.0(react-dom@18.2.0)(react@18.2.0) + '@trpc/client': 10.38.4(@trpc/server@10.38.4) + '@trpc/server': 10.38.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@trpc/server@10.38.4: + resolution: {integrity: sha512-xSMTwnKA/Unxu5fbAkQ7cApHeWj1rTEA3XgrGDcPHn03fmeIidIKxronM46N46ZF4CCexey4JWzu89XxA16uIA==} + dev: false + /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -6145,6 +6185,10 @@ packages: type-fest: 2.19.0 dev: false + /zod@3.22.2: + resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} + dev: false + /zustand@4.4.1(@types/react@18.2.21)(react@18.2.0): resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==} engines: {node: '>=12.7.0'} diff --git a/src/client/App.tsx b/src/client/App.tsx index b987976..0d879f5 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -12,6 +12,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './api/cache'; import { TokenLoginContainer } from './components/TokenLoginContainer'; import React from 'react'; +import { trpc, trpcClient } from './api/trpc'; export const AppRoutes: React.FC = React.memo(() => { const { info } = useUserStore(); @@ -47,13 +48,15 @@ AppRoutes.displayName = 'AppRoutes'; export const App: React.FC = React.memo(() => { return (
- - - - - - - + + + + + + + + +
); }); diff --git a/src/client/api/trpc.ts b/src/client/api/trpc.ts new file mode 100644 index 0000000..683202d --- /dev/null +++ b/src/client/api/trpc.ts @@ -0,0 +1,19 @@ +import { createTRPCReact } from '@trpc/react-query'; +import type { AppRouter } from '../../server/trpc'; +import { httpBatchLink } from '@trpc/client'; +import { getJWT } from './auth'; + +export const trpc = createTRPCReact(); + +export const trpcClient = trpc.createClient({ + links: [ + httpBatchLink({ + url: '/trpc', + async headers() { + return { + Authorization: `Bearer ${getJWT()}`, + }; + }, + }), + ], +}); diff --git a/src/server/main.ts b/src/server/main.ts index f0295a9..875b465 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -11,6 +11,7 @@ import { websiteRouter } from './router/website'; import { workspaceRouter } from './router/workspace'; import { telemetryRouter } from './router/telemetry'; import { initSocketio } from './ws'; +import { trpcExpressMiddleware } from './trpc'; const port = Number(process.env.PORT || 12345); @@ -31,6 +32,8 @@ app.use('/api/website', websiteRouter); app.use('/api/workspace', workspaceRouter); app.use('/api/telemetry', telemetryRouter); +app.use('/trpc', trpcExpressMiddleware); + app.use((err: any, req: any, res: any, next: any) => { console.error(err); res.status(500).json({ message: err.message }); diff --git a/src/server/trpc/index.ts b/src/server/trpc/index.ts new file mode 100644 index 0000000..6d32a5a --- /dev/null +++ b/src/server/trpc/index.ts @@ -0,0 +1,16 @@ +import * as trpcExpress from '@trpc/server/adapters/express'; +import { createContext, publicProcedure, router } from './trpc'; +import { z } from 'zod'; + +const appRouter = router({ + debug: publicProcedure.input(z.string()).query((opts) => { + return { id: opts.input, name: 'Bilbo' }; + }), +}); + +export type AppRouter = typeof appRouter; + +export const trpcExpressMiddleware = trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, +}); diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts new file mode 100644 index 0000000..feb4433 --- /dev/null +++ b/src/server/trpc/trpc.ts @@ -0,0 +1,90 @@ +import { initTRPC, inferAsyncReturnType, TRPCError } from '@trpc/server'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { jwtVerify } from '../middleware/auth'; +import { getWorkspaceUser } from '../model/workspace'; +import { ROLES, SYSTEM_ROLES } from '../utils/const'; + +export function createContext({ + req, + res, +}: trpcExpress.CreateExpressContextOptions) { + const authorization = req.headers['authorization'] ?? ''; + const token = authorization.replace('Bearer ', ''); + + try { + const user = jwtVerify(token); + + return { user }; + } catch (err) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } +} + +type Context = inferAsyncReturnType; +const t = initTRPC.context().create(); + +export const middleware = t.middleware; +export const router = t.router; +export const publicProcedure = t.procedure; + +const isSystemAdmin = middleware(async (opts) => { + const { ctx, input } = opts; + if (ctx.user.role !== SYSTEM_ROLES.admin) { + throw new TRPCError({ code: 'FORBIDDEN' }); + } + + return opts.next(); +}); + +export const systemAdminProcedure = t.procedure.use(isSystemAdmin); +export const workspaceProcedure = t.procedure.use( + createWorkspacePermissionMiddleware() +); +export const ownerProcedure = t.procedure.use( + createWorkspacePermissionMiddleware([ROLES.owner]) +); + +/** + * Create a trpc middleware which help user check workspace permission + */ +function createWorkspacePermissionMiddleware(roles: ROLES[] = []) { + return middleware(async (opts) => { + const { ctx, input } = opts; + + const workspaceId = _.get(input, 'workspaceId', ''); + if (!workspaceId) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Payload required workspaceId', + }); + } + + const userId = ctx.user.id; + + if (!userId) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'ctx miss userId', + }); + } + + const info = await getWorkspaceUser(workspaceId, userId); + if (!info) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Is not workspace user', + }); + } + + if (Array.isArray(roles) && roles.length > 0) { + if (!roles.includes(info.role as ROLES)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Workspace roles not has this permission, need ${roles}`, + }); + } + } + + return opts.next(); + }); +}