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:
committed by
Carl-Gerhard Lindesvärd
parent
f28802b1c2
commit
d31d9924a5
@@ -8,5 +8,5 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
tmp
|
docker
|
||||||
converage
|
converage
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
# CLERK
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=CHANGE_ME
|
|
||||||
CLERK_SECRET_KEY=CHANGE_ME
|
|
||||||
CLERK_SIGNING_SECRET="CHANGE_ME"
|
|
||||||
|
|
||||||
# STORAGE
|
# STORAGE
|
||||||
REDIS_URL="redis://127.0.0.1:6379"
|
REDIS_URL="redis://127.0.0.1:6379"
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"
|
||||||
@@ -17,7 +12,3 @@ NEXT_PUBLIC_DASHBOARD_URL="http://localhost:3000"
|
|||||||
NEXT_PUBLIC_API_URL="http://localhost:3333"
|
NEXT_PUBLIC_API_URL="http://localhost:3333"
|
||||||
WORKER_PORT=9999
|
WORKER_PORT=9999
|
||||||
API_PORT=3333
|
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="/"
|
|
||||||
70
README.md
70
README.md
@@ -14,38 +14,16 @@
|
|||||||
·
|
·
|
||||||
<a href="https://go.openpanel.dev/discord">Discord</a>
|
<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>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</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.
|
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.
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -58,13 +36,13 @@ Openpanel is a simple analytics tool for logging events on web, apps and backend
|
|||||||
- **Postgres** - storing basic information
|
- **Postgres** - storing basic information
|
||||||
- **Clickhouse** - storing events
|
- **Clickhouse** - storing events
|
||||||
- **Redis** - cache layer, pub/sub and queue
|
- **Redis** - cache layer, pub/sub and queue
|
||||||
|
- **BullMQ** - queue
|
||||||
### More
|
- **Resend** - email
|
||||||
|
- **Arctic** - oauth
|
||||||
- Tailwind
|
- **Oslo** - auth
|
||||||
- Shadcn
|
- **tRPC** - api
|
||||||
- tRPC - will probably migrate this to server actions
|
- **Tailwind** - styling
|
||||||
- Clerk - for authentication
|
- **Shadcn** - ui
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
@@ -78,9 +56,35 @@ You can find the how to [here](https://docs.openpanel.dev/docs/self-hosting)
|
|||||||
|
|
||||||
## Development
|
## 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
|
```bash
|
||||||
pnpm dock:up
|
pnpm dock:up
|
||||||
pnpm codegen
|
pnpm codegen
|
||||||
pnpm migrate:deploy # once to setup the db
|
pnpm migrate:deploy # once to setup the db
|
||||||
pnpm dev
|
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
|
||||||
@@ -30,8 +30,11 @@ COPY apps/api/package.json ./apps/api/
|
|||||||
# Packages
|
# Packages
|
||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
COPY packages/trpc/package.json packages/trpc/
|
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/queue/package.json packages/queue/
|
||||||
COPY packages/redis/package.json packages/redis/
|
COPY packages/redis/package.json packages/redis/
|
||||||
|
COPY packages/logger/package.json packages/logger/
|
||||||
COPY packages/common/package.json packages/common/
|
COPY packages/common/package.json packages/common/
|
||||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||||
COPY packages/constants/package.json packages/constants/
|
COPY packages/constants/package.json packages/constants/
|
||||||
@@ -87,9 +90,13 @@ COPY --from=build /app/apps/api ./apps/api
|
|||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
COPY --from=build /app/packages/db ./packages/db
|
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/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/queue ./packages/queue
|
||||||
COPY --from=build /app/packages/redis ./packages/redis
|
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/common ./packages/common
|
||||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||||
COPY --from=build /app/packages/constants ./packages/constants
|
COPY --from=build /app/packages/constants ./packages/constants
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/fastify": "^1.0.0",
|
|
||||||
"@fastify/compress": "^7.0.3",
|
"@fastify/compress": "^7.0.3",
|
||||||
"@fastify/cookie": "^9.3.1",
|
"@fastify/cookie": "^9.3.1",
|
||||||
"@fastify/cors": "^9.0.0",
|
"@fastify/cors": "^9.0.0",
|
||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^8.3.1",
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
"@openpanel/auth": "workspace:^",
|
||||||
"@openpanel/common": "workspace:*",
|
"@openpanel/common": "workspace:*",
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@openpanel/trpc": "workspace:*",
|
"@openpanel/trpc": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
"@trpc/server": "^10.45.1",
|
"@trpc/server": "^10.45.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
"fastify": "^4.25.2",
|
"fastify": "^4.25.2",
|
||||||
"fastify-metrics": "^11.0.0",
|
"fastify-metrics": "^11.0.0",
|
||||||
"ico-to-png": "^0.2.1",
|
"ico-to-png": "^0.2.1",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { validateClerkJwt } from '@/utils/auth';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import superjson from 'superjson';
|
import superjson from 'superjson';
|
||||||
import type * as WebSocket from 'ws';
|
import type * as WebSocket from 'ws';
|
||||||
@@ -124,18 +123,24 @@ export async function wsProjectEvents(
|
|||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
const { params, query } = req;
|
const { params, query } = req;
|
||||||
const { token } = query;
|
|
||||||
const type = query.type || 'saved';
|
const type = query.type || 'saved';
|
||||||
|
const subscribeToEvent = `event:${type}`;
|
||||||
|
|
||||||
if (!['saved', 'received'].includes(type)) {
|
if (!['saved', 'received'].includes(type)) {
|
||||||
connection.socket.send('Invalid type');
|
connection.socket.send('Invalid type');
|
||||||
connection.socket.close();
|
connection.socket.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subscribeToEvent = `event:${type}`;
|
|
||||||
const decoded = validateClerkJwt(token);
|
const userId = req.session?.userId;
|
||||||
const userId = decoded?.sub;
|
if (!userId) {
|
||||||
|
connection.socket.send('No active session');
|
||||||
|
connection.socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const access = await getProjectAccess({
|
const access = await getProjectAccess({
|
||||||
userId: userId!,
|
userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,18 +190,18 @@ export async function wsProjectNotifications(
|
|||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
const { params, query } = req;
|
const { params, query } = req;
|
||||||
|
const userId = req.session?.userId;
|
||||||
|
|
||||||
if (!query.token) {
|
if (!userId) {
|
||||||
connection.socket.send('No token provided');
|
connection.socket.send('No active session');
|
||||||
connection.socket.close();
|
connection.socket.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribeToEvent = 'notification';
|
const subscribeToEvent = 'notification';
|
||||||
const decoded = validateClerkJwt(query.token);
|
|
||||||
const userId = decoded?.sub;
|
|
||||||
const access = await getProjectAccess({
|
const access = await getProjectAccess({
|
||||||
userId: userId!,
|
userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
359
apps/api/src/controllers/oauth-callback.controller.tsx
Normal file
359
apps/api/src/controllers/oauth-callback.controller.tsx
Normal 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!);
|
||||||
|
}
|
||||||
@@ -1,164 +1,14 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { WebhookEvent } from '@clerk/fastify';
|
import { db } from '@openpanel/db';
|
||||||
import { AccessLevel, db } from '@openpanel/db';
|
|
||||||
import {
|
import {
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
slackInstaller,
|
slackInstaller,
|
||||||
} from '@openpanel/integrations/src/slack';
|
} from '@openpanel/integrations/src/slack';
|
||||||
import { getRedisPub } from '@openpanel/redis';
|
|
||||||
import { zSlackAuthResponse } from '@openpanel/validation';
|
import { zSlackAuthResponse } from '@openpanel/validation';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { pathOr } from 'ramda';
|
|
||||||
import { Webhook } from 'svix';
|
|
||||||
import { z } from 'zod';
|
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({
|
const paramsSchema = z.object({
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
@@ -172,7 +22,7 @@ const metadataSchema = z.object({
|
|||||||
|
|
||||||
export async function slackWebhook(
|
export async function slackWebhook(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Querystring: WebhookEvent;
|
Querystring: unknown;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
import { clerkPlugin } from '@clerk/fastify';
|
|
||||||
import compress from '@fastify/compress';
|
import compress from '@fastify/compress';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
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 type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import metricsPlugin from 'fastify-metrics';
|
import metricsPlugin from 'fastify-metrics';
|
||||||
import { path, pick } from 'ramda';
|
|
||||||
|
|
||||||
import { generateId } from '@openpanel/common';
|
import { generateId } from '@openpanel/common';
|
||||||
import type { IServiceClient, IServiceClientWithProject } from '@openpanel/db';
|
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||||
import { getRedisPub } from '@openpanel/redis';
|
import { getRedisPub } from '@openpanel/redis';
|
||||||
import type { AppRouter } from '@openpanel/trpc';
|
import type { AppRouter } from '@openpanel/trpc';
|
||||||
import { appRouter, createContext } 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 sourceMapSupport from 'source-map-support';
|
||||||
import {
|
import {
|
||||||
healthcheck,
|
healthcheck,
|
||||||
@@ -30,6 +33,7 @@ import exportRouter from './routes/export.router';
|
|||||||
import importRouter from './routes/import.router';
|
import importRouter from './routes/import.router';
|
||||||
import liveRouter from './routes/live.router';
|
import liveRouter from './routes/live.router';
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
|
import oauthRouter from './routes/oauth-callback.router';
|
||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
import trackRouter from './routes/track.router';
|
import trackRouter from './routes/track.router';
|
||||||
import webhookRouter from './routes/webhook.router';
|
import webhookRouter from './routes/webhook.router';
|
||||||
@@ -42,6 +46,7 @@ declare module 'fastify' {
|
|||||||
client: IServiceClientWithProject | null;
|
client: IServiceClientWithProject | null;
|
||||||
clientIp?: string;
|
clientIp?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
session: SessionValidationResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,31 @@ const startServer = async () => {
|
|||||||
: generateId(),
|
: 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', ipHook);
|
||||||
fastify.addHook('preHandler', timestampHook);
|
fastify.addHook('preHandler', timestampHook);
|
||||||
fastify.addHook('onRequest', requestIdHook);
|
fastify.addHook('onRequest', requestIdHook);
|
||||||
@@ -105,40 +135,34 @@ const startServer = async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.register(cors, () => {
|
// Dashboard API
|
||||||
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.register((instance, opts, done) => {
|
fastify.register((instance, opts, done) => {
|
||||||
fastify.register(cookie, {
|
instance.register(cookie, {
|
||||||
secret: 'random', // for cookies signature
|
secret: process.env.COOKIE_SECRET ?? '',
|
||||||
hook: 'onRequest',
|
hook: 'onRequest',
|
||||||
|
parseOptions: {},
|
||||||
});
|
});
|
||||||
instance.register(clerkPlugin, {
|
|
||||||
publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
instance.addHook('onRequest', (req, reply, done) => {
|
||||||
secretKey: process.env.CLERK_SECRET_KEY,
|
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, {
|
instance.register(fastifyTRPCPlugin, {
|
||||||
prefix: '/trpc',
|
prefix: '/trpc',
|
||||||
trpcOptions: {
|
trpcOptions: {
|
||||||
@@ -155,22 +179,27 @@ const startServer = async () => {
|
|||||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||||
});
|
});
|
||||||
instance.register(liveRouter, { prefix: '/live' });
|
instance.register(liveRouter, { prefix: '/live' });
|
||||||
|
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||||
|
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||||
|
instance.register(miscRouter, { prefix: '/misc' });
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.register(metricsPlugin, { endpoint: '/metrics' });
|
// Public API
|
||||||
fastify.register(eventRouter, { prefix: '/event' });
|
fastify.register((instance, opts, done) => {
|
||||||
fastify.register(profileRouter, { prefix: '/profile' });
|
instance.register(metricsPlugin, { endpoint: '/metrics' });
|
||||||
fastify.register(miscRouter, { prefix: '/misc' });
|
instance.register(eventRouter, { prefix: '/event' });
|
||||||
fastify.register(exportRouter, { prefix: '/export' });
|
instance.register(profileRouter, { prefix: '/profile' });
|
||||||
fastify.register(webhookRouter, { prefix: '/webhook' });
|
instance.register(exportRouter, { prefix: '/export' });
|
||||||
fastify.register(importRouter, { prefix: '/import' });
|
instance.register(importRouter, { prefix: '/import' });
|
||||||
fastify.register(trackRouter, { prefix: '/track' });
|
instance.register(trackRouter, { prefix: '/track' });
|
||||||
fastify.get('/', (_request, reply) =>
|
instance.get('/healthcheck', healthcheck);
|
||||||
reply.send({ name: 'openpanel sdk api' }),
|
instance.get('/healthcheck/queue', healthcheckQueue);
|
||||||
);
|
instance.get('/', (_request, reply) =>
|
||||||
fastify.get('/healthcheck', healthcheck);
|
reply.send({ name: 'openpanel sdk api' }),
|
||||||
fastify.get('/healthcheck/queue', healthcheckQueue);
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
fastify.setErrorHandler((error, request, reply) => {
|
fastify.setErrorHandler((error, request, reply) => {
|
||||||
if (error.statusCode === 429) {
|
if (error.statusCode === 429) {
|
||||||
|
|||||||
18
apps/api/src/routes/oauth-callback.router.ts
Normal file
18
apps/api/src/routes/oauth-callback.router.ts
Normal 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;
|
||||||
@@ -2,11 +2,6 @@ import * as controller from '@/controllers/webhook.controller';
|
|||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||||
fastify.route({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/clerk',
|
|
||||||
handler: controller.clerkWebhook,
|
|
||||||
});
|
|
||||||
fastify.route({
|
fastify.route({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/slack',
|
url: '/slack',
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
import { verifyPassword } from '@openpanel/common/server';
|
import { verifyPassword } from '@openpanel/common/server';
|
||||||
import type {
|
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||||
Client,
|
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||||
IServiceClient,
|
|
||||||
IServiceClientWithProject,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
import { ClientType, db, getClientByIdCached } from '@openpanel/db';
|
|
||||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||||
import type {
|
import type {
|
||||||
IProjectFilterIp,
|
IProjectFilterIp,
|
||||||
@@ -187,23 +182,3 @@ export async function validateImportRequest(
|
|||||||
|
|
||||||
return client;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ const options: Options = {
|
|||||||
clean: true,
|
clean: true,
|
||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
|
noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u],
|
||||||
external: ['@hyperdx/node-opentelemetry', 'winston'],
|
external: [
|
||||||
|
'@hyperdx/node-opentelemetry',
|
||||||
|
'winston',
|
||||||
|
'@node-rs/argon2',
|
||||||
|
'bcrypt',
|
||||||
|
],
|
||||||
ignoreWatch: ['../../**/{.git,node_modules}/**'],
|
ignoreWatch: ['../../**/{.git,node_modules}/**'],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
splitting: false,
|
splitting: false,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ COPY packages/db/package.json packages/db/package.json
|
|||||||
COPY packages/redis/package.json packages/redis/package.json
|
COPY packages/redis/package.json packages/redis/package.json
|
||||||
COPY packages/queue/package.json packages/queue/package.json
|
COPY packages/queue/package.json packages/queue/package.json
|
||||||
COPY packages/common/package.json packages/common/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/constants/package.json packages/constants/package.json
|
||||||
COPY packages/validation/package.json packages/validation/package.json
|
COPY packages/validation/package.json packages/validation/package.json
|
||||||
COPY packages/integrations/package.json packages/integrations/package.json
|
COPY packages/integrations/package.json packages/integrations/package.json
|
||||||
@@ -55,12 +57,6 @@ WORKDIR /app/apps/dashboard
|
|||||||
# Will be replaced on runtime
|
# Will be replaced on runtime
|
||||||
ENV NEXT_PUBLIC_DASHBOARD_URL="__NEXT_PUBLIC_DASHBOARD_URL__"
|
ENV NEXT_PUBLIC_DASHBOARD_URL="__NEXT_PUBLIC_DASHBOARD_URL__"
|
||||||
ENV NEXT_PUBLIC_API_URL="__NEXT_PUBLIC_API_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
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,16 @@ set -e
|
|||||||
echo "> Replace env variable placeholders with runtime values..."
|
echo "> Replace env variable placeholders with runtime values..."
|
||||||
|
|
||||||
# Define environment variables to check (space-separated string)
|
# 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
|
# Replace env variable placeholders with real values
|
||||||
for key in $variables_to_replace; do
|
for key in $variables_to_replace; do
|
||||||
value=$(eval echo \$"$key")
|
value=$(eval echo \$"$key")
|
||||||
if [ -n "$value" ]; then
|
if [ -n "$value" ]; then
|
||||||
echo " - Searching for $key with value $value..."
|
echo " - Searching for $key with value $value..."
|
||||||
# Use a custom placeholder for 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY' or use the actual key otherwise
|
# Use standard placeholder format for all variables
|
||||||
case "$key" in
|
placeholder="__${key}__"
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
|
|
||||||
placeholder="pk_test_eW9sby5jb20k"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
placeholder="__${key}__"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
# Run the replacement
|
# Run the replacement
|
||||||
find /app -type f \( -name "*.js" -o -name "*.html" \) | while read -r file; do
|
find /app -type f \( -name "*.js" -o -name "*.html" \) | while read -r file; do
|
||||||
if grep -q "$placeholder" "$file"; then
|
if grep -q "$placeholder" "$file"; then
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const config = {
|
|||||||
'@openpanel/constants',
|
'@openpanel/constants',
|
||||||
'@openpanel/redis',
|
'@openpanel/redis',
|
||||||
'@openpanel/validation',
|
'@openpanel/validation',
|
||||||
|
'@openpanel/email',
|
||||||
],
|
],
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
@@ -34,6 +35,7 @@ const config = {
|
|||||||
'bullmq',
|
'bullmq',
|
||||||
'ioredis',
|
'ioredis',
|
||||||
'@hyperdx/node-opentelemetry',
|
'@hyperdx/node-opentelemetry',
|
||||||
|
'@node-rs/argon2',
|
||||||
],
|
],
|
||||||
instrumentationHook: !!process.env.ENABLE_INSTRUMENTATION_HOOK,
|
instrumentationHook: !!process.env.ENABLE_INSTRUMENTATION_HOOK,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
"with-env": "dotenv -e ../../.env -c --"
|
"with-env": "dotenv -e ../../.env -c --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^5.0.12",
|
|
||||||
"@clickhouse/client": "^1.2.0",
|
"@clickhouse/client": "^1.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||||
|
"@openpanel/auth": "workspace:^",
|
||||||
"@openpanel/common": "workspace:^",
|
"@openpanel/common": "workspace:^",
|
||||||
"@openpanel/constants": "workspace:^",
|
"@openpanel/constants": "workspace:^",
|
||||||
"@openpanel/db": "workspace:^",
|
"@openpanel/db": "workspace:^",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"@reduxjs/toolkit": "^1.9.7",
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
"@t3-oss/env-nextjs": "^0.7.3",
|
"@t3-oss/env-nextjs": "^0.7.3",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tanstack/react-query": "^4.36.1",
|
"@tanstack/react-query": "^4.36.1",
|
||||||
"@tanstack/react-table": "^8.11.8",
|
"@tanstack/react-table": "^8.11.8",
|
||||||
"@trpc/client": "^10.45.1",
|
"@trpc/client": "^10.45.1",
|
||||||
@@ -117,7 +118,7 @@
|
|||||||
"@types/lodash.debounce": "^4.0.9",
|
"@types/lodash.debounce": "^4.0.9",
|
||||||
"@types/lodash.isequal": "^4.5.8",
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"@types/lodash.throttle": "^4.1.9",
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
"@types/node": "^18.19.15",
|
"@types/node": "20.14.8",
|
||||||
"@types/ramda": "^0.29.10",
|
"@types/ramda": "^0.29.10",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
getCurrentOrganizations,
|
getOrganizations,
|
||||||
getProjectsByOrganizationId,
|
getProjectsByOrganizationId,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface LayoutProjectSelectorProps {
|
interface LayoutProjectSelectorProps {
|
||||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
|
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
|
||||||
organizations?: Awaited<ReturnType<typeof getCurrentOrganizations>>;
|
organizations?: Awaited<ReturnType<typeof getOrganizations>>;
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
}
|
}
|
||||||
export default function LayoutProjectSelector({
|
export default function LayoutProjectSelector({
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCurrentOrganizations,
|
|
||||||
getCurrentProjects,
|
|
||||||
getDashboardsByProjectId,
|
getDashboardsByProjectId,
|
||||||
|
getOrganizations,
|
||||||
|
getProjects,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
|
||||||
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
import LayoutContent from './layout-content';
|
import LayoutContent from './layout-content';
|
||||||
import { LayoutSidebar } from './layout-sidebar';
|
import { LayoutSidebar } from './layout-sidebar';
|
||||||
import SideEffects from './side-effects';
|
import SideEffects from './side-effects';
|
||||||
@@ -22,9 +23,10 @@ export default async function AppLayout({
|
|||||||
children,
|
children,
|
||||||
params: { organizationSlug: organizationId, projectId },
|
params: { organizationSlug: organizationId, projectId },
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
|
const { userId } = await auth();
|
||||||
const [organizations, projects, dashboards] = await Promise.all([
|
const [organizations, projects, dashboards] = await Promise.all([
|
||||||
getCurrentOrganizations(),
|
getOrganizations(userId),
|
||||||
getCurrentProjects(organizationId),
|
getProjects({ organizationId, userId }),
|
||||||
getDashboardsByProjectId(projectId),
|
getDashboardsByProjectId(projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -48,101 +48,137 @@ export default function CreateInvite({ projects }: Props) {
|
|||||||
|
|
||||||
const mutation = api.organization.inviteUser.useMutation({
|
const mutation = api.organization.inviteUser.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toast('User invited!', {
|
toast.success('User has been invited');
|
||||||
description: 'The user has been invited to the organization.',
|
|
||||||
});
|
|
||||||
reset();
|
reset();
|
||||||
closeSheet();
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError() {
|
onError(error) {
|
||||||
toast.error('Failed to invite user');
|
toast.error('Failed to invite user', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet onOpenChange={() => mutation.reset()}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button icon={PlusIcon}>Invite user</Button>
|
<Button icon={PlusIcon}>Invite user</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent>
|
{mutation.isSuccess ? (
|
||||||
<SheetHeader>
|
<SheetContent>
|
||||||
<div>
|
<SheetHeader>
|
||||||
<SheetTitle>Invite a user</SheetTitle>
|
<SheetTitle>User has been invited</SheetTitle>
|
||||||
<SheetDescription>
|
</SheetHeader>
|
||||||
Invite users to your organization. They will recieve an email will
|
<div className="prose">
|
||||||
instructions.
|
{mutation.data.type === 'is_member' ? (
|
||||||
</SheetDescription>
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
</SheetHeader>
|
</SheetContent>
|
||||||
<form
|
) : (
|
||||||
onSubmit={handleSubmit((values) => mutation.mutate(values))}
|
<SheetContent>
|
||||||
className="flex flex-col gap-8"
|
<SheetHeader>
|
||||||
>
|
<div>
|
||||||
<InputWithLabel
|
<SheetTitle>Invite a user</SheetTitle>
|
||||||
className="w-full max-w-sm"
|
<SheetDescription>
|
||||||
label="Email"
|
Invite users to your organization. They will recieve an email
|
||||||
error={formState.errors.email?.message}
|
will instructions.
|
||||||
placeholder="Who do you want to invite?"
|
</SheetDescription>
|
||||||
{...register('email')}
|
</div>
|
||||||
/>
|
</SheetHeader>
|
||||||
<div>
|
<form
|
||||||
<Label>What role?</Label>
|
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
|
<Controller
|
||||||
name="role"
|
name="access"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<RadioGroup
|
<div>
|
||||||
defaultValue={field.value}
|
<Label>Restrict access</Label>
|
||||||
onChange={field.onChange}
|
<ComboboxAdvanced
|
||||||
ref={field.ref}
|
placeholder="Restrict access to projects"
|
||||||
onBlur={field.onBlur}
|
value={field.value}
|
||||||
className="flex gap-4"
|
onChange={field.onChange}
|
||||||
>
|
items={projects.map((item) => ({
|
||||||
<div className="flex items-center gap-2">
|
label: item.name,
|
||||||
<RadioGroupItem value="org:member" id="member" />
|
value: item.id,
|
||||||
<Label className="mb-0" htmlFor="member">
|
}))}
|
||||||
Member
|
/>
|
||||||
</Label>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
</div>
|
Leave empty to give access to all projects
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
<RadioGroupItem value="org:admin" id="admin" />
|
</div>
|
||||||
<Label className="mb-0" htmlFor="admin">
|
|
||||||
Admin
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<SheetFooter>
|
||||||
<Controller
|
<Button
|
||||||
name="access"
|
icon={SendIcon}
|
||||||
control={control}
|
type="submit"
|
||||||
render={({ field }) => (
|
loading={mutation.isLoading}
|
||||||
<div>
|
>
|
||||||
<Label>Restrict access</Label>
|
Invite user
|
||||||
<ComboboxAdvanced
|
</Button>
|
||||||
placeholder="Restrict access to projects"
|
</SheetFooter>
|
||||||
value={field.value}
|
</form>
|
||||||
onChange={field.onChange}
|
</SheetContent>
|
||||||
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>
|
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
||||||
import { Padding } from '@/components/ui/padding';
|
import { Padding } from '@/components/ui/padding';
|
||||||
import { auth } from '@clerk/nextjs/server';
|
|
||||||
import { ShieldAlertIcon } from 'lucide-react';
|
import { ShieldAlertIcon } from 'lucide-react';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { parseAsStringEnum } from 'nuqs/server';
|
import { parseAsStringEnum } from 'nuqs/server';
|
||||||
|
|
||||||
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
import { db } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
|
|
||||||
import EditOrganization from './edit-organization';
|
import EditOrganization from './edit-organization';
|
||||||
@@ -26,7 +26,7 @@ export default async function Page({
|
|||||||
const tab = parseAsStringEnum(['org', 'members', 'invites'])
|
const tab = parseAsStringEnum(['org', 'members', 'invites'])
|
||||||
.withDefault('org')
|
.withDefault('org')
|
||||||
.parseServerSide(searchParams.tab);
|
.parseServerSide(searchParams.tab);
|
||||||
const session = auth();
|
const session = await auth();
|
||||||
const organization = await db.organization.findUnique({
|
const organization = await db.organization.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: organizationId,
|
id: organizationId,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Padding } from '@/components/ui/padding';
|
import { Padding } from '@/components/ui/padding';
|
||||||
import { auth } from '@clerk/nextjs/server';
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
|
|
||||||
import { getUserById } from '@openpanel/db';
|
import { getUserById } from '@openpanel/db';
|
||||||
|
|
||||||
import EditProfile from './edit-profile';
|
import EditProfile from './edit-profile';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const { userId } = auth();
|
const { userId } = await auth();
|
||||||
const profile = await getUserById(userId!);
|
const profile = await getUserById(userId!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { pushModal, useOnPushModal } from '@/modals';
|
import { pushModal, useOnPushModal } from '@/modals';
|
||||||
import { useUser } from '@clerk/nextjs';
|
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { useOpenPanel } from '@openpanel/nextjs';
|
import { useOpenPanel } from '@openpanel/nextjs';
|
||||||
|
|
||||||
export default function SideEffects() {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import ProjectCard from '@/components/projects/project-card';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import SettingsToggle from '@/components/settings-toggle';
|
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';
|
import LayoutProjectSelector from './[projectId]/layout-project-selector';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -16,9 +17,10 @@ interface PageProps {
|
|||||||
export default async function Page({
|
export default async function Page({
|
||||||
params: { organizationSlug: organizationId },
|
params: { organizationSlug: organizationId },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
const { userId } = await auth();
|
||||||
const [organizations, projects] = await Promise.all([
|
const [organizations, projects] = await Promise.all([
|
||||||
getCurrentOrganizations(),
|
getOrganizations(userId),
|
||||||
getCurrentProjects(organizationId),
|
getProjects({ organizationId, userId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const organization = organizations.find((org) => org.id === organizationId);
|
const organization = organizations.find((org) => org.id === organizationId);
|
||||||
@@ -32,7 +34,7 @@ export default async function Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return redirect('/onboarding');
|
return redirect('/onboarding/project');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projects.length === 1 && projects[0]) {
|
if (projects.length === 1 && projects[0]) {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { redirect } from 'next/navigation';
|
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() {
|
export default async function Page() {
|
||||||
const organizations = await getCurrentOrganizations();
|
const { userId } = await auth();
|
||||||
|
const organizations = await getOrganizations(userId);
|
||||||
|
|
||||||
if (organizations.length > 0) {
|
if (organizations.length > 0) {
|
||||||
return redirect(`/${organizations[0]?.id}`);
|
return redirect(`/${organizations[0]?.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect('/onboarding');
|
return redirect('/onboarding/project');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<div className="min-h-screen border-r border-r-background bg-gradient-to-r from-background to-def-200 max-md:hidden">
|
||||||
<LiveEventsServer />
|
<LiveEventsServer />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-screen">{children}</div>
|
<div className="min-h-screen p-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const useWebEventGenerator = () => {
|
|||||||
function createNewEvent() {
|
function createNewEvent() {
|
||||||
const newEvent = generateEvent();
|
const newEvent = generateEvent();
|
||||||
setEvents((prevEvents) => [newEvent, ...prevEvents]);
|
setEvents((prevEvents) => [newEvent, ...prevEvents]);
|
||||||
timer = setTimeout(() => createNewEvent(), Math.random() * 1000);
|
timer = setTimeout(() => createNewEvent(), Math.random() * 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
createNewEvent();
|
createNewEvent();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
33
apps/dashboard/src/app/(auth)/login/page.tsx
Normal file
33
apps/dashboard/src/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
17
apps/dashboard/src/app/(auth)/reset-password/page.tsx
Normal file
17
apps/dashboard/src/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const SSOCallback = () => {
|
|
||||||
return <AuthenticateWithRedirectCallback />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SSOCallback;
|
|
||||||
@@ -11,7 +11,12 @@ export const OnboardingDescription = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: Pick<Props, '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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
import OnboardingConnect from './onboarding-connect';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -9,7 +10,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Connect = async ({ params: { projectId } }: 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;
|
const organizationId = orgs[0]?.id;
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new Error('No organization found');
|
throw new Error('No organization found');
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
|
|
||||||
const isConnected = events.length > 0;
|
const isConnected = events.length > 0;
|
||||||
|
|
||||||
const renderBadge = () => {
|
|
||||||
if (isConnected) {
|
|
||||||
return <Badge variant={'success'}>Connected</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Badge variant={'destructive'}>Not connected</Badge>;
|
|
||||||
};
|
|
||||||
const renderIcon = () => {
|
const renderIcon = () => {
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return (
|
return (
|
||||||
@@ -61,9 +54,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
<div className="flex items-center gap-2 text-2xl capitalize">
|
<div className="flex items-center gap-2 text-2xl capitalize">
|
||||||
{client?.name}
|
{client?.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm font-semibold text-muted-foreground">
|
|
||||||
Connection status: {renderBadge()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -75,7 +65,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-lg font-semibold">
|
<div className="text-lg font-semibold leading-normal">
|
||||||
{isConnected ? 'Success' : 'Waiting for events'}
|
{isConnected ? 'Success' : 'Waiting for events'}
|
||||||
</div>
|
</div>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
|
|||||||
@@ -4,10 +4,24 @@ import { ButtonContainer } from '@/components/button-container';
|
|||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import Link from 'next/link';
|
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, {
|
import OnboardingLayout, {
|
||||||
OnboardingDescription,
|
OnboardingDescription,
|
||||||
} from '../../../onboarding-layout';
|
} from '../../../onboarding-layout';
|
||||||
@@ -36,29 +50,15 @@ const Verify = ({ project, events }: Props) => {
|
|||||||
</OnboardingDescription>
|
</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
|
<VerifyListener
|
||||||
project={project}
|
project={project}
|
||||||
client={client}
|
client={client}
|
||||||
events={events}
|
events={events}
|
||||||
onVerified={setVerified}
|
onVerified={setVerified}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CurlPreview project={project} />
|
||||||
|
|
||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
href={`/onboarding/${project.id}/connect`}
|
href={`/onboarding/${project.id}/connect`}
|
||||||
@@ -80,7 +80,7 @@ const Verify = ({ project, events }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<LinkButton
|
<LinkButton
|
||||||
href="/"
|
href={`/${project.organizationId}/${project.id}`}
|
||||||
size="lg"
|
size="lg"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-28 self-start',
|
'min-w-28 self-start',
|
||||||
@@ -96,3 +96,48 @@ const Verify = ({ project, events }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default Verify;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { escape } from 'sqlstring';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
getCurrentOrganizations,
|
|
||||||
getEvents,
|
getEvents,
|
||||||
|
getOrganizations,
|
||||||
getProjectWithClients,
|
getProjectWithClients,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
|
||||||
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
import OnboardingVerify from './onboarding-verify';
|
import OnboardingVerify from './onboarding-verify';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -17,7 +18,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Verify = async ({ params: { projectId } }: 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;
|
const organizationId = orgs[0]?.id;
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new Error('No organization found');
|
throw new Error('No organization found');
|
||||||
|
|||||||
@@ -1,9 +1,78 @@
|
|||||||
import { getCurrentOrganizations } from '@openpanel/db';
|
import { Or } from '@/components/auth/or';
|
||||||
|
import { SignInGithub } from '@/components/auth/sign-in-github';
|
||||||
|
import { SignInGoogle } from '@/components/auth/sign-in-google';
|
||||||
|
import { SignUpEmailForm } from '@/components/auth/sign-up-email-form';
|
||||||
|
import { auth } from '@openpanel/auth/nextjs';
|
||||||
|
import { getInviteById } from '@openpanel/db';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout';
|
||||||
|
|
||||||
import OnboardingTracking from './onboarding-tracking';
|
const Page = async ({
|
||||||
|
searchParams,
|
||||||
|
}: { searchParams: { inviteId: string } }) => {
|
||||||
|
const session = await auth();
|
||||||
|
const inviteId = await searchParams.inviteId;
|
||||||
|
const invite = inviteId ? await getInviteById(inviteId) : null;
|
||||||
|
const hasInviteExpired = invite?.expiresAt && invite.expiresAt < new Date();
|
||||||
|
if (session.userId) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
const Tracking = async () => {
|
return (
|
||||||
return <OnboardingTracking organizations={await getCurrentOrganizations()} />;
|
<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;
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ import type { z } from 'zod';
|
|||||||
import type { IServiceOrganization } from '@openpanel/db';
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
import { zOnboardingProject } from '@openpanel/validation';
|
import { zOnboardingProject } from '@openpanel/validation';
|
||||||
|
|
||||||
import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout';
|
import OnboardingLayout, {
|
||||||
|
OnboardingDescription,
|
||||||
|
} from '../../onboarding-layout';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zOnboardingProject>;
|
type IForm = z.infer<typeof zOnboardingProject>;
|
||||||
|
|
||||||
const Tracking = ({
|
export const OnboardingCreateProject = ({
|
||||||
organizations,
|
organizations,
|
||||||
}: {
|
}: {
|
||||||
organizations: IServiceOrganization[];
|
organizations: IServiceOrganization[];
|
||||||
@@ -260,5 +262,3 @@ const Tracking = ({
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tracking;
|
|
||||||
@@ -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;
|
||||||
@@ -1,22 +1,47 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useLogout } from '@/hooks/useLogout';
|
||||||
import { showConfirm } from '@/modals';
|
import { showConfirm } from '@/modals';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { useAuth } from '@clerk/nextjs';
|
import { ChevronLastIcon, LogInIcon } from 'lucide-react';
|
||||||
import { ChevronLastIcon } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import {
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
useSelectedLayoutSegments,
|
||||||
|
} from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const PUBLIC_SEGMENTS = [['onboarding']];
|
||||||
|
|
||||||
const SkipOnboarding = () => {
|
const SkipOnboarding = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const res = api.onboarding.skipOnboardingCheck.useQuery();
|
const segments = useSelectedLayoutSegments();
|
||||||
const auth = useAuth();
|
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(() => {
|
useEffect(() => {
|
||||||
res.refetch();
|
res.refetch();
|
||||||
}, [pathname]);
|
}, [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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -29,8 +54,7 @@ const SkipOnboarding = () => {
|
|||||||
title: 'Skip onboarding?',
|
title: 'Skip onboarding?',
|
||||||
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
|
text: 'Are you sure you want to skip onboarding? Since you do not have any projects, you will be logged out.',
|
||||||
onConfirm() {
|
onConfirm() {
|
||||||
auth.signOut();
|
logout();
|
||||||
router.replace(process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL!);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ type Props = {
|
|||||||
function useSteps(path: string) {
|
function useSteps(path: string) {
|
||||||
const steps: Step[] = [
|
const steps: Step[] = [
|
||||||
{
|
{
|
||||||
name: 'Account creation',
|
name: 'Create an account',
|
||||||
status: 'pending',
|
|
||||||
match: '/sign-up',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'General',
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
match: '/onboarding',
|
match: '/onboarding',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Create a project',
|
||||||
|
status: 'pending',
|
||||||
|
match: '/onboarding/project',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Connect your data',
|
name: 'Connect your data',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -75,7 +75,7 @@ const Steps = ({ className }: Props) => {
|
|||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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' &&
|
step.status === 'current' &&
|
||||||
'rounded-xl border border-border bg-card',
|
'rounded-xl border border-border bg-card',
|
||||||
step.status === 'completed' &&
|
step.status === 'completed' &&
|
||||||
@@ -108,7 +108,7 @@ const Steps = ({ className }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className=" font-medium">{step.name}</div>
|
<div className="font-medium">{step.name}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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!' });
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
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,
|
GeistSans.variable,
|
||||||
GeistMono.variable,
|
GeistMono.variable,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ModalProvider } from '@/modals';
|
|||||||
import type { AppStore } from '@/redux';
|
import type { AppStore } from '@/redux';
|
||||||
import makeStore from '@/redux';
|
import makeStore from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { ClerkProvider, useAuth } from '@clerk/nextjs';
|
|
||||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { httpLink } from '@trpc/client';
|
import { httpLink } from '@trpc/client';
|
||||||
@@ -18,7 +17,6 @@ import { Toaster } from 'sonner';
|
|||||||
import superjson from 'superjson';
|
import superjson from 'superjson';
|
||||||
|
|
||||||
function AllProviders({ children }: { children: React.ReactNode }) {
|
function AllProviders({ children }: { children: React.ReactNode }) {
|
||||||
const { getToken } = useAuth();
|
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
@@ -44,15 +42,6 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
|||||||
mode: 'cors',
|
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 }) {
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <AllProviders>{children}</AllProviders>;
|
||||||
<ClerkProvider>
|
|
||||||
<AllProviders>{children}</AllProviders>
|
|
||||||
</ClerkProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/dashboard/src/components/auth/or.tsx
Normal file
11
apps/dashboard/src/components/auth/or.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/dashboard/src/components/auth/reset-password-form.tsx
Normal file
57
apps/dashboard/src/components/auth/reset-password-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/dashboard/src/components/auth/sign-in-email-form.tsx
Normal file
67
apps/dashboard/src/components/auth/sign-in-email-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/dashboard/src/components/auth/sign-in-github.tsx
Normal file
45
apps/dashboard/src/components/auth/sign-in-github.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/dashboard/src/components/auth/sign-in-google.tsx
Normal file
57
apps/dashboard/src/components/auth/sign-in-google.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/dashboard/src/components/auth/sign-up-email-form.tsx
Normal file
83
apps/dashboard/src/components/auth/sign-up-email-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,8 +37,13 @@ export const WithLabel = ({
|
|||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
{error && (
|
{error && (
|
||||||
<Tooltiper asChild content={error}>
|
<Tooltiper
|
||||||
<div className="flex items-center gap-1 leading-none text-destructive">
|
asChild
|
||||||
|
content={error}
|
||||||
|
tooltipClassName="max-w-80 leading-normal"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 leading-none text-destructive">
|
||||||
Issues
|
Issues
|
||||||
<BanIcon size={14} />
|
<BanIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { useTheme } from 'next-themes';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
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';
|
import { ProjectLink } from './links';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,9 +28,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsToggle({ className }: Props) {
|
export default function SettingsToggle({ className }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const auth = useAuth();
|
const logout = useLogout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -101,12 +104,7 @@ export default function SettingsToggle({ className }: Props) {
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="text-red-600" onClick={() => logout()}>
|
||||||
className="text-red-600"
|
|
||||||
onClick={() => {
|
|
||||||
auth.signOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export function useColumns(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'access',
|
accessorKey: 'projectAccess',
|
||||||
header: 'Access',
|
header: 'Access',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const access = pathOr<string[]>([], ['meta', 'access'], row.original);
|
const access = row.original.projectAccess;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{access.map((id) => {
|
{access.map((id) => {
|
||||||
@@ -102,7 +102,7 @@ function ActionCell({ row }: { row: Row<IServiceInvite> }) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
revoke.mutate({ memberId: row.original.id });
|
revoke.mutate({ inviteId: row.original.id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Revoke invite
|
Revoke invite
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { ACTIONS } from '@/components/data-table';
|
import { ACTIONS } from '@/components/data-table';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import type { IServiceMember, IServiceProject } from '@openpanel/db';
|
import type { IServiceMember, IServiceProject } from '@openpanel/db';
|
||||||
|
|
||||||
export function useColumns(projects: IServiceProject[]) {
|
export function useColumns(projects: IServiceProject[]) {
|
||||||
@@ -77,6 +78,8 @@ function AccessCell({
|
|||||||
row: Row<IServiceMember>;
|
row: Row<IServiceMember>;
|
||||||
projects: IServiceProject[];
|
projects: IServiceProject[];
|
||||||
}) {
|
}) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const currentUserId = auth.data?.userId;
|
||||||
const initial = useRef(row.original.access.map((item) => item.projectId));
|
const initial = useRef(row.original.access.map((item) => item.projectId));
|
||||||
const [access, setAccess] = useState<string[]>(
|
const [access, setAccess] = useState<string[]>(
|
||||||
row.original.access.map((item) => item.projectId),
|
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 (
|
return (
|
||||||
<ComboboxAdvanced
|
<ComboboxAdvanced
|
||||||
placeholder="Restrict access to projects"
|
placeholder="Restrict access to projects"
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { SignOutButton as ClerkSignOutButton } from '@clerk/nextjs';
|
|
||||||
import { LogOutIcon } from 'lucide-react';
|
import { LogOutIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useLogout } from '@/hooks/useLogout';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
const SignOutButton = () => {
|
const SignOutButton = () => {
|
||||||
|
const logout = useLogout();
|
||||||
return (
|
return (
|
||||||
<ClerkSignOutButton>
|
<Button variant={'secondary'} icon={LogOutIcon} onClick={() => logout()}>
|
||||||
<Button variant={'secondary'} icon={LogOutIcon}>
|
Sign out
|
||||||
Sign out
|
</Button>
|
||||||
</Button>
|
|
||||||
</ClerkSignOutButton>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,23 +17,24 @@ export default function Syntax({ code }: SyntaxProps) {
|
|||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
clipboard(code);
|
clipboard(code);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span>Copy</span>
|
||||||
<CopyIcon size={12} />
|
<CopyIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
// wrapLongLines
|
// wrapLongLines
|
||||||
style={docco}
|
style={docco}
|
||||||
language="html"
|
|
||||||
customStyle={{
|
customStyle={{
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
paddingTop: '0.5rem',
|
paddingTop: '0.5rem',
|
||||||
paddingBottom: '0.5rem',
|
paddingBottom: '0.5rem',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const SheetHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface TooltiperProps {
|
|||||||
tooltipClassName?: string;
|
tooltipClassName?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
delayDuration?: number;
|
delayDuration?: number;
|
||||||
sideOffset?: number;
|
sideOffset?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -56,6 +57,7 @@ export function Tooltiper({
|
|||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
sideOffset = 10,
|
sideOffset = 10,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
align,
|
||||||
}: TooltiperProps) {
|
}: TooltiperProps) {
|
||||||
if (disabled) return children;
|
if (disabled) return children;
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +70,7 @@ export function Tooltiper({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
side={side}
|
side={side}
|
||||||
className={tooltipClassName}
|
className={tooltipClassName}
|
||||||
|
align={align}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
5
apps/dashboard/src/hooks/useAuth.tsx
Normal file
5
apps/dashboard/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return api.auth.session.useQuery();
|
||||||
|
}
|
||||||
15
apps/dashboard/src/hooks/useLogout.ts
Normal file
15
apps/dashboard/src/hooks/useLogout.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuth } from '@clerk/nextjs';
|
|
||||||
import debounce from 'lodash.debounce';
|
import debounce from 'lodash.debounce';
|
||||||
import { use, useEffect, useMemo, useState } from 'react';
|
import { use, useEffect, useMemo, useState } from 'react';
|
||||||
import useWebSocket from 'react-use-websocket';
|
import useWebSocket from 'react-use-websocket';
|
||||||
@@ -18,19 +17,10 @@ export default function useWS<T>(
|
|||||||
onMessage: (event: T) => void,
|
onMessage: (event: T) => void,
|
||||||
options?: UseWSOptions,
|
options?: UseWSOptions,
|
||||||
) {
|
) {
|
||||||
const auth = useAuth();
|
|
||||||
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
||||||
.replace(/^https/, 'wss')
|
.replace(/^https/, 'wss')
|
||||||
.replace(/^http/, 'ws');
|
.replace(/^http/, 'ws');
|
||||||
const [baseUrl, setBaseUrl] = useState(`${ws}${path}`);
|
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(() => {
|
const debouncedOnMessage = useMemo(() => {
|
||||||
if (options?.debounce) {
|
if (options?.debounce) {
|
||||||
@@ -39,18 +29,12 @@ export default function useWS<T>(
|
|||||||
return onMessage;
|
return onMessage;
|
||||||
}, [options?.debounce?.delay]);
|
}, [options?.debounce?.delay]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth.isSignedIn) {
|
|
||||||
auth.getToken().then(setToken);
|
|
||||||
}
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (baseUrl === `${ws}${path}`) return;
|
if (baseUrl === `${ws}${path}`) return;
|
||||||
setBaseUrl(`${ws}${path}`);
|
setBaseUrl(`${ws}${path}`);
|
||||||
}, [path, baseUrl, ws]);
|
}, [path, baseUrl, ws]);
|
||||||
|
|
||||||
useWebSocket(socketUrl, {
|
useWebSocket(baseUrl, {
|
||||||
shouldReconnect: () => true,
|
shouldReconnect: () => true,
|
||||||
onMessage(event) {
|
onMessage(event) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,30 +1,80 @@
|
|||||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
import { COOKIE_MAX_AGE, COOKIE_OPTIONS } from '@openpanel/auth/constants';
|
||||||
import { NextResponse } from 'next/server';
|
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
|
// This example protects all routes including api/trpc routes
|
||||||
// Please edit this to allow other routes to be public as needed.
|
// 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([
|
const isPublicRoute = createRouteMatcher([
|
||||||
'/share/overview/:id',
|
'/share/overview/:id',
|
||||||
'/api/clerk/(.*)?',
|
|
||||||
'/login(.*)?',
|
'/login(.*)?',
|
||||||
|
'/reset-password(.*)?',
|
||||||
'/register(.*)?',
|
'/register(.*)?',
|
||||||
'/sso-callback(.*)?',
|
'/sso-callback(.*)?',
|
||||||
|
'/onboarding',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default clerkMiddleware(
|
export default (request: NextRequest) => {
|
||||||
(auth, req) => {
|
if (request.method === 'GET') {
|
||||||
if (process.env.MAINTENANCE_MODE && !req.url.includes('/maintenance')) {
|
const response = NextResponse.next();
|
||||||
return NextResponse.redirect(new URL('/maintenance', req.url), 307);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
return response;
|
||||||
{
|
}
|
||||||
debug: !!process.env.CLERK_DEBUG,
|
|
||||||
},
|
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 = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,9 @@ const Loading = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
|
RequestPasswordReset: dynamic(() => import('./request-reset-password'), {
|
||||||
|
loading: Loading,
|
||||||
|
}),
|
||||||
EditEvent: dynamic(() => import('./edit-event'), {
|
EditEvent: dynamic(() => import('./edit-event'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
@@ -56,9 +59,6 @@ const modals = {
|
|||||||
OnboardingTroubleshoot: dynamic(() => import('./OnboardingTroubleshoot'), {
|
OnboardingTroubleshoot: dynamic(() => import('./OnboardingTroubleshoot'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
VerifyEmail: dynamic(() => import('./VerifyEmail'), {
|
|
||||||
loading: Loading,
|
|
||||||
}),
|
|
||||||
DateRangerPicker: dynamic(() => import('./DateRangerPicker'), {
|
DateRangerPicker: dynamic(() => import('./DateRangerPicker'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
|
|||||||
73
apps/dashboard/src/modals/request-reset-password.tsx
Normal file
73
apps/dashboard/src/modals/request-reset-password.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,27 @@
|
|||||||
--radius: 0.5rem;
|
--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 {
|
.dark {
|
||||||
--highlight: 221.44 100% 62.04%;
|
--highlight: 221.44 100% 62.04%;
|
||||||
|
|
||||||
@@ -80,6 +101,27 @@
|
|||||||
--input: 0 0% 15.1%; /* #262626 */
|
--input: 0 0% 15.1%; /* #262626 */
|
||||||
--ring: 0 0% 83.9%; /* #d6d6d6 */
|
--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 {
|
@layer base {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function clipboard(value: string | number) {
|
export function clipboard(value: string | number, description?: null | string) {
|
||||||
navigator.clipboard.writeText(value.toString());
|
navigator.clipboard.writeText(value.toString());
|
||||||
toast('Copied to clipboard', {
|
toast(
|
||||||
description: value.toString(),
|
'Copied to clipboard',
|
||||||
});
|
description !== null
|
||||||
|
? {
|
||||||
|
description: description ?? value.toString(),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ const config = {
|
|||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/container-queries'),
|
require('@tailwindcss/container-queries'),
|
||||||
require('tailwindcss-animate'),
|
require('tailwindcss-animate'),
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
5
apps/public/content/docs/self-hosting/meta.json
Normal file
5
apps/public/content/docs/self-hosting/meta.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"title": "Self-hosting",
|
||||||
|
"defaultOpen": true,
|
||||||
|
"pages": ["self-hosting", "migrating-from-clerk"]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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';
|
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>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
|
## Instructions
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- VPS of any kind (only tested on Ubuntu 24.04)
|
- 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
|
- 🙋♂️ 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
|
### Quickstart
|
||||||
|
|
||||||
@@ -28,6 +25,7 @@ git clone https://github.com/Openpanel-dev/openpanel && cd openpanel/self-hostin
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
|
|
||||||
|
<Step>
|
||||||
### Clone
|
### Clone
|
||||||
|
|
||||||
Clone the repository to your VPS
|
Clone the repository to your VPS
|
||||||
@@ -35,7 +33,8 @@ Clone the repository to your VPS
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Openpanel-dev/openpanel.git
|
git clone https://github.com/Openpanel-dev/openpanel.git
|
||||||
```
|
```
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
### Run the setup script
|
### Run the setup script
|
||||||
|
|
||||||
The setup script will do 3 things
|
The setup script will do 3 things
|
||||||
@@ -43,9 +42,8 @@ The setup script will do 3 things
|
|||||||
1. Install node (if you accept)
|
1. Install node (if you accept)
|
||||||
2. Install docker (if you accept)
|
2. Install docker (if you accept)
|
||||||
3. Execute a node script that will ask some questions about your setup
|
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
|
```bash
|
||||||
cd openpanel/self-hosting
|
cd openpanel/self-hosting
|
||||||
@@ -59,6 +57,8 @@ cd openpanel/self-hosting
|
|||||||
3. Install pnpm
|
3. Install pnpm
|
||||||
4. Run the `npx jiti ./quiz.ts` script inside the self-hosting folder
|
4. Run the `npx jiti ./quiz.ts` script inside the self-hosting folder
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
### Start 🚀
|
### Start 🚀
|
||||||
|
|
||||||
Run the `./start` script located inside the self-hosting folder
|
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
|
```bash
|
||||||
./start
|
./start
|
||||||
```
|
```
|
||||||
|
</Step>
|
||||||
</Steps>
|
</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
|
## Good to know
|
||||||
|
|
||||||
### Always use correct api url
|
### 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
|
### Managed Redis
|
||||||
|
|
||||||
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
|
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "22.8.1",
|
"@types/node": "20.14.8",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
BIN
apps/public/public/icons/discord.png
Normal file
BIN
apps/public/public/icons/discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 970 B |
BIN
apps/public/public/icons/email.png
Normal file
BIN
apps/public/public/icons/email.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 B |
BIN
apps/public/public/icons/github.png
Normal file
BIN
apps/public/public/icons/github.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/public/public/icons/x.png
Normal file
BIN
apps/public/public/icons/x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -5,7 +5,7 @@ services:
|
|||||||
image: postgres:14-alpine
|
image: postgres:14-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tmp/op-db-data:/var/lib/postgresql/data
|
- ./docker/data/op-db-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
environment:
|
environment:
|
||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
image: redis:7.2.5-alpine
|
image: redis:7.2.5-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tmp/op-kv-data:/data
|
- ./docker/data/op-kv-data:/data
|
||||||
command: ['redis-server', '--maxmemory-policy', 'noeviction']
|
command: [ 'redis-server', '--maxmemory-policy', 'noeviction' ]
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@@ -31,14 +31,30 @@ services:
|
|||||||
image: clickhouse/clickhouse-server:24.3.2-alpine
|
image: clickhouse/clickhouse-server:24.3.2-alpine
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tmp/op-ch-data:/var/lib/clickhouse
|
- ./docker/data/op-ch-data:/var/lib/clickhouse
|
||||||
- ./tmp/op-ch-logs:/var/log/clickhouse-server
|
- ./docker/data/op-ch-logs:/var/log/clickhouse-server
|
||||||
- ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml:ro
|
- ./self-hosting/clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml
|
||||||
- ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml:ro
|
- ./self-hosting/clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 262144
|
soft: 262144
|
||||||
hard: 262144
|
hard: 262144
|
||||||
ports:
|
ports:
|
||||||
- 9000:9000
|
- "8123:8123" # HTTP interface
|
||||||
- 8123:8123
|
- "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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Carl-Gerhard Lindesvärd",
|
"author": "Carl-Gerhard Lindesvärd",
|
||||||
"packageManager": "pnpm@8.7.6",
|
"packageManager": "pnpm@9.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dock:up": "docker compose up -d",
|
"dock:up": "docker compose up -d",
|
||||||
"dock:down": "docker compose down",
|
"dock:down": "docker compose down",
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
"@clerk/shared",
|
|
||||||
"@prisma/client",
|
"@prisma/client",
|
||||||
"@prisma/engines",
|
"@prisma/engines",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
|||||||
19
packages/auth/constants.ts
Normal file
19
packages/auth/constants.ts
Normal 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
2
packages/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './src';
|
||||||
|
export * from './constants';
|
||||||
10
packages/auth/nextjs.ts
Normal file
10
packages/auth/nextjs.ts
Normal 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);
|
||||||
27
packages/auth/package.json
Normal file
27
packages/auth/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/auth/server/oauth.ts
Normal file
18
packages/auth/server/oauth.ts
Normal 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 ?? '',
|
||||||
|
);
|
||||||
20
packages/auth/src/cookie.ts
Normal file
20
packages/auth/src/cookie.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
4
packages/auth/src/index.ts
Normal file
4
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './cookie';
|
||||||
|
export * from './oauth';
|
||||||
|
export * from './password';
|
||||||
|
export * from './session';
|
||||||
18
packages/auth/src/oauth.ts
Normal file
18
packages/auth/src/oauth.ts
Normal 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 ?? '',
|
||||||
|
);
|
||||||
41
packages/auth/src/password.ts
Normal file
41
packages/auth/src/password.ts
Normal 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;
|
||||||
|
}
|
||||||
83
packages/auth/src/session.ts
Normal file
83
packages/auth/src/session.ts
Normal 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 };
|
||||||
12
packages/auth/tsconfig.json
Normal file
12
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@openpanel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["."],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@openpanel/db": "workspace:^",
|
"@openpanel/db": "workspace:^",
|
||||||
"@openpanel/sdk": "workspace:*",
|
"@openpanel/sdk": "workspace:*",
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "20.14.8",
|
||||||
"@types/progress": "^2.0.7",
|
"@types/progress": "^2.0.7",
|
||||||
"@types/ramda": "^0.30.1",
|
"@types/ramda": "^0.30.1",
|
||||||
"tsup": "^7.2.0",
|
"tsup": "^7.2.0",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
"@types/node": "^18.16.0",
|
"@types/node": "20.14.8",
|
||||||
"@types/ramda": "^0.29.6",
|
"@types/ramda": "^0.29.6",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"prisma": "^5.1.1",
|
"prisma": "^5.1.1",
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ export async function verifyPassword(
|
|||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
// compare the new supplied password with the hashed password using timeSafeEqual
|
// compare the new supplied password with the hashed password using timeSafeEqual
|
||||||
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
|
resolve(
|
||||||
|
timingSafeEqual(
|
||||||
|
new Uint8Array(hashKeyBuff),
|
||||||
|
new Uint8Array(derivedKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/common/server/id.ts
Normal file
5
packages/common/server/id.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export function generateSecureId(prefix: string) {
|
||||||
|
return `${prefix}_${nanoid(18)}`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { stripTrailingSlash } from '@openpanel/common';
|
|
||||||
import {
|
import {
|
||||||
chQuery,
|
chQuery,
|
||||||
db,
|
db,
|
||||||
getClientByIdCached,
|
getClientByIdCached,
|
||||||
getProjectByIdCached,
|
getProjectByIdCached,
|
||||||
} from '@openpanel/db';
|
} from '../index';
|
||||||
|
|
||||||
|
import { stripTrailingSlash } from '@openpanel/common';
|
||||||
|
|
||||||
const pickBestDomain = (domains: string[]): string | null => {
|
const pickBestDomain = (domains: string[]): string | null => {
|
||||||
// Filter out invalid domains
|
// Filter out invalid domains
|
||||||
@@ -61,7 +62,7 @@ const pickBestDomain = (domains: string[]): string | null => {
|
|||||||
return bestDomain?.domain || null;
|
return bestDomain?.domain || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main() {
|
export const up = async () => {
|
||||||
const projects = await db.project.findMany({
|
const projects = await db.project.findMany({
|
||||||
include: {
|
include: {
|
||||||
clients: true,
|
clients: true,
|
||||||
@@ -70,6 +71,14 @@ async function main() {
|
|||||||
|
|
||||||
const matches = [];
|
const matches = [];
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
|
if (project.cors.length > 0 || project.domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.clients.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const cors = [];
|
const cors = [];
|
||||||
let crossDomain = false;
|
let crossDomain = false;
|
||||||
for (const client of project.clients) {
|
for (const client of project.clients) {
|
||||||
@@ -93,8 +102,6 @@ async function main() {
|
|||||||
if (res.length) {
|
if (res.length) {
|
||||||
domain = pickBestDomain(res.map((r) => r.origin));
|
domain = pickBestDomain(res.map((r) => r.origin));
|
||||||
matches.push(domain);
|
matches.push(domain);
|
||||||
} else {
|
|
||||||
console.log('No domain found for client');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,19 +113,7 @@ async function main() {
|
|||||||
domain,
|
domain,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('Updated', {
|
|
||||||
cors,
|
|
||||||
crossDomain,
|
|
||||||
domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
await getProjectByIdCached.clear(project.id);
|
await getProjectByIdCached.clear(project.id);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
console.log('DONE');
|
|
||||||
console.log('DONE');
|
|
||||||
console.log('DONE');
|
|
||||||
console.log('DONE');
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
88
packages/db/code-migrations/2-accounts.ts
Normal file
88
packages/db/code-migrations/2-accounts.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/db/code-migrations/helpers.ts
Normal file
13
packages/db/code-migrations/helpers.ts
Normal 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('└──┘');
|
||||||
|
}
|
||||||
62
packages/db/code-migrations/migrate.ts
Normal file
62
packages/db/code-migrations/migrate.ts
Normal 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();
|
||||||
@@ -252,8 +252,9 @@ CREATE TABLE IF NOT EXISTS profile_aliases_distributed ON CLUSTER '{cluster}' AS
|
|||||||
-- +goose StatementBegin
|
-- +goose StatementBegin
|
||||||
INSERT INTO events_replicated
|
INSERT INTO events_replicated
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM events_v2 -- +goose StatementEnd
|
FROM events_v2;
|
||||||
-- +goose StatementBegin
|
-- +goose StatementEnd
|
||||||
|
-- +goose StatementBegin
|
||||||
INSERT INTO events_bots_replicated
|
INSERT INTO events_bots_replicated
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM events_bots;
|
FROM events_bots;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user