feature(auth): replace clerk.com with custom auth (#103)

* feature(auth): replace clerk.com with custom auth

* minor fixes

* remove notification preferences

* decrease live events interval

fix(api): cookies..

# Conflicts:
#	.gitignore
#	apps/api/src/index.ts
#	apps/dashboard/src/app/providers.tsx
#	packages/trpc/src/trpc.ts
This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-18 21:30:39 +01:00
committed by Carl-Gerhard Lindesvärd
parent f28802b1c2
commit d31d9924a5
151 changed files with 18484 additions and 12853 deletions

View File

@@ -8,5 +8,5 @@ npm-debug.log
README.md
.next
.git
tmp
docker
converage

View File

@@ -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="/"
API_PORT=3333

View File

@@ -14,38 +14,16 @@
·
<a href="https://go.openpanel.dev/discord">Discord</a>
·
<a href="https://twitter.com/CarlLindesvard">X/Twitter</a>
<a href="https://twitter.com/OpenPanelDev">X/Twitter</a>
·
<a href="https://twitter.com/CarlLindesvard">Creator</a>
·
</p>
<br />
<br />
</p>
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

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
});

View File

@@ -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!);
}

View File

@@ -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<string[]>([], ['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,
) {

View File

@@ -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<AppRouter>['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) {

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},

View File

@@ -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",

View File

@@ -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<ReturnType<typeof getProjectsByOrganizationId>>;
organizations?: Awaited<ReturnType<typeof getCurrentOrganizations>>;
organizations?: Awaited<ReturnType<typeof getOrganizations>>;
align?: 'start' | 'end';
}
export default function LayoutProjectSelector({

View File

@@ -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),
]);

View File

@@ -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 (
<Sheet>
<Sheet onOpenChange={() => mutation.reset()}>
<SheetTrigger asChild>
<Button icon={PlusIcon}>Invite user</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<div>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email will
instructions.
</SheetDescription>
{mutation.isSuccess ? (
<SheetContent>
<SheetHeader>
<SheetTitle>User has been invited</SheetTitle>
</SheetHeader>
<div className="prose">
{mutation.data.type === 'is_member' ? (
<>
<p>
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.
</p>
<p>We have also notified the user by email about this.</p>
</>
) : (
<p>
We have sent an email with instructions to join the
organization.
</p>
)}
<div className="row gap-4 mt-8">
<Button onClick={() => mutation.reset()}>
Invite another user
</Button>
<Button variant="outline" onClick={() => closeSheet()}>
Close
</Button>
</div>
</div>
</SheetHeader>
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex flex-col gap-8"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
error={formState.errors.email?.message}
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div>
<Label>What role?</Label>
</SheetContent>
) : (
<SheetContent>
<SheetHeader>
<div>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email
will instructions.
</SheetDescription>
</div>
</SheetHeader>
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex flex-col gap-8"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
error={formState.errors.email?.message}
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div>
<Label>What role?</Label>
<Controller
name="role"
control={control}
render={({ field }) => (
<RadioGroup
defaultValue={field.value}
onChange={field.onChange}
ref={field.ref}
onBlur={field.onBlur}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:member" id="member" />
<Label className="mb-0" htmlFor="member">
Member
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:admin" id="admin" />
<Label className="mb-0" htmlFor="admin">
Admin
</Label>
</div>
</RadioGroup>
)}
/>
</div>
<Controller
name="role"
name="access"
control={control}
render={({ field }) => (
<RadioGroup
defaultValue={field.value}
onChange={field.onChange}
ref={field.ref}
onBlur={field.onBlur}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:member" id="member" />
<Label className="mb-0" htmlFor="member">
Member
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:admin" id="admin" />
<Label className="mb-0" htmlFor="admin">
Admin
</Label>
</div>
</RadioGroup>
<div>
<Label>Restrict access</Label>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={field.value}
onChange={field.onChange}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
<p className="mt-1 text-sm text-muted-foreground">
Leave empty to give access to all projects
</p>
</div>
)}
/>
</div>
<Controller
name="access"
control={control}
render={({ field }) => (
<div>
<Label>Restrict access</Label>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={field.value}
onChange={field.onChange}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
<p className="mt-1 text-sm text-muted-foreground">
Leave empty to give access to all projects
</p>
</div>
)}
/>
<SheetFooter>
<Button icon={SendIcon} type="submit" loading={mutation.isLoading}>
Invite user
</Button>
</SheetFooter>
</form>
</SheetContent>
<SheetFooter>
<Button
icon={SendIcon}
type="submit"
loading={mutation.isLoading}
>
Invite user
</Button>
</SheetFooter>
</form>
</SheetContent>
)}
</Sheet>
);
}

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -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]) {

View File

@@ -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');
}

View File

@@ -12,7 +12,7 @@ const Page = ({ children }: Props) => {
<div className="min-h-screen border-r border-r-background bg-gradient-to-r from-background to-def-200 max-md:hidden">
<LiveEventsServer />
</div>
<div className="min-h-screen">{children}</div>
<div className="min-h-screen p-4">{children}</div>
</div>
</div>
</>

View File

@@ -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();

View File

@@ -1,9 +0,0 @@
import { SignIn } from '@clerk/nextjs';
export default function Page() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<SignIn signUpUrl="/register" />
</div>
);
}

View File

@@ -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 (
<div className="flex h-full center-center w-full">
<div className="col gap-8 max-w-md w-full">
<div className="col md:row gap-4">
<SignInGithub type="sign-in" />
<SignInGoogle type="sign-in" />
</div>
<Or />
<div className="card p-8">
<SignInEmailForm />
</div>
<LinkButton variant={'outline'} size="lg" href="/onboarding">
No account? Sign up today
</LinkButton>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { SignUp } from '@clerk/nextjs';
export default function Page() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<SignUp signInUrl="/login" />
</div>
);
}

View File

@@ -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 (
<div className="flex h-full center-center">
<ResetPasswordForm />
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs';
export const dynamic = 'force-dynamic';
const SSOCallback = () => {
return <AuthenticateWithRedirectCallback />;
};
export default SSOCallback;

View File

@@ -11,7 +11,12 @@ export const OnboardingDescription = ({
children,
className,
}: Pick<Props, 'children' | 'className'>) => (
<div className={cn('font-medium text-muted-foreground', className)}>
<div
className={cn(
'font-medium text-muted-foreground leading-normal [&_a]:underline [&_a]:font-semibold',
className,
)}
>
{children}
</div>
);

View File

@@ -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');

View File

@@ -33,13 +33,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
const isConnected = events.length > 0;
const renderBadge = () => {
if (isConnected) {
return <Badge variant={'success'}>Connected</Badge>;
}
return <Badge variant={'destructive'}>Not connected</Badge>;
};
const renderIcon = () => {
if (isConnected) {
return (
@@ -61,9 +54,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div className="flex items-center gap-2 text-2xl capitalize">
{client?.name}
</div>
<div className="mt-2 text-sm font-semibold text-muted-foreground">
Connection status: {renderBadge()}
</div>
<div
className={cn(
@@ -75,7 +65,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
>
{renderIcon()}
<div className="flex-1">
<div className="text-lg font-semibold">
<div className="text-lg font-semibold leading-normal">
{isConnected ? 'Success' : 'Waiting for events'}
</div>
{isConnected ? (

View File

@@ -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) => {
</OnboardingDescription>
}
>
{/*
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 <Component key={type} client={client} events={events} />;
})} */}
<VerifyListener
project={project}
client={client}
events={events}
onVerified={setVerified}
/>
<CurlPreview project={project} />
<ButtonContainer>
<LinkButton
href={`/onboarding/${project.id}/connect`}
@@ -80,7 +80,7 @@ const Verify = ({ project, events }: Props) => {
)}
<LinkButton
href="/"
href={`/${project.organizationId}/${project.id}`}
size="lg"
className={cn(
'min-w-28 self-start',
@@ -96,3 +96,48 @@ const Verify = ({ project, events }: Props) => {
};
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 (
<div className="card">
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger
className="px-6"
onClick={() => {
clipboard(code, null);
}}
>
Try out the curl command
</AccordionTrigger>
<AccordionContent className="p-0">
<Syntax code={code} />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -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');

View File

@@ -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 <OnboardingTracking organizations={await getCurrentOrganizations()} />;
return (
<div>
<OnboardingLayout
className="max-w-screen-sm"
title="Create an account"
description={
<OnboardingDescription>
Lets start with creating you account. By creating an account you
accept the{' '}
<Link target="_blank" href="https://openpanel.dev/terms">
Terms of Service
</Link>{' '}
and{' '}
<Link target="_blank" href="https://openpanel.dev/privacy">
Privacy Policy
</Link>
.
</OnboardingDescription>
}
>
{invite && !hasInviteExpired && (
<div className="card p-8 mb-8 col gap-2">
<h2 className="text-2xl font-medium">
Invitation to {invite.organization.name}
</h2>
<p>
After you have created your account, you will be added to the
organization.
</p>
</div>
)}
{invite && hasInviteExpired && (
<div className="card p-8 mb-8 col gap-2">
<h2 className="text-2xl font-medium">
Invitation to {invite.organization.name} has expired
</h2>
<p>
The invitation has expired. Please contact the organization owner
to get a new invitation.
</p>
</div>
)}
<div className="col md:row gap-4">
<SignInGithub type="sign-up" />
<SignInGoogle type="sign-up" />
</div>
<Or className="my-8" />
<div className="col gap-8 p-8 card">
<h2 className="text-2xl font-medium">Sign up with email</h2>
<SignUpEmailForm />
</div>
</OnboardingLayout>
</div>
);
};
export default Tracking;
export default Page;

View File

@@ -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<typeof zOnboardingProject>;
const Tracking = ({
export const OnboardingCreateProject = ({
organizations,
}: {
organizations: IServiceOrganization[];
@@ -260,5 +262,3 @@ const Tracking = ({
</form>
);
};
export default Tracking;

View File

@@ -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 <OnboardingCreateProject organizations={organizations} />;
};
export default Page;

View File

@@ -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 (
<Link
href="/login"
className="flex items-center gap-2 text-muted-foreground"
>
Login
<LogInIcon size={16} />
</Link>
);
}
return (
<button
@@ -29,8 +54,7 @@ const SkipOnboarding = () => {
title: 'Skip onboarding?',
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
onConfirm() {
auth.signOut();
router.replace(process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL!);
logout();
},
});
}

View File

@@ -17,15 +17,15 @@ type Props = {
function useSteps(path: string) {
const steps: Step[] = [
{
name: 'Account creation',
status: 'pending',
match: '/sign-up',
},
{
name: 'General',
name: 'Create an account',
status: 'pending',
match: '/onboarding',
},
{
name: 'Create a project',
status: 'pending',
match: '/onboarding/project',
},
{
name: 'Connect your data',
status: 'pending',
@@ -75,7 +75,7 @@ const Steps = ({ className }: Props) => {
{steps.map((step, index) => (
<div
className={cn(
'flex flex-shrink-0 items-center gap-2 self-start px-3 py-1.5',
'flex flex-shrink-0 items-center gap-4 self-start px-3 py-1.5',
step.status === 'current' &&
'rounded-xl border border-border bg-card',
step.status === 'completed' &&
@@ -108,7 +108,7 @@ const Steps = ({ className }: Props) => {
</div>
</div>
<div className=" font-medium">{step.name}</div>
<div className="font-medium">{step.name}</div>
</div>
))}
</div>

View File

@@ -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<string[]>([], ['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!' });
}

View File

@@ -29,7 +29,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body
className={cn(
'grainy min-h-screen bg-def-100 font-sans text-base antialiased',
'grainy min-h-screen bg-def-100 font-sans text-base antialiased leading-normal',
GeistSans.variable,
GeistMono.variable,
)}

View File

@@ -6,7 +6,6 @@ import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux';
import makeStore from '@/redux';
import { api } from '@/trpc/client';
import { ClerkProvider, useAuth } from '@clerk/nextjs';
import { OpenPanelComponent } from '@openpanel/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpLink } from '@trpc/client';
@@ -18,7 +17,6 @@ import { Toaster } from 'sonner';
import superjson from 'superjson';
function AllProviders({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth();
const [queryClient] = useState(
() =>
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 (
<ClerkProvider>
<AllProviders>{children}</AllProviders>
</ClerkProvider>
);
return <AllProviders>{children}</AllProviders>;
}

View File

@@ -0,0 +1,11 @@
import { cn } from '@/utils/cn';
export function Or({ className }: { className?: string }) {
return (
<div className={cn('row items-center gap-2', className)}>
<div className="h-px w-full bg-border" />
<span className="text-muted-foreground">OR</span>
<div className="h-px w-full bg-border" />
</div>
);
}

View File

@@ -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<typeof validator>;
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<IForm>({
resolver: zodResolver(validator),
defaultValues: {
token: token ?? '',
password: '',
},
});
const onSubmit = form.handleSubmit(async (data) => {
mutation.mutate(data);
});
return (
<div className="col gap-8">
<form onSubmit={onSubmit}>
<InputWithLabel
label="New password"
placeholder="New password"
type="password"
{...form.register('password')}
/>
<Button type="submit">Reset password</Button>
</form>
</div>
);
}

View File

@@ -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<typeof validator>;
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<IForm>({
resolver: zodResolver(validator),
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
});
};
return (
<form
onSubmit={form.handleSubmit(onSubmit, (err) => console.log(err))}
className="col gap-6"
>
<h3 className="text-2xl font-medium text-left">Sign in with email</h3>
<InputWithLabel
{...form.register('email')}
error={form.formState.errors.email?.message}
label="Email"
/>
<InputWithLabel
{...form.register('password')}
error={form.formState.errors.password?.message}
label="Password"
type="password"
/>
<Button type="submit">Sign in</Button>
<button
type="button"
onClick={() =>
pushModal('RequestPasswordReset', {
email: form.getValues('email'),
})
}
className="text-sm text-muted-foreground hover:underline"
>
Forgot password?
</button>
</form>
);
}

View File

@@ -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 (
<Button
className="md:flex-1"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'github',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{title()}
</Button>
);
}

View File

@@ -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 (
<Button
className="md:flex-1"
size="lg"
onClick={() =>
mutation.mutate({
provider: 'google',
inviteId: type === 'sign-up' ? inviteId : undefined,
})
}
>
<svg
className="size-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{title()}
</Button>
);
}

View File

@@ -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<typeof validator>;
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<IForm>({
resolver: zodResolver(validator),
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
...values,
inviteId: searchParams.get('inviteId'),
});
};
return (
<form className="col gap-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="row gap-8 w-full flex-1">
<InputWithLabel
label="First name"
className="flex-1"
type="text"
{...form.register('firstName')}
error={form.formState.errors.firstName?.message}
/>
<InputWithLabel
label="Last name"
className="flex-1"
type="text"
{...form.register('lastName')}
error={form.formState.errors.lastName?.message}
/>
</div>
<InputWithLabel
label="Email"
className="w-full"
type="email"
{...form.register('email')}
error={form.formState.errors.email?.message}
/>
<div className="row gap-8 w-full">
<InputWithLabel
label="Password"
className="flex-1"
type="password"
{...form.register('password')}
error={form.formState.errors.password?.message}
/>
<InputWithLabel
label="Confirm password"
className="flex-1"
type="password"
{...form.register('confirmPassword')}
error={form.formState.errors.confirmPassword?.message}
/>
</div>
<div className="grid grid-cols-2 gap-8">
<div />
<Button type="submit" className="w-full" size="lg">
Create account
</Button>
</div>
</form>
);
}

View File

@@ -37,8 +37,13 @@ export const WithLabel = ({
)}
</Label>
{error && (
<Tooltiper asChild content={error}>
<div className="flex items-center gap-1 leading-none text-destructive">
<Tooltiper
asChild
content={error}
tooltipClassName="max-w-80 leading-normal"
align="end"
>
<div className="flex items-center gap-1 leading-none text-destructive">
Issues
<BanIcon size={14} />
</div>

View File

@@ -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 (
<DropdownMenu>
@@ -101,12 +104,7 @@ export default function SettingsToggle({ className }: Props) {
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => {
auth.signOut();
}}
>
<DropdownMenuItem className="text-red-600" onClick={() => logout()}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -44,10 +44,10 @@ export function useColumns(
),
},
{
accessorKey: 'access',
accessorKey: 'projectAccess',
header: 'Access',
cell: ({ row }) => {
const access = pathOr<string[]>([], ['meta', 'access'], row.original);
const access = row.original.projectAccess;
return (
<>
{access.map((id) => {
@@ -102,7 +102,7 @@ function ActionCell({ row }: { row: Row<IServiceInvite> }) {
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ memberId: row.original.id });
revoke.mutate({ inviteId: row.original.id });
}}
>
Revoke invite

View File

@@ -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<IServiceMember>;
projects: IServiceProject[];
}) {
const auth = useAuth();
const currentUserId = auth.data?.userId;
const initial = useRef(row.original.access.map((item) => item.projectId));
const [access, setAccess] = useState<string[]>(
row.original.access.map((item) => item.projectId),
@@ -88,6 +91,16 @@ function AccessCell({
},
});
if (auth.isLoading) {
return null;
}
if (currentUserId === row.original.userId) {
return (
<div className="text-muted-foreground">Can't change your own access</div>
);
}
return (
<ComboboxAdvanced
placeholder="Restrict access to projects"

View File

@@ -1,17 +1,16 @@
'use client';
import { SignOutButton as ClerkSignOutButton } from '@clerk/nextjs';
import { LogOutIcon } from 'lucide-react';
import { useLogout } from '@/hooks/useLogout';
import { Button } from './ui/button';
const SignOutButton = () => {
const logout = useLogout();
return (
<ClerkSignOutButton>
<Button variant={'secondary'} icon={LogOutIcon}>
Sign out
</Button>
</ClerkSignOutButton>
<Button variant={'secondary'} icon={LogOutIcon} onClick={() => logout()}>
Sign out
</Button>
);
};

View File

@@ -17,23 +17,24 @@ export default function Syntax({ code }: SyntaxProps) {
<div className="group relative">
<button
type="button"
className="absolute right-1 top-1 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100"
className="absolute right-1 top-1 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100 row items-center gap-2"
onClick={() => {
clipboard(code);
}}
>
<span>Copy</span>
<CopyIcon size={12} />
</button>
<SyntaxHighlighter
// wrapLongLines
style={docco}
language="html"
customStyle={{
borderRadius: '0.5rem',
padding: '1rem',
paddingTop: '0.5rem',
paddingBottom: '0.5rem',
fontSize: 14,
lineHeight: 1.3,
}}
>
{code}

View File

@@ -87,7 +87,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'relative -m-6 mb-6 flex justify-between rounded-t-lg border-b bg-def-100 p-6',
'relative -m-6 mb-0 flex justify-between rounded-t-lg border-b bg-def-100 p-6',
className,
)}
{...props}

View File

@@ -41,6 +41,7 @@ interface TooltiperProps {
tooltipClassName?: string;
onClick?: () => 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}
</TooltipContent>

View File

@@ -0,0 +1,5 @@
import { api } from '@/trpc/client';
export function useAuth() {
return api.auth.session.useQuery();
}

View File

@@ -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();
}

View File

@@ -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<T>(
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<string | null>(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<T>(
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 {

View File

@@ -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: [

View File

@@ -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 (
<ModalContent
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<ModalHeader
title="Verify your email"
text={
<p>
Please enter the verification code sent to your{' '}
<span className="font-semibold">{email}</span>.
</p>
}
/>
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
onComplete={async () => {
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');
}
}
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</ModalContent>
);
}

View File

@@ -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,
}),

View File

@@ -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<typeof validation>;
type Props = {
email?: string;
};
export default function RequestPasswordReset({ email }: Props) {
const router = useRouter();
const form = useForm<IForm>({
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 (
<ModalContent>
<ModalHeader title="Request password reset" />
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<InputWithLabel
label="Email"
placeholder="Your email address"
error={form.formState.errors.email?.message}
{...form.register('email')}
/>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => popModal()}
>
Cancel
</Button>
<Button type="submit" icon={SendIcon} loading={mutation.isLoading}>
Continue
</Button>
</DialogFooter>
</form>
</ModalContent>
);
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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(),
}
: {},
);
}

View File

@@ -173,6 +173,7 @@ const config = {
plugins: [
require('@tailwindcss/container-queries'),
require('tailwindcss-animate'),
require('@tailwindcss/typography'),
],
};

View File

@@ -0,0 +1,5 @@
{
"title": "Self-hosting",
"defaultOpen": true,
"pages": ["self-hosting", "migrating-from-clerk"]
}

View File

@@ -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.
<Steps>
<Step>
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
```
</Step>
<Step>
SSH into your server:
```bash
ssh user@your-ip
```
</Step>
<Step>
Pull the latest images, and restart the containers:
```bash
docker compose pull
docker compose down
docker compose up -d
```
</Step>
<Step>
SSH into your server:
```bash
ssh user@your-ip
```
</Step>
<Step>
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
```
</Step>
<Step>
Run the migration:
```bash
docker compose exec -it op-api bash -c "cd /app/packages/db && pnpm migrate:deploy:db:code 2-accounts.ts"
```
</Step>
</Steps>

View File

@@ -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';
<Callout>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.</Callout>
<Callout>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)</Callout>
## 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
<Steps>
<Step>
### 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
```
</Step>
<Step>
### 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
</Step>
<Step>
### 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
```
</Step>
</Steps>
## Clerk.com
<Callout>
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.
</Callout>
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.
<Callout>This is nothing that is required for the basic setup, but it is required for some features.</Callout>
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.

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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

View File

@@ -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",

View File

@@ -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;

2
packages/auth/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './src';
export * from './constants';

10
packages/auth/nextjs.ts Normal file
View File

@@ -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);

View File

@@ -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"
}
}

View File

@@ -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 ?? '',
);

View File

@@ -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,
});
}

View File

@@ -0,0 +1,4 @@
export * from './cookie';
export * from './oauth';
export * from './password';
export * from './session';

View File

@@ -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 ?? '',
);

View File

@@ -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<string> {
return await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}
export async function verifyPasswordHash(
hash: string,
password: string,
): Promise<boolean> {
return await verify(hash, password);
}
export async function verifyPasswordStrength(
password: string,
): Promise<boolean> {
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;
}

View File

@@ -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<Session> {
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<SessionValidationResult> {
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<void> {
await db.session.delete({ where: { id: sessionId } });
}
export type SessionValidationResult =
| { session: Session; user: User; userId: string }
| { session: null; user: null; userId: null };

View File

@@ -0,0 +1,12 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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),
),
);
});
});
}

View File

@@ -0,0 +1,5 @@
import { nanoid } from 'nanoid';
export function generateSecureId(prefix: string) {
return `${prefix}_${nanoid(18)}`;
}

View File

@@ -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();
};

View File

@@ -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<string, unknown>[] => {
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<string, unknown>,
),
);
};
async function checkFileExists(filePath: string): Promise<boolean> {
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,
},
});
}
}

View File

@@ -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('└──┘');
}

View File

@@ -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();

View File

@@ -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;

Some files were not shown because too many files have changed in this diff Show More