diff --git a/.dockerignore b/.dockerignore index b57dee0d..2ff6521c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,5 +8,5 @@ npm-debug.log README.md .next .git -tmp +docker converage \ No newline at end of file diff --git a/.env.example b/.env.example index 531a0016..b5a950ad 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,3 @@ -# CLERK -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=CHANGE_ME -CLERK_SECRET_KEY=CHANGE_ME -CLERK_SIGNING_SECRET="CHANGE_ME" - # STORAGE REDIS_URL="redis://127.0.0.1:6379" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" @@ -16,8 +11,4 @@ CONCURRENCY="10" NEXT_PUBLIC_DASHBOARD_URL="http://localhost:3000" NEXT_PUBLIC_API_URL="http://localhost:3333" WORKER_PORT=9999 -API_PORT=3333 -NEXT_PUBLIC_CLERK_SIGN_IN_URL="/login" -NEXT_PUBLIC_CLERK_SIGN_UP_URL="/register" -NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/" -NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/" \ No newline at end of file +API_PORT=3333 \ No newline at end of file diff --git a/README.md b/README.md index 0e4f34f1..ba55d694 100644 --- a/README.md +++ b/README.md @@ -14,38 +14,16 @@ · Discord · - X/Twitter + X/Twitter + · + Creator ·



-Openpanel is a simple analytics tool for logging events on web, apps and backend. We have tried to combine Mixpanel and Plausible in the same product. - -- Visualize your data - - **Charts** - - Funnels - - Line - - Bar - - Pie - - Histogram - - Maps - - **Breakdown** on all properties - - **Advanced filters** on all properties - - Create **beautiful dashboards** with your charts - - **Access all your events** - - Access all your visitors and there history -- Own Your Own Data -- GDPR Compliant -- Cloud or Self-Hosting -- Real-Time Events -- No cookies! -- Privacy friendly -- Cost-Effective -- Predictable pricing -- First Class React Native Support -- Powerful Export API +Openpanel is a powerful analytics platform that captures and visualizes user behavior across web, mobile apps, and backend services. It combines the power of Mixpanel with the simplicity of Plausible. ## Disclaimer @@ -58,13 +36,13 @@ Openpanel is a simple analytics tool for logging events on web, apps and backend - **Postgres** - storing basic information - **Clickhouse** - storing events - **Redis** - cache layer, pub/sub and queue - -### More - -- Tailwind -- Shadcn -- tRPC - will probably migrate this to server actions -- Clerk - for authentication +- **BullMQ** - queue +- **Resend** - email +- **Arctic** - oauth +- **Oslo** - auth +- **tRPC** - api +- **Tailwind** - styling +- **Shadcn** - ui ## Self-hosting @@ -78,9 +56,35 @@ You can find the how to [here](https://docs.openpanel.dev/docs/self-hosting) ## Development +### Prerequisites + +- Docker +- Docker Compose +- Node +- pnpm + +### Setup + +Add the following to your hosts file (`/etc/hosts` on mac/linux or `C:\Windows\System32\drivers\etc\hosts` on windows). This will be your local domain. + +``` +127.0.0.1 op.local +127.0.0.1 api.op.local +``` + +### Start + ```bash pnpm dock:up pnpm codegen pnpm migrate:deploy # once to setup the db pnpm dev ``` + +You can now access the following: + +- Dashboard: https://op.local +- API: https://api.op.local +- Bullboard (queue): http://localhost:9999 +- `pnpm dock:ch` to access clickhouse terminal +- `pnpm dock:redis` to access redis terminal \ No newline at end of file diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 71cff5c8..6c63167e 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -30,8 +30,11 @@ COPY apps/api/package.json ./apps/api/ # Packages COPY packages/db/package.json packages/db/ COPY packages/trpc/package.json packages/trpc/ +COPY packages/auth/package.json packages/auth/ +COPY packages/email/package.json packages/email/ COPY packages/queue/package.json packages/queue/ COPY packages/redis/package.json packages/redis/ +COPY packages/logger/package.json packages/logger/ COPY packages/common/package.json packages/common/ COPY packages/sdks/sdk/package.json packages/sdks/sdk/ COPY packages/constants/package.json packages/constants/ @@ -87,9 +90,13 @@ COPY --from=build /app/apps/api ./apps/api # Packages COPY --from=build /app/packages/db ./packages/db +COPY --from=build /app/packages/auth ./packages/auth COPY --from=build /app/packages/trpc ./packages/trpc +COPY --from=build /app/packages/auth ./packages/auth +COPY --from=build /app/packages/email ./packages/email COPY --from=build /app/packages/queue ./packages/queue COPY --from=build /app/packages/redis ./packages/redis +COPY --from=build /app/packages/logger ./packages/logger COPY --from=build /app/packages/common ./packages/common COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk COPY --from=build /app/packages/constants ./packages/constants diff --git a/apps/api/package.json b/apps/api/package.json index e073b1b9..b6e4f713 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,12 +11,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/fastify": "^1.0.0", "@fastify/compress": "^7.0.3", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.0", "@fastify/rate-limit": "^9.1.0", "@fastify/websocket": "^8.3.1", + "@node-rs/argon2": "^2.0.2", + "@openpanel/auth": "workspace:^", "@openpanel/common": "workspace:*", "@openpanel/db": "workspace:*", "@openpanel/integrations": "workspace:^", @@ -26,6 +27,7 @@ "@openpanel/trpc": "workspace:*", "@openpanel/validation": "workspace:*", "@trpc/server": "^10.45.1", + "bcrypt": "^5.1.1", "fastify": "^4.25.2", "fastify-metrics": "^11.0.0", "ico-to-png": "^0.2.1", diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index 61fd2c38..214576ce 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -1,4 +1,3 @@ -import { validateClerkJwt } from '@/utils/auth'; import type { FastifyReply, FastifyRequest } from 'fastify'; import superjson from 'superjson'; import type * as WebSocket from 'ws'; @@ -124,18 +123,24 @@ export async function wsProjectEvents( }>, ) { const { params, query } = req; - const { token } = query; const type = query.type || 'saved'; + const subscribeToEvent = `event:${type}`; + if (!['saved', 'received'].includes(type)) { connection.socket.send('Invalid type'); connection.socket.close(); return; } - const subscribeToEvent = `event:${type}`; - const decoded = validateClerkJwt(token); - const userId = decoded?.sub; + + const userId = req.session?.userId; + if (!userId) { + connection.socket.send('No active session'); + connection.socket.close(); + return; + } + const access = await getProjectAccess({ - userId: userId!, + userId, projectId: params.projectId, }); @@ -185,18 +190,18 @@ export async function wsProjectNotifications( }>, ) { const { params, query } = req; + const userId = req.session?.userId; - if (!query.token) { - connection.socket.send('No token provided'); + if (!userId) { + connection.socket.send('No active session'); connection.socket.close(); return; } const subscribeToEvent = 'notification'; - const decoded = validateClerkJwt(query.token); - const userId = decoded?.sub; + const access = await getProjectAccess({ - userId: userId!, + userId, projectId: params.projectId, }); diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx new file mode 100644 index 00000000..b3b3c4b9 --- /dev/null +++ b/apps/api/src/controllers/oauth-callback.controller.tsx @@ -0,0 +1,359 @@ +import { + Arctic, + type OAuth2Tokens, + createSession, + generateSessionToken, + github, + google, + setSessionTokenCookie, +} from '@openpanel/auth'; +import { type User, connectUserToOrganization, db } from '@openpanel/db'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; + +async function getGithubEmail(githubAccessToken: string) { + const emailListRequest = new Request('https://api.github.com/user/emails'); + emailListRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`); + const emailListResponse = await fetch(emailListRequest); + const emailListResult: unknown = await emailListResponse.json(); + if (!Array.isArray(emailListResult) || emailListResult.length < 1) { + return null; + } + let email: string | null = null; + for (const emailRecord of emailListResult) { + const emailParser = z.object({ + primary: z.boolean(), + verified: z.boolean(), + email: z.string(), + }); + const emailResult = emailParser.safeParse(emailRecord); + if (!emailResult.success) { + continue; + } + if (emailResult.data.primary && emailResult.data.verified) { + email = emailResult.data.email; + } + } + return email; +} + +export async function githubCallback( + req: FastifyRequest<{ + Querystring: { + code: string; + state: string; + }; + }>, + reply: FastifyReply, +) { + const schema = z.object({ + code: z.string(), + state: z.string(), + inviteId: z.string().nullish(), + }); + + const query = schema.safeParse(req.query); + if (!query.success) { + return reply.status(400).send(query.error.message); + } + const { code, state, inviteId } = query.data; + const storedState = req.cookies.github_oauth_state ?? null; + + if (code === null || state === null || storedState === null) { + return new Response('Please restart the process.', { + status: 400, + }); + } + if (state !== storedState) { + return new Response('Please restart the process.', { + status: 400, + }); + } + + let tokens: OAuth2Tokens; + try { + tokens = await github.validateAuthorizationCode(code); + } catch { + // Invalid code or client credentials + return new Response('Please restart the process.', { + status: 400, + }); + } + const githubAccessToken = tokens.accessToken(); + + const userRequest = new Request('https://api.github.com/user'); + userRequest.headers.set('Authorization', `Bearer ${githubAccessToken}`); + const userResponse = await fetch(userRequest); + + const userSchema = z.object({ + id: z.number(), + login: z.string(), + name: z.string(), + }); + const userJson = await userResponse.json(); + + const userResult = userSchema.safeParse(userJson); + if (!userResult.success) { + return reply.status(400).send(userResult.error.message); + } + const githubUserId = userResult.data.id; + const email = await getGithubEmail(githubAccessToken); + + const existingUser = await db.account.findFirst({ + where: { + OR: [ + { + provider: 'github', + providerId: String(githubUserId), + }, + { + provider: 'oauth', + user: { + email: email ?? '', + }, + }, + ], + }, + }); + + if (existingUser !== null) { + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, existingUser.userId); + + if (existingUser.provider === 'oauth') { + await db.account.update({ + where: { + id: existingUser.id, + }, + data: { + provider: 'github', + providerId: String(githubUserId), + }, + }); + } else if (existingUser.provider !== 'github') { + await db.account.create({ + data: { + provider: 'github', + providerId: String(githubUserId), + user: { + connect: { + id: existingUser.userId, + }, + }, + }, + }); + } + + setSessionTokenCookie( + (...args) => reply.setCookie(...args), + sessionToken, + session.expiresAt, + ); + return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!); + } + + if (email === null) { + return reply.status(400).send('Please verify your GitHub email address.'); + } + + // (githubUserId, email, username); + const user = await await db.user.create({ + data: { + email, + firstName: userResult.data.name, + accounts: { + create: { + provider: 'github', + providerId: String(githubUserId), + }, + }, + }, + }); + + if (inviteId) { + try { + await connectUserToOrganization({ user, inviteId }); + } catch (error) { + req.log.error( + error instanceof Error + ? error.message + : 'Unknown error connecting user to projects', + { + inviteId, + email: user.email, + error, + }, + ); + } + } + + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id); + setSessionTokenCookie( + (...args) => reply.setCookie(...args), + sessionToken, + session.expiresAt, + ); + return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!); +} + +export async function googleCallback( + req: FastifyRequest<{ + Querystring: { + code: string; + state: string; + }; + }>, + reply: FastifyReply, +) { + const schema = z.object({ + code: z.string(), + state: z.string(), + inviteId: z.string().nullish(), + }); + + const query = schema.safeParse(req.query); + if (!query.success) { + return reply.status(400).send(query.error.message); + } + + const { code, state, inviteId } = query.data; + const storedState = req.cookies.google_oauth_state ?? null; + const codeVerifier = req.cookies.google_code_verifier ?? null; + + if ( + code === null || + state === null || + storedState === null || + codeVerifier === null + ) { + return reply.status(400).send('Please restart the process.'); + } + if (state !== storedState) { + return reply.status(400).send('Please restart the process.'); + } + + let tokens: OAuth2Tokens; + try { + tokens = await google.validateAuthorizationCode(code, codeVerifier); + } catch { + return reply.status(400).send('Please restart the process.'); + } + + const claims = Arctic.decodeIdToken(tokens.idToken()); + + const claimsParser = z.object({ + sub: z.string(), + given_name: z.string(), + family_name: z.string(), + picture: z.string(), + email: z.string(), + }); + + const claimsResult = claimsParser.safeParse(claims); + if (!claimsResult.success) { + return reply.status(400).send(claimsResult.error.message); + } + + const { sub: googleId, given_name, family_name, email } = claimsResult.data; + + const existingAccount = await db.account.findFirst({ + where: { + OR: [ + { + provider: 'google', + providerId: googleId, + }, + { + provider: 'oauth', + user: { + email, + }, + }, + ], + }, + }); + + if (existingAccount !== null) { + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, existingAccount.userId); + + if (existingAccount.provider === 'oauth') { + await db.account.update({ + where: { + id: existingAccount.id, + }, + data: { + provider: 'google', + providerId: googleId, + }, + }); + } else if (existingAccount.provider !== 'google') { + await db.account.create({ + data: { + provider: 'google', + providerId: googleId, + user: { + connect: { + id: existingAccount.userId, + }, + }, + }, + }); + } + + setSessionTokenCookie( + (...args) => reply.setCookie(...args), + sessionToken, + session.expiresAt, + ); + return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!); + } + + const user = await db.user.upsert({ + where: { + email, + }, + update: { + firstName: given_name, + lastName: family_name, + }, + create: { + email, + firstName: given_name, + lastName: family_name, + accounts: { + create: { + provider: 'google', + providerId: googleId, + }, + }, + }, + }); + + if (inviteId) { + try { + await connectUserToOrganization({ user, inviteId }); + } catch (error) { + req.log.error( + error instanceof Error + ? error.message + : 'Unknown error connecting user to projects', + { + inviteId, + email: user.email, + error, + }, + ); + } + } + + const sessionToken = generateSessionToken(); + const session = await createSession(sessionToken, user.id); + setSessionTokenCookie( + (...args) => reply.setCookie(...args), + sessionToken, + session.expiresAt, + ); + return reply.status(302).redirect(process.env.NEXT_PUBLIC_DASHBOARD_URL!); +} diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index e8d3dd80..7dbe6f81 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -1,164 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { WebhookEvent } from '@clerk/fastify'; -import { AccessLevel, db } from '@openpanel/db'; +import { db } from '@openpanel/db'; import { sendSlackNotification, slackInstaller, } from '@openpanel/integrations/src/slack'; -import { getRedisPub } from '@openpanel/redis'; import { zSlackAuthResponse } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { pathOr } from 'ramda'; -import { Webhook } from 'svix'; import { z } from 'zod'; -if (!process.env.CLERK_SIGNING_SECRET) { - throw new Error('CLERK_SIGNING_SECRET is required'); -} - -const wh = new Webhook(process.env.CLERK_SIGNING_SECRET); - -function verify(body: any, headers: FastifyRequest['headers']) { - try { - const svix_id = headers['svix-id'] as string; - const svix_timestamp = headers['svix-timestamp'] as string; - const svix_signature = headers['svix-signature'] as string; - - wh.verify(JSON.stringify(body), { - 'svix-id': svix_id, - 'svix-timestamp': svix_timestamp, - 'svix-signature': svix_signature, - }); - - return true; - } catch (error) { - return false; - } -} - -export async function clerkWebhook( - request: FastifyRequest<{ - Body: WebhookEvent; - }>, - reply: FastifyReply, -) { - const payload = request.body; - const verified = verify(payload, request.headers); - - if (!verified) { - return reply.send({ message: 'Invalid signature' }); - } - - if (payload.type === 'user.created') { - const email = payload.data.email_addresses[0]?.email_address; - const emails = payload.data.email_addresses.map((e) => e.email_address); - - if (!email) { - return Response.json( - { message: 'No email address found' }, - { status: 400 }, - ); - } - - const user = await db.user.create({ - data: { - id: payload.data.id, - email, - firstName: payload.data.first_name, - lastName: payload.data.last_name, - }, - }); - - const memberships = await db.member.findMany({ - where: { - email: { - in: emails, - }, - userId: null, - }, - }); - - for (const membership of memberships) { - const access = pathOr([], ['meta', 'access'], membership); - await db.$transaction([ - // Update the member to link it to the user - // This will remove the item from invitations - db.member.update({ - where: { - id: membership.id, - }, - data: { - userId: user.id, - }, - }), - db.projectAccess.createMany({ - data: access - .filter((a) => typeof a === 'string') - .map((projectId) => ({ - organizationId: membership.organizationId, - projectId: projectId, - userId: user.id, - level: AccessLevel.read, - })), - }), - ]); - } - } - - if (payload.type === 'organizationMembership.created') { - const access = payload.data.public_metadata.access; - if (Array.isArray(access)) { - await db.projectAccess.createMany({ - data: access - .filter((a): a is string => typeof a === 'string') - .map((projectId) => ({ - organizationId: payload.data.organization.slug, - projectId: projectId, - userId: payload.data.public_user_data.user_id, - level: AccessLevel.read, - })), - }); - } - } - - if (payload.type === 'user.deleted') { - await db.$transaction([ - db.user.update({ - where: { - id: payload.data.id, - }, - data: { - deletedAt: new Date(), - firstName: null, - lastName: null, - }, - }), - db.projectAccess.deleteMany({ - where: { - userId: payload.data.id, - }, - }), - db.member.deleteMany({ - where: { - userId: payload.data.id, - }, - }), - ]); - } - - if (payload.type === 'organizationMembership.deleted') { - await db.projectAccess.deleteMany({ - where: { - organizationId: payload.data.organization.slug, - userId: payload.data.public_user_data.user_id, - }, - }); - } - - reply.send({ success: true }); -} - const paramsSchema = z.object({ code: z.string(), state: z.string(), @@ -172,7 +22,7 @@ const metadataSchema = z.object({ export async function slackWebhook( request: FastifyRequest<{ - Querystring: WebhookEvent; + Querystring: unknown; }>, reply: FastifyReply, ) { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3161255c..e9b6f9bc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,4 @@ import zlib from 'node:zlib'; -import { clerkPlugin } from '@clerk/fastify'; import compress from '@fastify/compress'; import cookie from '@fastify/cookie'; import cors, { type FastifyCorsOptions } from '@fastify/cors'; @@ -8,14 +7,18 @@ import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import type { FastifyBaseLogger, FastifyRequest } from 'fastify'; import Fastify from 'fastify'; import metricsPlugin from 'fastify-metrics'; -import { path, pick } from 'ramda'; import { generateId } from '@openpanel/common'; -import type { IServiceClient, IServiceClientWithProject } from '@openpanel/db'; +import type { IServiceClientWithProject } from '@openpanel/db'; import { getRedisPub } from '@openpanel/redis'; import type { AppRouter } from '@openpanel/trpc'; import { appRouter, createContext } from '@openpanel/trpc'; +import { + EMPTY_SESSION, + type SessionValidationResult, + validateSessionToken, +} from '@openpanel/auth'; import sourceMapSupport from 'source-map-support'; import { healthcheck, @@ -30,6 +33,7 @@ import exportRouter from './routes/export.router'; import importRouter from './routes/import.router'; import liveRouter from './routes/live.router'; import miscRouter from './routes/misc.router'; +import oauthRouter from './routes/oauth-callback.router'; import profileRouter from './routes/profile.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; @@ -42,6 +46,7 @@ declare module 'fastify' { client: IServiceClientWithProject | null; clientIp?: string; timestamp?: number; + session: SessionValidationResult; } } @@ -61,6 +66,31 @@ const startServer = async () => { : generateId(), }); + fastify.register(cors, () => { + return ( + req: FastifyRequest, + callback: (error: Error | null, options: FastifyCorsOptions) => void, + ) => { + // TODO: set prefix on dashboard routes + const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc']; + + const isPrivatePath = corsPaths.some((path) => + req.url.startsWith(path), + ); + + if (isPrivatePath) { + return callback(null, { + origin: process.env.NEXT_PUBLIC_DASHBOARD_URL, + credentials: true, + }); + } + + return callback(null, { + origin: '*', + }); + }; + }); + fastify.addHook('preHandler', ipHook); fastify.addHook('preHandler', timestampHook); fastify.addHook('onRequest', requestIdHook); @@ -105,40 +135,34 @@ const startServer = async () => { }, ); - fastify.register(cors, () => { - return ( - req: FastifyRequest, - callback: (error: Error | null, options: FastifyCorsOptions) => void, - ) => { - // TODO: set prefix on dashboard routes - const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc']; - - const isPrivatePath = corsPaths.some((path) => - req.url.startsWith(path), - ); - - if (isPrivatePath) { - return callback(null, { - origin: process.env.NEXT_PUBLIC_DASHBOARD_URL, - credentials: true, - }); - } - - return callback(null, { - origin: '*', - }); - }; - }); - + // Dashboard API fastify.register((instance, opts, done) => { - fastify.register(cookie, { - secret: 'random', // for cookies signature + instance.register(cookie, { + secret: process.env.COOKIE_SECRET ?? '', hook: 'onRequest', + parseOptions: {}, }); - instance.register(clerkPlugin, { - publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, - secretKey: process.env.CLERK_SECRET_KEY, + + instance.addHook('onRequest', (req, reply, done) => { + if (req.cookies?.session) { + validateSessionToken(req.cookies.session) + .then((session) => { + if (session.session) { + req.session = session; + } + }) + .catch(() => { + req.session = EMPTY_SESSION; + }) + .finally(() => { + done(); + }); + } else { + req.session = EMPTY_SESSION; + done(); + } }); + instance.register(fastifyTRPCPlugin, { prefix: '/trpc', trpcOptions: { @@ -155,22 +179,27 @@ const startServer = async () => { } satisfies FastifyTRPCPluginOptions['trpcOptions'], }); instance.register(liveRouter, { prefix: '/live' }); + instance.register(webhookRouter, { prefix: '/webhook' }); + instance.register(oauthRouter, { prefix: '/oauth' }); + instance.register(miscRouter, { prefix: '/misc' }); done(); }); - fastify.register(metricsPlugin, { endpoint: '/metrics' }); - fastify.register(eventRouter, { prefix: '/event' }); - fastify.register(profileRouter, { prefix: '/profile' }); - fastify.register(miscRouter, { prefix: '/misc' }); - fastify.register(exportRouter, { prefix: '/export' }); - fastify.register(webhookRouter, { prefix: '/webhook' }); - fastify.register(importRouter, { prefix: '/import' }); - fastify.register(trackRouter, { prefix: '/track' }); - fastify.get('/', (_request, reply) => - reply.send({ name: 'openpanel sdk api' }), - ); - fastify.get('/healthcheck', healthcheck); - fastify.get('/healthcheck/queue', healthcheckQueue); + // Public API + fastify.register((instance, opts, done) => { + instance.register(metricsPlugin, { endpoint: '/metrics' }); + instance.register(eventRouter, { prefix: '/event' }); + instance.register(profileRouter, { prefix: '/profile' }); + instance.register(exportRouter, { prefix: '/export' }); + instance.register(importRouter, { prefix: '/import' }); + instance.register(trackRouter, { prefix: '/track' }); + instance.get('/healthcheck', healthcheck); + instance.get('/healthcheck/queue', healthcheckQueue); + instance.get('/', (_request, reply) => + reply.send({ name: 'openpanel sdk api' }), + ); + done(); + }); fastify.setErrorHandler((error, request, reply) => { if (error.statusCode === 429) { diff --git a/apps/api/src/routes/oauth-callback.router.ts b/apps/api/src/routes/oauth-callback.router.ts new file mode 100644 index 00000000..5b971972 --- /dev/null +++ b/apps/api/src/routes/oauth-callback.router.ts @@ -0,0 +1,18 @@ +import * as controller from '@/controllers/oauth-callback.controller'; +import type { FastifyPluginCallback } from 'fastify'; + +const router: FastifyPluginCallback = (fastify, opts, done) => { + fastify.route({ + method: 'GET', + url: '/github/callback', + handler: controller.githubCallback, + }); + fastify.route({ + method: 'GET', + url: '/google/callback', + handler: controller.googleCallback, + }); + done(); +}; + +export default router; diff --git a/apps/api/src/routes/webhook.router.ts b/apps/api/src/routes/webhook.router.ts index becb9fde..3994b000 100644 --- a/apps/api/src/routes/webhook.router.ts +++ b/apps/api/src/routes/webhook.router.ts @@ -2,11 +2,6 @@ import * as controller from '@/controllers/webhook.controller'; import type { FastifyPluginCallback } from 'fastify'; const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => { - fastify.route({ - method: 'POST', - url: '/clerk', - handler: controller.clerkWebhook, - }); fastify.route({ method: 'GET', url: '/slack', diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index 26f0b608..9944b49a 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -1,13 +1,8 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify'; -import jwt from 'jsonwebtoken'; import { verifyPassword } from '@openpanel/common/server'; -import type { - Client, - IServiceClient, - IServiceClientWithProject, -} from '@openpanel/db'; -import { ClientType, db, getClientByIdCached } from '@openpanel/db'; +import type { IServiceClientWithProject } from '@openpanel/db'; +import { ClientType, getClientByIdCached } from '@openpanel/db'; import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type { IProjectFilterIp, @@ -187,23 +182,3 @@ export async function validateImportRequest( return client; } - -export function validateClerkJwt(token?: string) { - if (!token) { - return null; - } - try { - const decoded = jwt.verify( - token, - process.env.CLERK_PUBLIC_PEM_KEY!.replace(/\\n/g, '\n'), - ); - - if (typeof decoded === 'object') { - return decoded; - } - } catch (e) { - // - } - - return null; -} diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index ce3e972b..39de3440 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -5,7 +5,12 @@ const options: Options = { clean: true, entry: ['src/index.ts'], noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u], - external: ['@hyperdx/node-opentelemetry', 'winston'], + external: [ + '@hyperdx/node-opentelemetry', + 'winston', + '@node-rs/argon2', + 'bcrypt', + ], ignoreWatch: ['../../**/{.git,node_modules}/**'], sourcemap: true, splitting: false, diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index 45f93443..9c02e021 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -34,6 +34,8 @@ COPY packages/db/package.json packages/db/package.json COPY packages/redis/package.json packages/redis/package.json COPY packages/queue/package.json packages/queue/package.json COPY packages/common/package.json packages/common/package.json +COPY packages/auth/package.json packages/auth/package.json +COPY packages/email/package.json packages/email/package.json COPY packages/constants/package.json packages/constants/package.json COPY packages/validation/package.json packages/validation/package.json COPY packages/integrations/package.json packages/integrations/package.json @@ -55,12 +57,6 @@ WORKDIR /app/apps/dashboard # Will be replaced on runtime ENV NEXT_PUBLIC_DASHBOARD_URL="__NEXT_PUBLIC_DASHBOARD_URL__" ENV NEXT_PUBLIC_API_URL="__NEXT_PUBLIC_API_URL__" -ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_eW9sby5jb20k" -# Does not need to be replaced -ENV NEXT_PUBLIC_CLERK_SIGN_IN_URL="/login" -ENV NEXT_PUBLIC_CLERK_SIGN_UP_URL="/register" -ENV NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/" -ENV NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/" RUN pnpm run build diff --git a/apps/dashboard/entrypoint.sh b/apps/dashboard/entrypoint.sh index ef22975e..94c2031e 100644 --- a/apps/dashboard/entrypoint.sh +++ b/apps/dashboard/entrypoint.sh @@ -4,22 +4,16 @@ set -e echo "> Replace env variable placeholders with runtime values..." # Define environment variables to check (space-separated string) -variables_to_replace="NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_API_URL NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" +variables_to_replace="NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_API_URL" # Replace env variable placeholders with real values for key in $variables_to_replace; do value=$(eval echo \$"$key") if [ -n "$value" ]; then echo " - Searching for $key with value $value..." - # Use a custom placeholder for 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY' or use the actual key otherwise - case "$key" in - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) - placeholder="pk_test_eW9sby5jb20k" - ;; - *) - placeholder="__${key}__" - ;; - esac + # Use standard placeholder format for all variables + placeholder="__${key}__" + # Run the replacement find /app -type f \( -name "*.js" -o -name "*.html" \) | while read -r file; do if grep -q "$placeholder" "$file"; then diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 7f83a9da..59bf5d9d 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -25,6 +25,7 @@ const config = { '@openpanel/constants', '@openpanel/redis', '@openpanel/validation', + '@openpanel/email', ], eslint: { ignoreDuringBuilds: true }, typescript: { ignoreBuildErrors: true }, @@ -34,6 +35,7 @@ const config = { 'bullmq', 'ioredis', '@hyperdx/node-opentelemetry', + '@node-rs/argon2', ], instrumentationHook: !!process.env.ENABLE_INSTRUMENTATION_HOOK, }, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5b4da4c8..e364f25a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -11,10 +11,10 @@ "with-env": "dotenv -e ../../.env -c --" }, "dependencies": { - "@clerk/nextjs": "^5.0.12", "@clickhouse/client": "^1.2.0", "@hookform/resolvers": "^3.3.4", "@hyperdx/node-opentelemetry": "^0.8.1", + "@openpanel/auth": "workspace:^", "@openpanel/common": "workspace:^", "@openpanel/constants": "workspace:^", "@openpanel/db": "workspace:^", @@ -47,6 +47,7 @@ "@reduxjs/toolkit": "^1.9.7", "@t3-oss/env-nextjs": "^0.7.3", "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/typography": "^0.5.15", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.11.8", "@trpc/client": "^10.45.1", @@ -117,7 +118,7 @@ "@types/lodash.debounce": "^4.0.9", "@types/lodash.isequal": "^4.5.8", "@types/lodash.throttle": "^4.1.9", - "@types/node": "^18.19.15", + "@types/node": "20.14.8", "@types/ramda": "^0.29.10", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx index d99c48ac..62fcd8ed 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-project-selector.tsx @@ -24,14 +24,14 @@ import { usePathname, useRouter } from 'next/navigation'; import { useState } from 'react'; import type { - getCurrentOrganizations, + getOrganizations, getProjectsByOrganizationId, } from '@openpanel/db'; import Link from 'next/link'; interface LayoutProjectSelectorProps { projects: Awaited>; - organizations?: Awaited>; + organizations?: Awaited>; align?: 'start' | 'end'; } export default function LayoutProjectSelector({ diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx index 347a9548..aa3cd085 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx @@ -1,11 +1,12 @@ import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { - getCurrentOrganizations, - getCurrentProjects, getDashboardsByProjectId, + getOrganizations, + getProjects, } from '@openpanel/db'; +import { auth } from '@openpanel/auth/nextjs'; import LayoutContent from './layout-content'; import { LayoutSidebar } from './layout-sidebar'; import SideEffects from './side-effects'; @@ -22,9 +23,10 @@ export default async function AppLayout({ children, params: { organizationSlug: organizationId, projectId }, }: AppLayoutProps) { + const { userId } = await auth(); const [organizations, projects, dashboards] = await Promise.all([ - getCurrentOrganizations(), - getCurrentProjects(organizationId), + getOrganizations(userId), + getProjects({ organizationId, userId }), getDashboardsByProjectId(projectId), ]); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx index 08c80575..8c8c2ab6 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx @@ -48,101 +48,137 @@ export default function CreateInvite({ projects }: Props) { const mutation = api.organization.inviteUser.useMutation({ onSuccess() { - toast('User invited!', { - description: 'The user has been invited to the organization.', - }); + toast.success('User has been invited'); reset(); - closeSheet(); router.refresh(); }, - onError() { - toast.error('Failed to invite user'); + onError(error) { + toast.error('Failed to invite user', { + description: error.message, + }); }, }); return ( - + mutation.reset()}> - - -
- Invite a user - - Invite users to your organization. They will recieve an email will - instructions. - + {mutation.isSuccess ? ( + + + User has been invited + +
+ {mutation.data.type === 'is_member' ? ( + <> +

+ Since the user already has an account we have added him/her to + your organization. This means you will not see this user in + the list of invites. +

+

We have also notified the user by email about this.

+ + ) : ( +

+ We have sent an email with instructions to join the + organization. +

+ )} +
+ + +
- -
mutation.mutate(values))} - className="flex flex-col gap-8" - > - -
- + + ) : ( + + +
+ Invite a user + + Invite users to your organization. They will recieve an email + will instructions. + +
+
+ mutation.mutate(values))} + className="flex flex-col gap-8" + > + +
+ + ( + +
+ + +
+
+ + +
+
+ )} + /> +
( - -
- - -
-
- - -
-
+
+ + ({ + label: item.name, + value: item.id, + }))} + /> +

+ Leave empty to give access to all projects +

+
)} /> -
- ( -
- - ({ - label: item.name, - value: item.id, - }))} - /> -

- Leave empty to give access to all projects -

-
- )} - /> - - - - -
+ + + + + + )} ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx index 6050dd79..9543c676 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx @@ -1,11 +1,11 @@ import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { PageTabs, PageTabsLink } from '@/components/page-tabs'; import { Padding } from '@/components/ui/padding'; -import { auth } from '@clerk/nextjs/server'; import { ShieldAlertIcon } from 'lucide-react'; import { notFound } from 'next/navigation'; import { parseAsStringEnum } from 'nuqs/server'; +import { auth } from '@openpanel/auth/nextjs'; import { db } from '@openpanel/db'; import EditOrganization from './edit-organization'; @@ -26,7 +26,7 @@ export default async function Page({ const tab = parseAsStringEnum(['org', 'members', 'invites']) .withDefault('org') .parseServerSide(searchParams.tab); - const session = auth(); + const session = await auth(); const organization = await db.organization.findUnique({ where: { id: organizationId, diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx index e3838fc1..ee5865e3 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx @@ -1,12 +1,11 @@ import { Padding } from '@/components/ui/padding'; -import { auth } from '@clerk/nextjs/server'; - +import { auth } from '@openpanel/auth/nextjs'; import { getUserById } from '@openpanel/db'; import EditProfile from './edit-profile'; export default async function Page() { - const { userId } = auth(); + const { userId } = await auth(); const profile = await getUserById(userId!); return ( diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx index d2e97928..9ca10148 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx @@ -1,39 +1,11 @@ 'use client'; import { pushModal, useOnPushModal } from '@/modals'; -import { useUser } from '@clerk/nextjs'; import { differenceInDays } from 'date-fns'; import { useEffect } from 'react'; import { useOpenPanel } from '@openpanel/nextjs'; export default function SideEffects() { - const op = useOpenPanel(); - const { user } = useUser(); - const accountAgeInDays = differenceInDays( - new Date(), - user?.createdAt || new Date(), - ); - useOnPushModal('Testimonial', (open) => { - if (!open) { - user?.update({ - unsafeMetadata: { - ...user.unsafeMetadata, - testimonial: new Date().toISOString(), - }, - }); - } - }); - - const showTestimonial = - user && !user.unsafeMetadata.testimonial && accountAgeInDays > 7; - - useEffect(() => { - if (showTestimonial) { - pushModal('Testimonial'); - op.track('testimonials_shown'); - } - }, [showTestimonial]); - return null; } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx index 2f2acb5e..57c21a52 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx @@ -4,7 +4,8 @@ import ProjectCard from '@/components/projects/project-card'; import { redirect } from 'next/navigation'; import SettingsToggle from '@/components/settings-toggle'; -import { getCurrentOrganizations, getCurrentProjects } from '@openpanel/db'; +import { auth } from '@openpanel/auth/nextjs'; +import { getOrganizations, getProjects } from '@openpanel/db'; import LayoutProjectSelector from './[projectId]/layout-project-selector'; interface PageProps { @@ -16,9 +17,10 @@ interface PageProps { export default async function Page({ params: { organizationSlug: organizationId }, }: PageProps) { + const { userId } = await auth(); const [organizations, projects] = await Promise.all([ - getCurrentOrganizations(), - getCurrentProjects(organizationId), + getOrganizations(userId), + getProjects({ organizationId, userId }), ]); const organization = organizations.find((org) => org.id === organizationId); @@ -32,7 +34,7 @@ export default async function Page({ } if (projects.length === 0) { - return redirect('/onboarding'); + return redirect('/onboarding/project'); } if (projects.length === 1 && projects[0]) { diff --git a/apps/dashboard/src/app/(app)/page.tsx b/apps/dashboard/src/app/(app)/page.tsx index 4525fd9d..d3228445 100644 --- a/apps/dashboard/src/app/(app)/page.tsx +++ b/apps/dashboard/src/app/(app)/page.tsx @@ -1,13 +1,15 @@ import { redirect } from 'next/navigation'; -import { getCurrentOrganizations } from '@openpanel/db'; +import { auth } from '@openpanel/auth/nextjs'; +import { getOrganizations } from '@openpanel/db'; export default async function Page() { - const organizations = await getCurrentOrganizations(); + const { userId } = await auth(); + const organizations = await getOrganizations(userId); if (organizations.length > 0) { return redirect(`/${organizations[0]?.id}`); } - return redirect('/onboarding'); + return redirect('/onboarding/project'); } diff --git a/apps/dashboard/src/app/(auth)/layout.tsx b/apps/dashboard/src/app/(auth)/layout.tsx index 2e43dea0..c1c2128a 100644 --- a/apps/dashboard/src/app/(auth)/layout.tsx +++ b/apps/dashboard/src/app/(auth)/layout.tsx @@ -12,7 +12,7 @@ const Page = ({ children }: Props) => {
-
{children}
+
{children}
diff --git a/apps/dashboard/src/app/(auth)/live-events/live-events.tsx b/apps/dashboard/src/app/(auth)/live-events/live-events.tsx index 7e4b581a..668b2d56 100644 --- a/apps/dashboard/src/app/(auth)/live-events/live-events.tsx +++ b/apps/dashboard/src/app/(auth)/live-events/live-events.tsx @@ -102,7 +102,7 @@ const useWebEventGenerator = () => { function createNewEvent() { const newEvent = generateEvent(); setEvents((prevEvents) => [newEvent, ...prevEvents]); - timer = setTimeout(() => createNewEvent(), Math.random() * 1000); + timer = setTimeout(() => createNewEvent(), Math.random() * 3000); } createNewEvent(); diff --git a/apps/dashboard/src/app/(auth)/login/[[...login]]/page.tsx b/apps/dashboard/src/app/(auth)/login/[[...login]]/page.tsx deleted file mode 100644 index 33d59d16..00000000 --- a/apps/dashboard/src/app/(auth)/login/[[...login]]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SignIn } from '@clerk/nextjs'; - -export default function Page() { - return ( -
- -
- ); -} diff --git a/apps/dashboard/src/app/(auth)/login/page.tsx b/apps/dashboard/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..77c5707e --- /dev/null +++ b/apps/dashboard/src/app/(auth)/login/page.tsx @@ -0,0 +1,33 @@ +import { Or } from '@/components/auth/or'; +import { SignInEmailForm } from '@/components/auth/sign-in-email-form'; +import { SignInGithub } from '@/components/auth/sign-in-github'; +import { SignInGoogle } from '@/components/auth/sign-in-google'; +import { LinkButton } from '@/components/ui/button'; +import { auth } from '@openpanel/auth/nextjs'; +import { redirect } from 'next/navigation'; + +export default async function Page() { + const session = await auth(); + + if (session.userId) { + return redirect('/'); + } + + return ( +
+
+
+ + +
+ +
+ +
+ + No account? Sign up today + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(auth)/register/[[...register]]/page.tsx b/apps/dashboard/src/app/(auth)/register/[[...register]]/page.tsx deleted file mode 100644 index c19dfc66..00000000 --- a/apps/dashboard/src/app/(auth)/register/[[...register]]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SignUp } from '@clerk/nextjs'; - -export default function Page() { - return ( -
- -
- ); -} diff --git a/apps/dashboard/src/app/(auth)/reset-password/page.tsx b/apps/dashboard/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..84f47245 --- /dev/null +++ b/apps/dashboard/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,17 @@ +import { ResetPasswordForm } from '@/components/auth/reset-password-form'; +import { auth } from '@openpanel/auth/nextjs'; +import { redirect } from 'next/navigation'; + +export default async function Page() { + const session = await auth(); + + if (session.userId) { + return redirect('/'); + } + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(auth)/sso-callback/page.tsx b/apps/dashboard/src/app/(auth)/sso-callback/page.tsx deleted file mode 100644 index 3a38e6b5..00000000 --- a/apps/dashboard/src/app/(auth)/sso-callback/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'; - -export const dynamic = 'force-dynamic'; - -const SSOCallback = () => { - return ; -}; - -export default SSOCallback; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx b/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx index ef4a5cba..7d328940 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx @@ -11,7 +11,12 @@ export const OnboardingDescription = ({ children, className, }: Pick) => ( -
+
{children}
); diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx index 6a76ad71..9569c2fb 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx @@ -1,5 +1,6 @@ -import { getCurrentOrganizations, getProjectWithClients } from '@openpanel/db'; +import { getOrganizations, getProjectWithClients } from '@openpanel/db'; +import { auth } from '@openpanel/auth/nextjs'; import OnboardingConnect from './onboarding-connect'; type Props = { @@ -9,7 +10,8 @@ type Props = { }; const Connect = async ({ params: { projectId } }: Props) => { - const orgs = await getCurrentOrganizations(); + const { userId } = await auth(); + const orgs = await getOrganizations(userId); const organizationId = orgs[0]?.id; if (!organizationId) { throw new Error('No organization found'); diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx index 9ddd091d..95749aa0 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx @@ -33,13 +33,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => { const isConnected = events.length > 0; - const renderBadge = () => { - if (isConnected) { - return Connected; - } - - return Not connected; - }; const renderIcon = () => { if (isConnected) { return ( @@ -61,9 +54,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
{client?.name}
-
- Connection status: {renderBadge()} -
{ > {renderIcon()}
-
+
{isConnected ? 'Success' : 'Waiting for events'}
{isConnected ? ( diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx index 28da18ad..4caaf0a8 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx @@ -4,10 +4,24 @@ import { ButtonContainer } from '@/components/button-container'; import { LinkButton } from '@/components/ui/button'; import { cn } from '@/utils/cn'; import Link from 'next/link'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; -import type { IServiceEvent, IServiceProjectWithClients } from '@openpanel/db'; +import type { + IServiceClient, + IServiceEvent, + IServiceProjectWithClients, +} from '@openpanel/db'; +import Syntax from '@/components/syntax'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { useClientSecret } from '@/hooks/useClientSecret'; +import { clipboard } from '@/utils/clipboard'; +import { local } from 'd3'; import OnboardingLayout, { OnboardingDescription, } from '../../../onboarding-layout'; @@ -36,29 +50,15 @@ const Verify = ({ project, events }: Props) => { } > - {/* - Sadly we cant have a verify for each type since we use the same client for all different types (website, app, backend) - - Pros: the user just need to keep track of one client id/secret - Cons: we cant verify each type individually - - Might be a good idea to add a verify for each type in the future, but for now we will just have one verify for all types - - {project.types.map((type) => { - const Component = { - website: VerifyWeb, - app: VerifyApp, - backend: VerifyBackend, - }[type]; - - return ; - })} */} + + + { )} { }; export default Verify; + +function CurlPreview({ project }: { project: IServiceProjectWithClients }) { + const [secret] = useClientSecret(); + const client = project.clients[0]; + if (!client) { + return null; + } + + const code = `curl -X POST ${process.env.NEXT_PUBLIC_API_URL}/track \\ +-H "Content-Type: application/json" \\ +-H "openpanel-client-id: ${client.id}" \\ +-H "openpanel-client-secret: ${secret}" \\ +-H "User-Agent: ${window.navigator.userAgent}" \\ +-d '{ + "type": "track", + "payload": { + "name": "screen_view", + "properties": { + "__title": "Testing OpenPanel - ${project.name}", + "__path": "${project.domain}", + "__referrer": "${process.env.NEXT_PUBLIC_DASHBOARD_URL}" + } + } +}'`; + + return ( +
+ + + { + clipboard(code, null); + }} + > + Try out the curl command + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx index 422028c4..aea11e0c 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx @@ -3,11 +3,12 @@ import { escape } from 'sqlstring'; import { TABLE_NAMES, - getCurrentOrganizations, getEvents, + getOrganizations, getProjectWithClients, } from '@openpanel/db'; +import { auth } from '@openpanel/auth/nextjs'; import OnboardingVerify from './onboarding-verify'; type Props = { @@ -17,7 +18,8 @@ type Props = { }; const Verify = async ({ params: { projectId } }: Props) => { - const orgs = await getCurrentOrganizations(); + const { userId } = await auth(); + const orgs = await getOrganizations(userId); const organizationId = orgs[0]?.id; if (!organizationId) { throw new Error('No organization found'); diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx index 15e55184..b4d55130 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx @@ -1,9 +1,78 @@ -import { getCurrentOrganizations } from '@openpanel/db'; +import { Or } from '@/components/auth/or'; +import { SignInGithub } from '@/components/auth/sign-in-github'; +import { SignInGoogle } from '@/components/auth/sign-in-google'; +import { SignUpEmailForm } from '@/components/auth/sign-up-email-form'; +import { auth } from '@openpanel/auth/nextjs'; +import { getInviteById } from '@openpanel/db'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout'; -import OnboardingTracking from './onboarding-tracking'; +const Page = async ({ + searchParams, +}: { searchParams: { inviteId: string } }) => { + const session = await auth(); + const inviteId = await searchParams.inviteId; + const invite = inviteId ? await getInviteById(inviteId) : null; + const hasInviteExpired = invite?.expiresAt && invite.expiresAt < new Date(); + if (session.userId) { + return redirect('/'); + } -const Tracking = async () => { - return ; + return ( +
+ + Lets start with creating you account. By creating an account you + accept the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + + } + > + {invite && !hasInviteExpired && ( +
+

+ Invitation to {invite.organization.name} +

+

+ After you have created your account, you will be added to the + organization. +

+
+ )} + {invite && hasInviteExpired && ( +
+

+ Invitation to {invite.organization.name} has expired +

+

+ The invitation has expired. Please contact the organization owner + to get a new invitation. +

+
+ )} +
+ + +
+ +
+

Sign up with email

+ +
+
+
+ ); }; -export default Tracking; +export default Page; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/project/onboarding-create-project.tsx similarity index 98% rename from apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx rename to apps/dashboard/src/app/(onboarding)/onboarding/project/onboarding-create-project.tsx index db62506c..a65bc44a 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/project/onboarding-create-project.tsx @@ -26,11 +26,13 @@ import type { z } from 'zod'; import type { IServiceOrganization } from '@openpanel/db'; import { zOnboardingProject } from '@openpanel/validation'; -import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout'; +import OnboardingLayout, { + OnboardingDescription, +} from '../../onboarding-layout'; type IForm = z.infer; -const Tracking = ({ +export const OnboardingCreateProject = ({ organizations, }: { organizations: IServiceOrganization[]; @@ -260,5 +262,3 @@ const Tracking = ({ ); }; - -export default Tracking; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/project/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/project/page.tsx new file mode 100644 index 00000000..d25c25db --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/project/page.tsx @@ -0,0 +1,11 @@ +import { auth } from '@openpanel/auth/nextjs'; +import { getOrganizations } from '@openpanel/db'; +import { OnboardingCreateProject } from './onboarding-create-project'; + +const Page = async () => { + const { userId } = await auth(); + const organizations = await getOrganizations(userId); + return ; +}; + +export default Page; diff --git a/apps/dashboard/src/app/(onboarding)/skip-onboarding.tsx b/apps/dashboard/src/app/(onboarding)/skip-onboarding.tsx index e9f828e7..26fb7497 100644 --- a/apps/dashboard/src/app/(onboarding)/skip-onboarding.tsx +++ b/apps/dashboard/src/app/(onboarding)/skip-onboarding.tsx @@ -1,22 +1,47 @@ 'use client'; +import { useLogout } from '@/hooks/useLogout'; import { showConfirm } from '@/modals'; import { api } from '@/trpc/client'; -import { useAuth } from '@clerk/nextjs'; -import { ChevronLastIcon } from 'lucide-react'; -import { usePathname, useRouter } from 'next/navigation'; +import { ChevronLastIcon, LogInIcon } from 'lucide-react'; +import Link from 'next/link'; +import { + usePathname, + useRouter, + useSelectedLayoutSegments, +} from 'next/navigation'; import { useEffect } from 'react'; +const PUBLIC_SEGMENTS = [['onboarding']]; + const SkipOnboarding = () => { const router = useRouter(); const pathname = usePathname(); - const res = api.onboarding.skipOnboardingCheck.useQuery(); - const auth = useAuth(); + const segments = useSelectedLayoutSegments(); + const isPublic = PUBLIC_SEGMENTS.some((segment) => + segments.every((s, index) => s === segment[index]), + ); + const res = api.onboarding.skipOnboardingCheck.useQuery(undefined, { + enabled: !isPublic, + }); + + const logout = useLogout(); useEffect(() => { res.refetch(); }, [pathname]); - if (!pathname.startsWith('/onboarding')) return null; + // Do not show skip onboarding for the first step (register account) + if (isPublic) { + return ( + + Login + + + ); + } return (
-
{step.name}
+
{step.name}
))}
diff --git a/apps/dashboard/src/app/api/clerk/webhook/route.ts b/apps/dashboard/src/app/api/clerk/webhook/route.ts deleted file mode 100644 index 0148b2f9..00000000 --- a/apps/dashboard/src/app/api/clerk/webhook/route.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { WebhookEvent } from '@clerk/nextjs/server'; -import { pathOr } from 'ramda'; - -import { AccessLevel, db } from '@openpanel/db'; - -export const dynamic = 'force-dynamic'; - -export async function POST(request: Request) { - const payload: WebhookEvent = await request.json(); - - if (payload.type === 'user.created') { - const email = payload.data.email_addresses[0]?.email_address; - const emails = payload.data.email_addresses.map((e) => e.email_address); - - if (!email) { - return Response.json( - { message: 'No email address found' }, - { status: 400 }, - ); - } - - const user = await db.user.create({ - data: { - id: payload.data.id, - email, - firstName: payload.data.first_name, - lastName: payload.data.last_name, - }, - }); - - const memberships = await db.member.findMany({ - where: { - email: { - in: emails, - }, - userId: null, - }, - }); - - for (const membership of memberships) { - const access = pathOr([], ['meta', 'access'], membership); - await db.$transaction([ - // Update the member to link it to the user - // This will remove the item from invitations - db.member.update({ - where: { - id: membership.id, - }, - data: { - userId: user.id, - }, - }), - db.projectAccess.createMany({ - data: access - .filter((a) => typeof a === 'string') - .map((projectId) => ({ - organizationId: membership.organizationId, - projectId: projectId, - userId: user.id, - level: AccessLevel.read, - })), - }), - ]); - } - } - - if (payload.type === 'organizationMembership.created') { - const access = payload.data.public_metadata.access; - if (Array.isArray(access)) { - await db.projectAccess.createMany({ - data: access - .filter((a): a is string => typeof a === 'string') - .map((projectId) => ({ - organizationId: payload.data.organization.slug, - projectId: projectId, - userId: payload.data.public_user_data.user_id, - level: AccessLevel.read, - })), - }); - } - } - - if (payload.type === 'user.deleted') { - await db.$transaction([ - db.user.update({ - where: { - id: payload.data.id, - }, - data: { - deletedAt: new Date(), - firstName: null, - lastName: null, - }, - }), - db.projectAccess.deleteMany({ - where: { - userId: payload.data.id, - }, - }), - db.member.deleteMany({ - where: { - userId: payload.data.id, - }, - }), - ]); - } - - if (payload.type === 'organizationMembership.deleted') { - await db.projectAccess.deleteMany({ - where: { - organizationId: payload.data.organization.slug, - userId: payload.data.public_user_data.user_id, - }, - }); - } - - return Response.json({ message: 'Webhook received!' }); -} - -export function GET() { - return Response.json({ message: 'Hello World!' }); -} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 78d16742..356f4987 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -29,7 +29,7 @@ export default function RootLayout({ new QueryClient({ @@ -44,15 +42,6 @@ function AllProviders({ children }: { children: React.ReactNode }) { mode: 'cors', }); }, - async headers() { - const token = await getToken(); - if (token) { - return { - Authorization: `Bearer ${token}`, - }; - } - return {}; - }, }), ], }), @@ -97,9 +86,5 @@ function AllProviders({ children }: { children: React.ReactNode }) { } export default function Providers({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); + return {children}; } diff --git a/apps/dashboard/src/components/auth/or.tsx b/apps/dashboard/src/components/auth/or.tsx new file mode 100644 index 00000000..c6bd2450 --- /dev/null +++ b/apps/dashboard/src/components/auth/or.tsx @@ -0,0 +1,11 @@ +import { cn } from '@/utils/cn'; + +export function Or({ className }: { className?: string }) { + return ( +
+
+ OR +
+
+ ); +} diff --git a/apps/dashboard/src/components/auth/reset-password-form.tsx b/apps/dashboard/src/components/auth/reset-password-form.tsx new file mode 100644 index 00000000..d696ef3b --- /dev/null +++ b/apps/dashboard/src/components/auth/reset-password-form.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zResetPassword } from '@openpanel/validation'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +const validator = zResetPassword; +type IForm = z.infer; + +export function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get('token') ?? null; + const router = useRouter(); + const mutation = api.auth.resetPassword.useMutation({ + onSuccess() { + toast.success('Password reset successfully', { + description: 'You can now login with your new password', + }); + router.push('/login'); + }, + onError(error) { + toast.error(error.message); + }, + }); + + const form = useForm({ + resolver: zodResolver(validator), + defaultValues: { + token: token ?? '', + password: '', + }, + }); + + const onSubmit = form.handleSubmit(async (data) => { + mutation.mutate(data); + }); + + return ( +
+
+ + + +
+ ); +} diff --git a/apps/dashboard/src/components/auth/sign-in-email-form.tsx b/apps/dashboard/src/components/auth/sign-in-email-form.tsx new file mode 100644 index 00000000..60f8b7e3 --- /dev/null +++ b/apps/dashboard/src/components/auth/sign-in-email-form.tsx @@ -0,0 +1,67 @@ +'use client'; +import { pushModal } from '@/modals'; +import { api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zSignInEmail } from '@openpanel/validation'; +import { useRouter } from 'next/navigation'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; +import { InputWithLabel } from '../forms/input-with-label'; +import { Button } from '../ui/button'; + +const validator = zSignInEmail; +type IForm = z.infer; + +export function SignInEmailForm() { + const router = useRouter(); + const mutation = api.auth.signInEmail.useMutation({ + onSuccess(res) { + toast.success('Successfully signed in'); + router.push('/'); + }, + onError(error) { + toast.error(error.message); + }, + }); + const form = useForm({ + resolver: zodResolver(validator), + }); + const onSubmit: SubmitHandler = (values) => { + mutation.mutate({ + ...values, + }); + }; + + return ( +
console.log(err))} + className="col gap-6" + > +

Sign in with email

+ + + + + + ); +} diff --git a/apps/dashboard/src/components/auth/sign-in-github.tsx b/apps/dashboard/src/components/auth/sign-in-github.tsx new file mode 100644 index 00000000..670ead3a --- /dev/null +++ b/apps/dashboard/src/components/auth/sign-in-github.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { api } from '@/trpc/client'; +import { useSearchParams } from 'next/navigation'; +import { Button } from '../ui/button'; + +export function SignInGithub({ type }: { type: 'sign-in' | 'sign-up' }) { + const searchParams = useSearchParams(); + const inviteId = searchParams.get('inviteId'); + const mutation = api.auth.signInOAuth.useMutation({ + onSuccess(res) { + if (res.url) { + window.location.href = res.url; + } + }, + }); + const title = () => { + if (type === 'sign-in') return 'Sign in with Github'; + if (type === 'sign-up') return 'Sign up with Github'; + }; + return ( + + ); +} diff --git a/apps/dashboard/src/components/auth/sign-in-google.tsx b/apps/dashboard/src/components/auth/sign-in-google.tsx new file mode 100644 index 00000000..530f6fa1 --- /dev/null +++ b/apps/dashboard/src/components/auth/sign-in-google.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { api } from '@/trpc/client'; +import { useSearchParams } from 'next/navigation'; +import { Button } from '../ui/button'; + +export function SignInGoogle({ type }: { type: 'sign-in' | 'sign-up' }) { + const searchParams = useSearchParams(); + const inviteId = searchParams.get('inviteId'); + const mutation = api.auth.signInOAuth.useMutation({ + onSuccess(res) { + if (res.url) { + window.location.href = res.url; + } + }, + }); + const title = () => { + if (type === 'sign-in') return 'Sign in with Google'; + if (type === 'sign-up') return 'Sign up with Google'; + }; + return ( + + ); +} diff --git a/apps/dashboard/src/components/auth/sign-up-email-form.tsx b/apps/dashboard/src/components/auth/sign-up-email-form.tsx new file mode 100644 index 00000000..738a2904 --- /dev/null +++ b/apps/dashboard/src/components/auth/sign-up-email-form.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zSignUpEmail } from '@openpanel/validation'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; +import { InputWithLabel } from '../forms/input-with-label'; +import { Button } from '../ui/button'; + +const validator = zSignUpEmail; +type IForm = z.infer; + +export function SignUpEmailForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const mutation = api.auth.signUpEmail.useMutation({ + onSuccess(res) { + toast.success('Successfully signed up'); + router.push('/'); + }, + }); + const form = useForm({ + resolver: zodResolver(validator), + }); + const onSubmit: SubmitHandler = (values) => { + mutation.mutate({ + ...values, + inviteId: searchParams.get('inviteId'), + }); + }; + return ( +
+
+ + +
+ +
+ + +
+
+
+ +
+ + ); +} diff --git a/apps/dashboard/src/components/forms/input-with-label.tsx b/apps/dashboard/src/components/forms/input-with-label.tsx index 9ef8ad1b..937086da 100644 --- a/apps/dashboard/src/components/forms/input-with-label.tsx +++ b/apps/dashboard/src/components/forms/input-with-label.tsx @@ -37,8 +37,13 @@ export const WithLabel = ({ )} {error && ( - -
+ +
Issues
diff --git a/apps/dashboard/src/components/settings-toggle.tsx b/apps/dashboard/src/components/settings-toggle.tsx index 21ee635f..2bf8a7fd 100644 --- a/apps/dashboard/src/components/settings-toggle.tsx +++ b/apps/dashboard/src/components/settings-toggle.tsx @@ -18,7 +18,9 @@ import { useTheme } from 'next-themes'; import * as React from 'react'; import { useAppParams } from '@/hooks/useAppParams'; -import { useAuth } from '@clerk/nextjs'; +import { useLogout } from '@/hooks/useLogout'; +import { api } from '@/trpc/client'; +import { useRouter } from 'next/navigation'; import { ProjectLink } from './links'; interface Props { @@ -26,9 +28,10 @@ interface Props { } export default function SettingsToggle({ className }: Props) { + const router = useRouter(); const { setTheme, theme } = useTheme(); const { projectId } = useAppParams(); - const auth = useAuth(); + const logout = useLogout(); return ( @@ -101,12 +104,7 @@ export default function SettingsToggle({ className }: Props) { - { - auth.signOut(); - }} - > + logout()}> Logout diff --git a/apps/dashboard/src/components/settings/invites/columns.tsx b/apps/dashboard/src/components/settings/invites/columns.tsx index eca354c8..34c11694 100644 --- a/apps/dashboard/src/components/settings/invites/columns.tsx +++ b/apps/dashboard/src/components/settings/invites/columns.tsx @@ -44,10 +44,10 @@ export function useColumns( ), }, { - accessorKey: 'access', + accessorKey: 'projectAccess', header: 'Access', cell: ({ row }) => { - const access = pathOr([], ['meta', 'access'], row.original); + const access = row.original.projectAccess; return ( <> {access.map((id) => { @@ -102,7 +102,7 @@ function ActionCell({ row }: { row: Row }) { { - revoke.mutate({ memberId: row.original.id }); + revoke.mutate({ inviteId: row.original.id }); }} > Revoke invite diff --git a/apps/dashboard/src/components/settings/members/columns.tsx b/apps/dashboard/src/components/settings/members/columns.tsx index 2cb16848..642523c5 100644 --- a/apps/dashboard/src/components/settings/members/columns.tsx +++ b/apps/dashboard/src/components/settings/members/columns.tsx @@ -15,6 +15,7 @@ import { useRef, useState } from 'react'; import { toast } from 'sonner'; import { ACTIONS } from '@/components/data-table'; +import { useAuth } from '@/hooks/useAuth'; import type { IServiceMember, IServiceProject } from '@openpanel/db'; export function useColumns(projects: IServiceProject[]) { @@ -77,6 +78,8 @@ function AccessCell({ row: Row; projects: IServiceProject[]; }) { + const auth = useAuth(); + const currentUserId = auth.data?.userId; const initial = useRef(row.original.access.map((item) => item.projectId)); const [access, setAccess] = useState( row.original.access.map((item) => item.projectId), @@ -88,6 +91,16 @@ function AccessCell({ }, }); + if (auth.isLoading) { + return null; + } + + if (currentUserId === row.original.userId) { + return ( +
Can't change your own access
+ ); + } + return ( { + const logout = useLogout(); return ( - - - + ); }; diff --git a/apps/dashboard/src/components/syntax.tsx b/apps/dashboard/src/components/syntax.tsx index d243199b..cd908d45 100644 --- a/apps/dashboard/src/components/syntax.tsx +++ b/apps/dashboard/src/components/syntax.tsx @@ -17,23 +17,24 @@ export default function Syntax({ code }: SyntaxProps) {
{code} diff --git a/apps/dashboard/src/components/ui/sheet.tsx b/apps/dashboard/src/components/ui/sheet.tsx index fd19f541..c86c949c 100644 --- a/apps/dashboard/src/components/ui/sheet.tsx +++ b/apps/dashboard/src/components/ui/sheet.tsx @@ -87,7 +87,7 @@ const SheetHeader = ({ }: React.HTMLAttributes) => (
void; side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; delayDuration?: number; sideOffset?: number; disabled?: boolean; @@ -56,6 +57,7 @@ export function Tooltiper({ delayDuration = 0, sideOffset = 10, disabled = false, + align, }: TooltiperProps) { if (disabled) return children; return ( @@ -68,6 +70,7 @@ export function Tooltiper({ sideOffset={sideOffset} side={side} className={tooltipClassName} + align={align} > {content} diff --git a/apps/dashboard/src/hooks/useAuth.tsx b/apps/dashboard/src/hooks/useAuth.tsx new file mode 100644 index 00000000..43504add --- /dev/null +++ b/apps/dashboard/src/hooks/useAuth.tsx @@ -0,0 +1,5 @@ +import { api } from '@/trpc/client'; + +export function useAuth() { + return api.auth.session.useQuery(); +} diff --git a/apps/dashboard/src/hooks/useLogout.ts b/apps/dashboard/src/hooks/useLogout.ts new file mode 100644 index 00000000..6b7cda6b --- /dev/null +++ b/apps/dashboard/src/hooks/useLogout.ts @@ -0,0 +1,15 @@ +import { api } from '@/trpc/client'; +import { useRouter } from 'next/navigation'; + +export function useLogout() { + const router = useRouter(); + const signOut = api.auth.signOut.useMutation({ + onSuccess() { + setTimeout(() => { + router.push('/login'); + }, 0); + }, + }); + + return () => signOut.mutate(); +} diff --git a/apps/dashboard/src/hooks/useWS.ts b/apps/dashboard/src/hooks/useWS.ts index 52fb7688..e5b8cfb7 100644 --- a/apps/dashboard/src/hooks/useWS.ts +++ b/apps/dashboard/src/hooks/useWS.ts @@ -1,6 +1,5 @@ 'use client'; -import { useAuth } from '@clerk/nextjs'; import debounce from 'lodash.debounce'; import { use, useEffect, useMemo, useState } from 'react'; import useWebSocket from 'react-use-websocket'; @@ -18,19 +17,10 @@ export default function useWS( onMessage: (event: T) => void, options?: UseWSOptions, ) { - const auth = useAuth(); const ws = String(process.env.NEXT_PUBLIC_API_URL) .replace(/^https/, 'wss') .replace(/^http/, 'ws'); const [baseUrl, setBaseUrl] = useState(`${ws}${path}`); - const [token, setToken] = useState(null); - const socketUrl = useMemo(() => { - const parseUrl = new URL(baseUrl); - if (token) { - parseUrl.searchParams.set('token', token); - } - return parseUrl.toString(); - }, [baseUrl, token]); const debouncedOnMessage = useMemo(() => { if (options?.debounce) { @@ -39,18 +29,12 @@ export default function useWS( return onMessage; }, [options?.debounce?.delay]); - useEffect(() => { - if (auth.isSignedIn) { - auth.getToken().then(setToken); - } - }, [auth]); - useEffect(() => { if (baseUrl === `${ws}${path}`) return; setBaseUrl(`${ws}${path}`); }, [path, baseUrl, ws]); - useWebSocket(socketUrl, { + useWebSocket(baseUrl, { shouldReconnect: () => true, onMessage(event) { try { diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 2d5a80fd..fc7c9acb 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -1,30 +1,80 @@ -import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; -import { NextResponse } from 'next/server'; +import { COOKIE_MAX_AGE, COOKIE_OPTIONS } from '@openpanel/auth/constants'; +import { type NextRequest, NextResponse } from 'next/server'; + +function createRouteMatcher(patterns: string[]) { + // Convert route patterns to regex patterns + const regexPatterns = patterns.map((pattern) => { + // Replace route parameters (:id) with regex capture groups + const regexPattern = pattern + .replace(/\//g, '\\/') // Escape forward slashes + .replace(/:\w+/g, '([^/]+)') // Convert :param to capture groups + .replace(/\(\.\*\)\?/g, '(?:.*)?'); // Handle optional wildcards + + return new RegExp(`^${regexPattern}$`); + }); + + // Return a matcher function + return (req: { url: string }) => { + const pathname = new URL(req.url).pathname; + return regexPatterns.some((regex) => regex.test(pathname)); + }; +} // This example protects all routes including api/trpc routes // Please edit this to allow other routes to be public as needed. -// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware const isPublicRoute = createRouteMatcher([ '/share/overview/:id', - '/api/clerk/(.*)?', '/login(.*)?', + '/reset-password(.*)?', '/register(.*)?', '/sso-callback(.*)?', + '/onboarding', ]); -export default clerkMiddleware( - (auth, req) => { - if (process.env.MAINTENANCE_MODE && !req.url.includes('/maintenance')) { - return NextResponse.redirect(new URL('/maintenance', req.url), 307); +export default (request: NextRequest) => { + if (request.method === 'GET') { + const response = NextResponse.next(); + const token = request.cookies.get('session')?.value ?? null; + + if (!isPublicRoute(request) && token === null) { + return NextResponse.redirect(new URL('/login', request.url)); } - if (!isPublicRoute(req)) { - auth().protect(); + + if (token !== null) { + // Only extend cookie expiration on GET requests since we can be sure + // a new session wasn't set when handling the request. + response.cookies.set('session', token, { + maxAge: COOKIE_MAX_AGE, + ...COOKIE_OPTIONS, + }); } - }, - { - debug: !!process.env.CLERK_DEBUG, - }, -); + return response; + } + + const originHeader = request.headers.get('Origin'); + // NOTE: You may need to use `X-Forwarded-Host` instead + const hostHeader = request.headers.get('Host'); + if (originHeader === null || hostHeader === null) { + return new NextResponse(null, { + status: 403, + }); + } + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + return new NextResponse(null, { + status: 403, + }); + } + if (origin.host !== hostHeader) { + return new NextResponse(null, { + status: 403, + }); + } + + return NextResponse.next(); +}; export const config = { matcher: [ diff --git a/apps/dashboard/src/modals/VerifyEmail.tsx b/apps/dashboard/src/modals/VerifyEmail.tsx deleted file mode 100644 index 6d39f38d..00000000 --- a/apps/dashboard/src/modals/VerifyEmail.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from '@/components/ui/input-otp'; -import { getClerkError } from '@/utils/clerk-error'; -import { useSignUp } from '@clerk/nextjs'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; - -import { popModal } from '.'; -import { ModalContent, ModalHeader } from './Modal/Container'; - -type Props = { - email: string; -}; - -export default function VerifyEmail({ email }: Props) { - const { signUp, setActive, isLoaded } = useSignUp(); - const router = useRouter(); - const [code, setCode] = useState(''); - - return ( - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > - - Please enter the verification code sent to your{' '} - {email}. -

- } - /> - - { - if (!isLoaded) { - return toast.info('Sign up is not available at the moment'); - } - - try { - const completeSignUp = await signUp.attemptEmailAddressVerification( - { - code, - }, - ); - - if (completeSignUp.status !== 'complete') { - // The status can also be `abandoned` or `missing_requirements` - // Please see https://clerk.com/docs/references/react/use-sign-up#result-status for more information - return toast.error('Invalid code'); - } - - // Check the status to see if it is complete - // If complete, the user has been created -- set the session active - if (completeSignUp.status === 'complete') { - await setActive({ session: completeSignUp.createdSessionId }); - router.push('/onboarding'); - popModal(); - } - } catch (e) { - const error = getClerkError(e); - if (error) { - toast.error(error.longMessage); - } else { - toast.error('An error occurred, please try again later'); - } - } - }} - > - - - - - - - - - -
- ); -} diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index ef74fcae..1ac6fe65 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -14,6 +14,9 @@ const Loading = () => ( ); const modals = { + RequestPasswordReset: dynamic(() => import('./request-reset-password'), { + loading: Loading, + }), EditEvent: dynamic(() => import('./edit-event'), { loading: Loading, }), @@ -56,9 +59,6 @@ const modals = { OnboardingTroubleshoot: dynamic(() => import('./OnboardingTroubleshoot'), { loading: Loading, }), - VerifyEmail: dynamic(() => import('./VerifyEmail'), { - loading: Loading, - }), DateRangerPicker: dynamic(() => import('./DateRangerPicker'), { loading: Loading, }), diff --git a/apps/dashboard/src/modals/request-reset-password.tsx b/apps/dashboard/src/modals/request-reset-password.tsx new file mode 100644 index 00000000..cc8c8417 --- /dev/null +++ b/apps/dashboard/src/modals/request-reset-password.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { DialogFooter } from '@/components/ui/dialog'; +import { api, handleError } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { SendIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { zRequestResetPassword } from '@openpanel/validation'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validation = zRequestResetPassword; +type IForm = z.infer; + +type Props = { + email?: string; +}; + +export default function RequestPasswordReset({ email }: Props) { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(validation), + defaultValues: { + email: email ?? '', + }, + }); + const mutation = api.auth.requestResetPassword.useMutation({ + onError: handleError, + onSuccess() { + toast.success('You should receive an email shortly!'); + popModal(); + }, + }); + + const onSubmit = form.handleSubmit((values) => { + mutation.mutate({ + email: values.email, + }); + }); + + return ( + + +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/styles/globals.css b/apps/dashboard/src/styles/globals.css index 9f5ca539..d1fc8edd 100644 --- a/apps/dashboard/src/styles/globals.css +++ b/apps/dashboard/src/styles/globals.css @@ -43,6 +43,27 @@ --radius: 0.5rem; } + .prose { + --tw-prose-body: #374151; + --tw-prose-headings: #111827; + --tw-prose-lead: #4b5563; + --tw-prose-links: #2563eb; + --tw-prose-bold: #111827; + --tw-prose-counters: #6b7280; + --tw-prose-bullets: #d1d5db; + --tw-prose-hr: #e5e7eb; + --tw-prose-quotes: #111827; + --tw-prose-quote-borders: #e5e7eb; + --tw-prose-captions: #6b7280; + --tw-prose-kbd: #111827; + --tw-prose-kbd-shadows: 17 24 39; + --tw-prose-code: #111827; + --tw-prose-pre-code: #e5e7eb; + --tw-prose-pre-bg: #f9fafb; + --tw-prose-th-borders: #d1d5db; + --tw-prose-td-borders: #e5e7eb; + } + .dark { --highlight: 221.44 100% 62.04%; @@ -80,6 +101,27 @@ --input: 0 0% 15.1%; /* #262626 */ --ring: 0 0% 83.9%; /* #d6d6d6 */ } + + .dark .prose { + --tw-prose-body: #e5e7eb; + --tw-prose-headings: #f3f4f6; + --tw-prose-lead: #9ca3af; + --tw-prose-links: #60a5fa; + --tw-prose-bold: #f3f4f6; + --tw-prose-counters: #9ca3af; + --tw-prose-bullets: #6b7280; + --tw-prose-hr: #4b5563; + --tw-prose-quotes: #f3f4f6; + --tw-prose-quote-borders: #4b5563; + --tw-prose-captions: #9ca3af; + --tw-prose-kbd: #f3f4f6; + --tw-prose-kbd-shadows: 255 255 255; + --tw-prose-code: #f3f4f6; + --tw-prose-pre-code: #d1d5db; + --tw-prose-pre-bg: #1f2937; + --tw-prose-th-borders: #4b5563; + --tw-prose-td-borders: #374151; + } } @layer base { diff --git a/apps/dashboard/src/utils/clerk-error.ts b/apps/dashboard/src/utils/clerk-error.ts deleted file mode 100644 index 57607728..00000000 --- a/apps/dashboard/src/utils/clerk-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -interface ClerkError extends Error { - longMessage: string; -} - -export function getClerkError(e: unknown): ClerkError | null { - if (e && typeof e === 'object' && 'errors' in e && Array.isArray(e.errors)) { - const error = e.errors[0]; - if ('longMessage' in error && typeof error.longMessage === 'string') { - return error as ClerkError; - } - } - - return null; -} diff --git a/apps/dashboard/src/utils/clipboard.ts b/apps/dashboard/src/utils/clipboard.ts index 02bf3ba8..51396a0a 100644 --- a/apps/dashboard/src/utils/clipboard.ts +++ b/apps/dashboard/src/utils/clipboard.ts @@ -1,8 +1,13 @@ import { toast } from 'sonner'; -export function clipboard(value: string | number) { +export function clipboard(value: string | number, description?: null | string) { navigator.clipboard.writeText(value.toString()); - toast('Copied to clipboard', { - description: value.toString(), - }); + toast( + 'Copied to clipboard', + description !== null + ? { + description: description ?? value.toString(), + } + : {}, + ); } diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js index 5a028b5f..c227b33f 100644 --- a/apps/dashboard/tailwind.config.js +++ b/apps/dashboard/tailwind.config.js @@ -173,6 +173,7 @@ const config = { plugins: [ require('@tailwindcss/container-queries'), require('tailwindcss-animate'), + require('@tailwindcss/typography'), ], }; diff --git a/apps/public/content/docs/self-hosting/meta.json b/apps/public/content/docs/self-hosting/meta.json new file mode 100644 index 00000000..0fcf0262 --- /dev/null +++ b/apps/public/content/docs/self-hosting/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Self-hosting", + "defaultOpen": true, + "pages": ["self-hosting", "migrating-from-clerk"] +} diff --git a/apps/public/content/docs/self-hosting/migrating-from-clerk.mdx b/apps/public/content/docs/self-hosting/migrating-from-clerk.mdx new file mode 100644 index 00000000..ad7d48ba --- /dev/null +++ b/apps/public/content/docs/self-hosting/migrating-from-clerk.mdx @@ -0,0 +1,53 @@ +--- +title: Migrating from Clerk +description: This is a simple guide how to migrate from Clerk to OpenPanel. +--- + +import { Step, Steps } from 'fumadocs-ui/components/steps'; + +As of version 0.0.5, we have removed Clerk.com from OpenPanel. This means that if you are upgrading from a previous version, you will need to export your users from Clerk and import them into OpenPanel. Here is how you can do it. + +Before we start lets get the users from Clerk. Go to **Clerk > Configure > Settings > Export all users** and download the CSV file. This file will be used to import the users into OpenPanel. + + + + + +Copy the csv file we downloaded from Clerk to your server: +```bash +scp ./path/to/your/clerk-users.csv user@your-ip:users-dump.csv +``` + + +SSH into your server: +```bash +ssh user@your-ip +``` + + +Pull the latest images, and restart the containers: +```bash +docker compose pull +docker compose down +docker compose up -d +``` + + +SSH into your server: +```bash +ssh user@your-ip +``` + + +Run the following command to copy the file to the OpenPanel container: +```bash +docker compose cp ./users-dump.csv op-api:/app/packages/db/code-migrations/users-dump.csv +``` + + +Run the migration: +```bash +docker compose exec -it op-api bash -c "cd /app/packages/db && pnpm migrate:deploy:db:code 2-accounts.ts" +``` + + diff --git a/apps/public/content/docs/self-hosting/self-hosting.mdx b/apps/public/content/docs/self-hosting/self-hosting.mdx index 083c578e..67277ccc 100644 --- a/apps/public/content/docs/self-hosting/self-hosting.mdx +++ b/apps/public/content/docs/self-hosting/self-hosting.mdx @@ -5,19 +5,16 @@ description: This is a simple guide how to get started with OpenPanel on your ow import { Step, Steps } from 'fumadocs-ui/components/steps'; - - OpenPanel is not stable yet. If you still want to self-host you can go ahead. Bear in mind that new changes might give a little headache to keep up with. - - +From version 0.0.5 we have removed Clerk.com. If you are upgrading from a previous version, you will need to export your users from Clerk and import them into OpenPanel. Read more about it here: [Migrating from Clerk](/docs/self-hosting/migrating-from-clerk) ## Instructions ### Prerequisites - VPS of any kind (only tested on Ubuntu 24.04) + - We recommend using [Hetzner (affiliate link)](https://hetzner.cloud/?ref=7Hq0H5mQh7tM). Use the link if you want to support us. 🫶 - 🙋‍♂️ This should work on any system if you have pre-installed docker, node and pnpm -- [Clerk.com](https://clerk.com) account (they have a free tier) ### Quickstart @@ -28,6 +25,7 @@ git clone https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hostin + ### Clone Clone the repository to your VPS @@ -35,7 +33,8 @@ Clone the repository to your VPS ```bash git clone https://github.com/Openpanel-dev/openpanel.git ``` - + + ### Run the setup script The setup script will do 3 things @@ -43,9 +42,8 @@ The setup script will do 3 things 1. Install node (if you accept) 2. Install docker (if you accept) 3. Execute a node script that will ask some questions about your setup -4. After this is done you'll need to point a webhook inside Clerk (https://your-domain.com/api/webhook/clerk) -> Setup takes 1-2 minutes depending on your VPS +> Setup takes 30s to 2 minutes depending on your VPS ```bash cd openpanel/self-hosting @@ -59,6 +57,8 @@ cd openpanel/self-hosting 3. Install pnpm 4. Run the `npx jiti ./quiz.ts` script inside the self-hosting folder + + ### Start 🚀 Run the `./start` script located inside the self-hosting folder @@ -66,39 +66,9 @@ Run the `./start` script located inside the self-hosting folder ```bash ./start ``` + -## Clerk.com - - -Some might wonder why we use Clerk.com for authentication. The main reason for this is that Clerk have great support for iOS and Android apps. We're in the process of building an native app and we want to have a seamless experience for our users. - -**next-auth** is great, but lacks good support for mobile apps. - - -You'll need to create an account at [Clerk.com](https://clerk.com) and create a new project. You'll need the 3 keys that Clerk provides you with. - -- **Publishable key** `pk_live_xxx` -- **Secret key** `sk_live_xxx` -- **Signing secret** `"whsec_xxx"` - -### Webhooks - -You'll also need to add a webhook to your domain. We listen on some events from Clerk to keep our database in sync. - -#### URL - -- **Path**: `/api/webhook/clerk` -- **Example**: `https://your-domain.com/api/webhook/clerk` - -#### Events we listen to - -- `organizationMembership.created` -- `user.created` -- `organizationMembership.deleted` -- `user.updated` -- `user.deleted` - ## Good to know ### Always use correct api url @@ -133,6 +103,17 @@ const op = new OpenPanel({ }); ``` +### E-mail + +Some of OpenPanel's features require e-mail. We use Resend as our transactional e-mail provider. So to get this working you'll need to create an account on Resend and set the `RESEND_API_KEY` environment variable. + +This is nothing that is required for the basic setup, but it is required for some features. + +Features that require e-mail: +- Password reset +- Invitations +- more will be added over time + ### Managed Redis If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually. diff --git a/apps/public/package.json b/apps/public/package.json index 186261bd..9c91240b 100644 --- a/apps/public/package.json +++ b/apps/public/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@types/mdx": "^2.0.13", - "@types/node": "22.8.1", + "@types/node": "20.14.8", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "autoprefixer": "^10.4.20", diff --git a/apps/public/public/icons/discord.png b/apps/public/public/icons/discord.png new file mode 100644 index 00000000..03036c0b Binary files /dev/null and b/apps/public/public/icons/discord.png differ diff --git a/apps/public/public/icons/email.png b/apps/public/public/icons/email.png new file mode 100644 index 00000000..59cf503c Binary files /dev/null and b/apps/public/public/icons/email.png differ diff --git a/apps/public/public/icons/github.png b/apps/public/public/icons/github.png new file mode 100644 index 00000000..bd562b32 Binary files /dev/null and b/apps/public/public/icons/github.png differ diff --git a/apps/public/public/icons/x.png b/apps/public/public/icons/x.png new file mode 100644 index 00000000..6c5829d3 Binary files /dev/null and b/apps/public/public/icons/x.png differ diff --git a/docker-compose.yml b/docker-compose.yml index adf1ee26..9f2db7cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: image: postgres:14-alpine restart: always volumes: - - ./tmp/op-db-data:/var/lib/postgresql/data + - ./docker/data/op-db-data:/var/lib/postgresql/data ports: - 5432:5432 environment: @@ -16,8 +16,8 @@ services: image: redis:7.2.5-alpine restart: always volumes: - - ./tmp/op-kv-data:/data - command: ['redis-server', '--maxmemory-policy', 'noeviction'] + - ./docker/data/op-kv-data:/data + command: [ 'redis-server', '--maxmemory-policy', 'noeviction' ] ports: - 6379:6379 @@ -31,14 +31,30 @@ services: image: clickhouse/clickhouse-server:24.3.2-alpine restart: always volumes: - - ./tmp/op-ch-data:/var/lib/clickhouse - - ./tmp/op-ch-logs:/var/log/clickhouse-server - - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml:ro - - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml:ro + - ./docker/data/op-ch-data:/var/lib/clickhouse + - ./docker/data/op-ch-logs:/var/log/clickhouse-server + - ./self-hosting/clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml + - ./self-hosting/clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml ulimits: nofile: soft: 262144 hard: 262144 ports: - - 9000:9000 - - 8123:8123 + - "8123:8123" # HTTP interface + - "9000:9000" # Native/TCP interface + - "9009:9009" # Inter-server communication + + op-zk: + image: clickhouse/clickhouse-server:24.3.2-alpine + volumes: + - ./docker/data/op-zk-data:/var/lib/clickhouse + - ./self-hosting/clickhouse/clickhouse-keeper-config.xml:/etc/clickhouse-server/config.xml + command: [ 'clickhouse-keeper', '--config-file', '/etc/clickhouse-server/config.xml' ] + restart: always + ulimits: + nofile: + soft: 262144 + hard: 262144 + ports: + - "9181:9181" # Keeper port + - "9234:9234" # Keeper Raft port diff --git a/package.json b/package.json index d56ed516..4c5bf278 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "MIT", "author": "Carl-Gerhard Lindesvärd", - "packageManager": "pnpm@8.7.6", + "packageManager": "pnpm@9.15.0", "scripts": { "dock:up": "docker compose up -d", "dock:down": "docker compose down", @@ -31,7 +31,6 @@ }, "trustedDependencies": [ "@biomejs/biome", - "@clerk/shared", "@prisma/client", "@prisma/engines", "bcrypt", diff --git a/packages/auth/constants.ts b/packages/auth/constants.ts new file mode 100644 index 00000000..d344d656 --- /dev/null +++ b/packages/auth/constants.ts @@ -0,0 +1,19 @@ +// Sorry co.uk, but you're not a top domain +const parseCookieDomain = (url: string) => { + const domain = new URL(url); + return { + domain: domain.hostname.split('.').slice(-2).join('.'), + secure: domain.protocol === 'https:', + }; +}; + +const parsed = parseCookieDomain(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? ''); + +export const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; +export const COOKIE_OPTIONS = { + domain: parsed.domain, + secure: parsed.secure, + sameSite: 'lax', + httpOnly: true, + path: '/', +} as const; diff --git a/packages/auth/index.ts b/packages/auth/index.ts new file mode 100644 index 00000000..d1628aec --- /dev/null +++ b/packages/auth/index.ts @@ -0,0 +1,2 @@ +export * from './src'; +export * from './constants'; diff --git a/packages/auth/nextjs.ts b/packages/auth/nextjs.ts new file mode 100644 index 00000000..565afcf7 --- /dev/null +++ b/packages/auth/nextjs.ts @@ -0,0 +1,10 @@ +import { unstable_cache } from 'next/cache'; +import { cookies } from 'next/headers'; +import { validateSessionToken } from './src/session'; + +export const auth = async () => { + const token = (await cookies().get('session')?.value) ?? null; + return cachedAuth(token); +}; + +export const cachedAuth = unstable_cache(validateSessionToken); diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..0e2193ae --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,27 @@ +{ + "name": "@openpanel/auth", + "version": "0.0.1", + "main": "index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "@openpanel/db": "workspace:^", + "@openpanel/validation": "workspace:^", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", + "arctic": "^2.3.0" + }, + "devDependencies": { + "@openpanel/tsconfig": "workspace:*", + "@types/node": "20.14.8", + "@types/react": "^18.2.0", + "prisma": "^5.1.1", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "next": "14.2.1", + "react": "18.2.0" + } +} diff --git a/packages/auth/server/oauth.ts b/packages/auth/server/oauth.ts new file mode 100644 index 00000000..18464f6a --- /dev/null +++ b/packages/auth/server/oauth.ts @@ -0,0 +1,18 @@ +import { GitHub } from 'arctic'; + +export type { OAuth2Tokens } from 'arctic'; +import * as Arctic from 'arctic'; + +export { Arctic }; + +export const github = new GitHub( + process.env.GITHUB_CLIENT_ID ?? '', + process.env.GITHUB_CLIENT_SECRET ?? '', + process.env.GITHUB_REDIRECT_URI ?? '', +); + +export const google = new Arctic.Google( + process.env.GOOGLE_CLIENT_ID ?? '', + process.env.GOOGLE_CLIENT_SECRET ?? '', + process.env.GOOGLE_REDIRECT_URI ?? '', +); diff --git a/packages/auth/src/cookie.ts b/packages/auth/src/cookie.ts new file mode 100644 index 00000000..dcbf4a68 --- /dev/null +++ b/packages/auth/src/cookie.ts @@ -0,0 +1,20 @@ +import type { ISetCookie } from '@openpanel/validation'; +import { COOKIE_OPTIONS } from '../constants'; + +export function setSessionTokenCookie( + setCookie: ISetCookie, + token: string, + expiresAt: Date, +): void { + setCookie('session', token, { + maxAge: expiresAt.getTime() - new Date().getTime(), + ...COOKIE_OPTIONS, + }); +} + +export function deleteSessionTokenCookie(setCookie: ISetCookie): void { + setCookie('session', '', { + maxAge: 0, + ...COOKIE_OPTIONS, + }); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..fdb955d1 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,4 @@ +export * from './cookie'; +export * from './oauth'; +export * from './password'; +export * from './session'; diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts new file mode 100644 index 00000000..18464f6a --- /dev/null +++ b/packages/auth/src/oauth.ts @@ -0,0 +1,18 @@ +import { GitHub } from 'arctic'; + +export type { OAuth2Tokens } from 'arctic'; +import * as Arctic from 'arctic'; + +export { Arctic }; + +export const github = new GitHub( + process.env.GITHUB_CLIENT_ID ?? '', + process.env.GITHUB_CLIENT_SECRET ?? '', + process.env.GITHUB_REDIRECT_URI ?? '', +); + +export const google = new Arctic.Google( + process.env.GOOGLE_CLIENT_ID ?? '', + process.env.GOOGLE_CLIENT_SECRET ?? '', + process.env.GOOGLE_REDIRECT_URI ?? '', +); diff --git a/packages/auth/src/password.ts b/packages/auth/src/password.ts new file mode 100644 index 00000000..b0180ea5 --- /dev/null +++ b/packages/auth/src/password.ts @@ -0,0 +1,41 @@ +import { hash, verify } from '@node-rs/argon2'; +import { sha1 } from '@oslojs/crypto/sha1'; +import { encodeHexLowerCase } from '@oslojs/encoding'; + +export async function hashPassword(password: string): Promise { + return await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); +} + +export async function verifyPasswordHash( + hash: string, + password: string, +): Promise { + return await verify(hash, password); +} + +export async function verifyPasswordStrength( + password: string, +): Promise { + if (password.length < 8 || password.length > 255) { + return false; + } + const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password))); + const hashPrefix = hash.slice(0, 5); + const response = await fetch( + `https://api.pwnedpasswords.com/range/${hashPrefix}`, + ); + const data = await response.text(); + const items = data.split('\n'); + for (const item of items) { + const hashSuffix = item.slice(0, 35).toLowerCase(); + if (hash === hashPrefix + hashSuffix) { + return false; + } + } + return true; +} diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts new file mode 100644 index 00000000..dd99c487 --- /dev/null +++ b/packages/auth/src/session.ts @@ -0,0 +1,83 @@ +import crypto from 'node:crypto'; +import { type Session, type User, db } from '@openpanel/db'; +import { sha256 } from '@oslojs/crypto/sha2'; +import { + encodeBase32LowerCaseNoPadding, + encodeHexLowerCase, +} from '@oslojs/encoding'; + +export function generateSessionToken(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + const token = encodeBase32LowerCaseNoPadding(bytes); + return token; +} + +export async function createSession( + token: string, + userId: string, +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + createdAt: new Date(), + updatedAt: new Date(), + }; + await db.session.create({ + data: session, + }); + return session; +} + +export const EMPTY_SESSION: SessionValidationResult = { + session: null, + user: null, + userId: null, +}; + +export async function validateSessionToken( + token: string | null, +): Promise { + if (!token) { + return EMPTY_SESSION; + } + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const result = await db.session.findUnique({ + where: { + id: sessionId, + }, + include: { + user: true, + }, + }); + if (result === null) { + return EMPTY_SESSION; + } + const { user, ...session } = result; + if (Date.now() >= session.expiresAt.getTime()) { + await db.session.delete({ where: { id: sessionId } }); + return EMPTY_SESSION; + } + if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + await db.session.update({ + where: { + id: session.id, + }, + data: { + expiresAt: session.expiresAt, + }, + }); + } + return { session, user, userId: user.id }; +} + +export async function invalidateSession(sessionId: string): Promise { + await db.session.delete({ where: { id: sessionId } }); +} + +export type SessionValidationResult = + | { session: Session; user: User; userId: string } + | { session: null; user: null; userId: null }; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000..a291eef2 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@openpanel/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["."], + "exclude": ["node_modules"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 18197094..286c8b28 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,7 @@ "@openpanel/db": "workspace:^", "@openpanel/sdk": "workspace:*", "@openpanel/tsconfig": "workspace:*", - "@types/node": "^20.14.10", + "@types/node": "20.14.8", "@types/progress": "^2.0.7", "@types/ramda": "^0.30.1", "tsup": "^7.2.0", diff --git a/packages/common/package.json b/packages/common/package.json index c3e0c276..899aede2 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@openpanel/tsconfig": "workspace:*", "@openpanel/validation": "workspace:*", - "@types/node": "^18.16.0", + "@types/node": "20.14.8", "@types/ramda": "^0.29.6", "@types/ua-parser-js": "^0.7.39", "prisma": "^5.1.1", diff --git a/packages/common/server/crypto.ts b/packages/common/server/crypto.ts index fa20c68d..d8505b14 100644 --- a/packages/common/server/crypto.ts +++ b/packages/common/server/crypto.ts @@ -49,7 +49,12 @@ export async function verifyPassword( reject(err); } // compare the new supplied password with the hashed password using timeSafeEqual - resolve(timingSafeEqual(hashKeyBuff, derivedKey)); + resolve( + timingSafeEqual( + new Uint8Array(hashKeyBuff), + new Uint8Array(derivedKey), + ), + ); }); }); } diff --git a/packages/common/server/id.ts b/packages/common/server/id.ts new file mode 100644 index 00000000..248c1d86 --- /dev/null +++ b/packages/common/server/id.ts @@ -0,0 +1,5 @@ +import { nanoid } from 'nanoid'; + +export function generateSecureId(prefix: string) { + return `${prefix}_${nanoid(18)}`; +} diff --git a/apps/api/scripts/migrate-client-settings.ts b/packages/db/code-migrations/1-settings.ts similarity index 90% rename from apps/api/scripts/migrate-client-settings.ts rename to packages/db/code-migrations/1-settings.ts index 7dc2575d..8961cdda 100644 --- a/apps/api/scripts/migrate-client-settings.ts +++ b/packages/db/code-migrations/1-settings.ts @@ -1,10 +1,11 @@ -import { stripTrailingSlash } from '@openpanel/common'; import { chQuery, db, getClientByIdCached, getProjectByIdCached, -} from '@openpanel/db'; +} from '../index'; + +import { stripTrailingSlash } from '@openpanel/common'; const pickBestDomain = (domains: string[]): string | null => { // Filter out invalid domains @@ -61,7 +62,7 @@ const pickBestDomain = (domains: string[]): string | null => { return bestDomain?.domain || null; }; -async function main() { +export const up = async () => { const projects = await db.project.findMany({ include: { clients: true, @@ -70,6 +71,14 @@ async function main() { const matches = []; for (const project of projects) { + if (project.cors.length > 0 || project.domain) { + continue; + } + + if (project.clients.length === 0) { + continue; + } + const cors = []; let crossDomain = false; for (const client of project.clients) { @@ -93,8 +102,6 @@ async function main() { if (res.length) { domain = pickBestDomain(res.map((r) => r.origin)); matches.push(domain); - } else { - console.log('No domain found for client'); } } @@ -106,19 +113,7 @@ async function main() { domain, }, }); - console.log('Updated', { - cors, - crossDomain, - domain, - }); await getProjectByIdCached.clear(project.id); } - - console.log('DONE'); - console.log('DONE'); - console.log('DONE'); - console.log('DONE'); -} - -main(); +}; diff --git a/packages/db/code-migrations/2-accounts.ts b/packages/db/code-migrations/2-accounts.ts new file mode 100644 index 00000000..1961e7ca --- /dev/null +++ b/packages/db/code-migrations/2-accounts.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { db } from '../index'; +import { printBoxMessage } from './helpers'; + +const simpleCsvParser = (csv: string): Record[] => { + const rows = csv.split('\n'); + const headers = rows[0]!.split(','); + return rows.slice(1).map((row) => + row.split(',').reduce( + (acc, curr, index) => { + acc[headers[index]!] = curr; + return acc; + }, + {} as Record, + ), + ); +}; + +async function checkFileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; // File exists + } catch (error) { + return false; // File does not exist + } +} + +export async function up() { + const accountCount = await db.account.count(); + const userCount = await db.user.count(); + if (accountCount > 0) { + printBoxMessage('⏭️ Skipping Migration ⏭️', ['Accounts already migrated']); + return; + } + + if (userCount === 0) { + printBoxMessage('⏭️ Skipping Migration ⏭️', [ + 'No users found, skipping migration', + ]); + return; + } + + const dumppath = path.join(__dirname, 'users-dump.csv'); + // check if file exists + if (!(await checkFileExists(dumppath))) { + printBoxMessage('⚠️ Missing Required File ⚠️', [ + `File not found: ${dumppath}`, + 'This file is required to run this migration', + '', + 'You can export it from:', + 'Clerk > Configure > Settings > Export all users', + ]); + throw new Error('Required users dump file not found'); + } + const csv = await fs.readFile(path.join(__dirname, 'users-dump.csv'), 'utf8'); + const data = simpleCsvParser(csv); + + for (const row of data) { + const email = + row.primary_email_address || + row.verified_email_addresses || + row.unverified_email_addresses; + + if (!email) { + continue; + } + + const user = await db.user.findUnique({ + where: { + email: String(email), + }, + }); + + if (!user) { + continue; + } + + await db.account.create({ + data: { + userId: user.id, + provider: row.password_digest ? 'email' : 'oauth', + providerId: null, + password: row.password_digest ? String(row.password_digest) : null, + }, + }); + } +} diff --git a/packages/db/code-migrations/helpers.ts b/packages/db/code-migrations/helpers.ts new file mode 100644 index 00000000..6efd2838 --- /dev/null +++ b/packages/db/code-migrations/helpers.ts @@ -0,0 +1,13 @@ +export function printBoxMessage(title: string, lines: (string | unknown)[]) { + console.log('┌──┐'); + console.log('│'); + if (title) { + console.log(`│ ${title}`); + console.log('│'); + } + lines.forEach((line) => { + console.log(`│ ${line}`); + }); + console.log('│'); + console.log('└──┘'); +} diff --git a/packages/db/code-migrations/migrate.ts b/packages/db/code-migrations/migrate.ts new file mode 100644 index 00000000..621694d4 --- /dev/null +++ b/packages/db/code-migrations/migrate.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { ch, db } from '../index'; +import { printBoxMessage } from './helpers'; + +async function migrate() { + const args = process.argv.slice(2); + const migration = args[0]; + + const migrationsDir = path.join(__dirname, '..', 'code-migrations'); + const migrations = fs.readdirSync(migrationsDir).filter((file) => { + const version = file.split('-')[0]; + return ( + !Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts') + ); + }); + + if (migration) { + await runMigration(migrationsDir, migration); + } else { + const finishedMigrations = await db.codeMigration.findMany(); + + for (const file of migrations) { + if (finishedMigrations.some((migration) => migration.name === file)) { + printBoxMessage('⏭️ Skipping Migration ⏭️', [`${file}`]); + continue; + } + + await runMigration(migrationsDir, file); + } + } + + console.log('Migrations finished'); + process.exit(0); +} + +async function runMigration(migrationsDir: string, file: string) { + printBoxMessage('⚡️ Running Migration ⚡️ ', [`${file}`]); + try { + const migration = await import(path.join(migrationsDir, file)); + await migration.up(); + await db.codeMigration.upsert({ + where: { + name: file, + }, + update: { + name: file, + }, + create: { + name: file, + }, + }); + } catch (error) { + printBoxMessage('❌ Migration Failed ❌', [ + `Error running migration ${file}:`, + error, + ]); + process.exit(1); + } +} + +migrate(); diff --git a/packages/db/migrations/20241127133914_cluster_prep.sql b/packages/db/migrations/20241127133914_cluster_prep.sql index 00b09491..4c779571 100644 --- a/packages/db/migrations/20241127133914_cluster_prep.sql +++ b/packages/db/migrations/20241127133914_cluster_prep.sql @@ -252,8 +252,9 @@ CREATE TABLE IF NOT EXISTS profile_aliases_distributed ON CLUSTER '{cluster}' AS -- +goose StatementBegin INSERT INTO events_replicated SELECT * -FROM events_v2 -- +goose StatementEnd - -- +goose StatementBegin +FROM events_v2; +-- +goose StatementEnd +-- +goose StatementBegin INSERT INTO events_bots_replicated SELECT * FROM events_bots; diff --git a/packages/db/package.json b/packages/db/package.json index 9e33a713..6c5c64aa 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,14 +6,14 @@ "goose": "pnpm with-env ./migrations/goose", "codegen": "pnpm with-env prisma generate", "migrate": "pnpm with-env prisma migrate dev", + "migrate:deploy:db:code": "pnpm with-env jiti ./code-migrations/migrate.ts", "migrate:deploy:db": "pnpm with-env prisma migrate deploy", "migrate:deploy:ch": "pnpm goose up", - "migrate:deploy": "pnpm migrate:deploy:db && pnpm migrate:deploy:ch", + "migrate:deploy": "pnpm migrate:deploy:db && pnpm migrate:deploy:db:code && pnpm migrate:deploy:ch", "typecheck": "tsc --noEmit", "with-env": "dotenv -e ../../.env -c --" }, "dependencies": { - "@clerk/nextjs": "^5.0.2", "@clickhouse/client": "^1.2.0", "@openpanel/common": "workspace:*", "@openpanel/constants": "workspace:*", @@ -22,6 +22,7 @@ "@openpanel/redis": "workspace:*", "@openpanel/validation": "workspace:*", "@prisma/client": "^5.1.1", + "jiti": "^2.4.1", "prisma-json-types-generator": "^3.1.1", "ramda": "^0.29.1", "sqlstring": "^2.3.3", @@ -30,7 +31,7 @@ }, "devDependencies": { "@openpanel/tsconfig": "workspace:*", - "@types/node": "^18.16.0", + "@types/node": "20.14.8", "@types/ramda": "^0.29.6", "@types/sqlstring": "^2.3.2", "@types/uuid": "^9.0.8", diff --git a/packages/db/prisma/migrations/20241207213908_auth/migration.sql b/packages/db/prisma/migrations/20241207213908_auth/migration.sql new file mode 100644 index 00000000..18b746e7 --- /dev/null +++ b/packages/db/prisma/migrations/20241207213908_auth/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241207215335_auth2/migration.sql b/packages/db/prisma/migrations/20241207215335_auth2/migration.sql new file mode 100644 index 00000000..d28642bb --- /dev/null +++ b/packages/db/prisma/migrations/20241207215335_auth2/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; + +-- DropTable +DROP TABLE "Session"; + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "userId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241208213543_auth_3/migration.sql b/packages/db/prisma/migrations/20241208213543_auth_3/migration.sql new file mode 100644 index 00000000..8b3868e9 --- /dev/null +++ b/packages/db/prisma/migrations/20241208213543_auth_3/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Added the required column `provider` to the `accounts` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "accounts" ADD COLUMN "provider" TEXT NOT NULL, +ALTER COLUMN "providerId" DROP NOT NULL; diff --git a/packages/db/prisma/migrations/20241209130044_invites/migration.sql b/packages/db/prisma/migrations/20241209130044_invites/migration.sql new file mode 100644 index 00000000..94024177 --- /dev/null +++ b/packages/db/prisma/migrations/20241209130044_invites/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "invites" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "projectAccess" TEXT[], + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invites_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241209131153_invite_2/migration.sql b/packages/db/prisma/migrations/20241209131153_invite_2/migration.sql new file mode 100644 index 00000000..964f4cce --- /dev/null +++ b/packages/db/prisma/migrations/20241209131153_invite_2/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `role` to the `invites` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "invites" ADD COLUMN "role" TEXT NOT NULL; diff --git a/packages/db/prisma/migrations/20241209133013_invite_3/migration.sql b/packages/db/prisma/migrations/20241209133013_invite_3/migration.sql new file mode 100644 index 00000000..675f4344 --- /dev/null +++ b/packages/db/prisma/migrations/20241209133013_invite_3/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "members" DROP CONSTRAINT "members_organizationId_fkey"; + +-- DropForeignKey +ALTER TABLE "members" DROP CONSTRAINT "members_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241209133136_invite_4/migration.sql b/packages/db/prisma/migrations/20241209133136_invite_4/migration.sql new file mode 100644 index 00000000..3b2be938 --- /dev/null +++ b/packages/db/prisma/migrations/20241209133136_invite_4/migration.sql @@ -0,0 +1,29 @@ +-- DropForeignKey +ALTER TABLE "invites" DROP CONSTRAINT "invites_organizationId_fkey"; + +-- DropForeignKey +ALTER TABLE "project_access" DROP CONSTRAINT "project_access_organizationId_fkey"; + +-- DropForeignKey +ALTER TABLE "project_access" DROP CONSTRAINT "project_access_projectId_fkey"; + +-- DropForeignKey +ALTER TABLE "project_access" DROP CONSTRAINT "project_access_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "projects" DROP CONSTRAINT "projects_organizationId_fkey"; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_access" ADD CONSTRAINT "project_access_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_access" ADD CONSTRAINT "project_access_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_access" ADD CONSTRAINT "project_access_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241210091648_reset_pw/migration.sql b/packages/db/prisma/migrations/20241210091648_reset_pw/migration.sql new file mode 100644 index 00000000..e1c3fa71 --- /dev/null +++ b/packages/db/prisma/migrations/20241210091648_reset_pw/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "reset_password" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reset_password_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db/prisma/migrations/20241210091731_reset_pw_2/migration.sql b/packages/db/prisma/migrations/20241210091731_reset_pw_2/migration.sql new file mode 100644 index 00000000..99bf5ebb --- /dev/null +++ b/packages/db/prisma/migrations/20241210091731_reset_pw_2/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `accountId` to the `reset_password` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "reset_password" ADD COLUMN "accountId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "reset_password" ADD CONSTRAINT "reset_password_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20241210143440_code_migration/migration.sql b/packages/db/prisma/migrations/20241210143440_code_migration/migration.sql new file mode 100644 index 00000000..9c689c91 --- /dev/null +++ b/packages/db/prisma/migrations/20241210143440_code_migration/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "__code_migrations" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "__code_migrations_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db/prisma/migrations/20241212214628_code_migration/migration.sql b/packages/db/prisma/migrations/20241212214628_code_migration/migration.sql new file mode 100644 index 00000000..5948e0f8 --- /dev/null +++ b/packages/db/prisma/migrations/20241212214628_code_migration/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `__code_migrations` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "__code_migrations_name_key" ON "__code_migrations"("name"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 757d243d..7e861107 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -15,6 +15,15 @@ datasource db { directUrl = env("DATABASE_URL_DIRECT") } +model CodeMigration { + id String @id @default(dbgenerated("gen_random_uuid()")) + name String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("__code_migrations") +} + enum ProjectType { website app @@ -35,6 +44,7 @@ model Organization { Dashboard Dashboard[] ShareOverview ShareOverview[] integrations Integration[] + invites Invite[] @@map("organizations") } @@ -51,21 +61,54 @@ model User { updatedAt DateTime @default(now()) @updatedAt deletedAt DateTime? ProjectAccess ProjectAccess[] + sessions Session[] + accounts Account[] + invites Invite[] @@map("users") } +model Account { + id String @id @default(dbgenerated("gen_random_uuid()")) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + providerId String? + accessToken String? + refreshToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + resetPasswords ResetPassword[] + + @@map("accounts") +} + +model Session { + id String @id + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + user User @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@map("sessions") +} + model Member { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid role String email String // userId is nullable because we want to allow invites to be sent to emails that are not registered userId String? - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) invitedById String? invitedBy User? @relation("invitedBy", fields: [invitedById], references: [id]) organizationId String - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) meta Json? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -73,10 +116,26 @@ model Member { @@map("members") } +model Invite { + id String @id + email String + createdBy User @relation(fields: [createdById], references: [id]) + createdById String + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + projectAccess String[] + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + role String + + @@map("invites") +} + model Project { id String @id @default(dbgenerated("gen_random_uuid()")) name String - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String eventsCount Int @default(0) types ProjectType[] @default([]) @@ -114,11 +173,11 @@ enum AccessLevel { model ProjectAccess { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid projectId String - project Project @relation(fields: [projectId], references: [id]) - organization Organization @relation(fields: [organizationId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) level AccessLevel createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -369,3 +428,14 @@ model Integration { @@map("integrations") } + +model ResetPassword { + id String @id + accountId String + account Account @relation(fields: [accountId], references: [id]) + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("reset_password") +} diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index 3d259923..0d8a905b 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -1,12 +1,14 @@ -import { auth } from '@clerk/nextjs/server'; - -import type { Organization, Prisma, ProjectAccess } from '../prisma-client'; +import type { + Invite, + Organization, + Prisma, + ProjectAccess, + User, +} from '../prisma-client'; import { db } from '../prisma-client'; export type IServiceOrganization = ReturnType; -export type IServiceInvite = Prisma.MemberGetPayload<{ - include: { user: true }; -}>; +export type IServiceInvite = Invite; export type IServiceMember = Prisma.MemberGetPayload<{ include: { user: true }; }> & { access: ProjectAccess[] }; @@ -21,15 +23,14 @@ export function transformOrganization(org: Organization) { }; } -export async function getCurrentOrganizations() { - const session = auth(); - if (!session.userId) return []; +export async function getOrganizations(userId: string | null) { + if (!userId) return []; const organizations = await db.organization.findMany({ where: { members: { some: { - userId: session.userId, + userId, }, }, }, @@ -67,13 +68,25 @@ export async function getOrganizationByProjectId(projectId: string) { } export async function getInvites(organizationId: string) { - return db.member.findMany({ + return db.invite.findMany({ where: { organizationId, - userId: null, + }, + }); +} + +export function getInviteById(inviteId: string) { + return db.invite.findUnique({ + where: { + id: inviteId, }, include: { - user: true, + organization: { + select: { + id: true, + name: true, + }, + }, }, }); } @@ -112,3 +125,56 @@ export async function getMember(organizationId: string, userId: string) { }, }); } + +export async function connectUserToOrganization({ + user, + inviteId, +}: { + user: User; + inviteId: string; +}) { + const invite = await db.invite.findUnique({ + where: { + id: inviteId, + }, + }); + + if (!invite) { + throw new Error('Invite not found'); + } + + if (invite.expiresAt < new Date()) { + throw new Error('Invite expired'); + } + + const member = await db.member.create({ + data: { + organizationId: invite.organizationId, + userId: user.id, + role: invite.role, + email: user.email, + invitedById: invite.createdById, + }, + }); + + if (invite.projectAccess.length > 0) { + for (const projectId of invite.projectAccess) { + await db.projectAccess.create({ + data: { + projectId, + userId: user.id, + organizationId: invite.organizationId, + level: 'write', + }, + }); + } + } + + await db.invite.delete({ + where: { + id: inviteId, + }, + }); + + return member; +} diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index c1784a5c..d727699c 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,5 +1,3 @@ -import { auth } from '@clerk/nextjs/server'; - import { cacheable } from '@openpanel/redis'; import type { Prisma, Project } from '../prisma-client'; import { db } from '../prisma-client'; @@ -55,9 +53,14 @@ export async function getProjectsByOrganizationId(organizationId: string) { }); } -export async function getCurrentProjects(organizationId: string) { - const session = auth(); - if (!session.userId) { +export async function getProjects({ + organizationId, + userId, +}: { + organizationId: string; + userId: string | null; +}) { + if (!userId) { return []; } @@ -72,13 +75,13 @@ export async function getCurrentProjects(organizationId: string) { }), db.member.findMany({ where: { - userId: session.userId, + userId, organizationId, }, }), db.projectAccess.findMany({ where: { - userId: session.userId, + userId, organizationId, }, }), diff --git a/packages/db/src/services/user.service.ts b/packages/db/src/services/user.service.ts index 5e08ef5b..5a3168d5 100644 --- a/packages/db/src/services/user.service.ts +++ b/packages/db/src/services/user.service.ts @@ -1,15 +1,5 @@ -import { auth } from '@clerk/nextjs/server'; - import { db } from '../prisma-client'; -export async function getCurrentUser() { - const session = auth(); - if (!session.userId) { - return null; - } - return getUserById(session.userId); -} - export async function getUserById(id: string) { return db.user.findUniqueOrThrow({ where: { @@ -17,3 +7,33 @@ export async function getUserById(id: string) { }, }); } + +export async function getUserAccount({ + email, + provider, + providerId, +}: { email: string; provider: string; providerId?: string }) { + const res = await db.user.findFirst({ + where: { + email, + }, + include: { + accounts: { + where: { + provider, + providerId: providerId ? String(providerId) : undefined, + }, + take: 1, + }, + }, + }); + + if (!res?.accounts[0]) { + return null; + } + + return { + ...res, + account: res?.accounts[0], + }; +} diff --git a/packages/email/index.ts b/packages/email/index.ts new file mode 100644 index 00000000..8420b109 --- /dev/null +++ b/packages/email/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 00000000..91d9f202 --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openpanel/email", + "version": "0.0.1", + "main": "index.ts", + "scripts": { + "dev": "email dev --dir src/emails -p 3939", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-email/components": "^0.0.30", + "react": "18.2.0", + "react-dom": "18.2.0", + "resend": "^4.0.1", + "responsive-react-email": "^0.0.5", + "zod": "^3.22.4" + }, + "devDependencies": { + "@openpanel/tsconfig": "workspace:*", + "@types/node": "20.14.8", + "@types/react": "^18.2.0", + "react-email": "3.0.4", + "typescript": "^5.2.2" + } +} diff --git a/packages/email/src/components/footer.tsx b/packages/email/src/components/footer.tsx new file mode 100644 index 00000000..6b6ac9db --- /dev/null +++ b/packages/email/src/components/footer.tsx @@ -0,0 +1,88 @@ +import { + Column, + Hr, + Img, + Link, + Row, + Section, + Text, +} from '@react-email/components'; +import React from 'react'; + +const baseUrl = 'https://openpanel.dev'; + +export function Footer() { + return ( + <> +
+
+ + An open-source alternative to Mixpanel + + +
+ + + + + OpenPanel on Github + + + + + OpenPanel on X + + + + + + OpenPanel on Discord + + + + + + Contact OpenPanel with email + + + + + + + OpenPanel AB - Sankt Eriksgatan 100, 113 31, Stockholm, Sweden. + + + + {/* + + Notification preferences + + */} +
+ + ); +} diff --git a/packages/email/src/components/layout.tsx b/packages/email/src/components/layout.tsx new file mode 100644 index 00000000..dbf6879c --- /dev/null +++ b/packages/email/src/components/layout.tsx @@ -0,0 +1,66 @@ +import { + Body, + Container, + Font, + Html, + Img, + Section, + Tailwind, +} from '@react-email/components'; +// biome-ignore lint/style/useImportType: resend needs React +import React from 'react'; +import { Footer } from './footer'; + +type Props = { + children: React.ReactNode; +}; + +export function Layout({ children }: Props) { + return ( + + + + + + + + + +
+ OpenPanel Logo +
+
{children}
+