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
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/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/progress": "^2.0.7",
|
||||
"@types/ramda": "^0.30.1",
|
||||
"tsup": "^7.2.0",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"prisma": "^5.1.1",
|
||||
|
||||
@@ -49,7 +49,12 @@ export async function verifyPassword(
|
||||
reject(err);
|
||||
}
|
||||
// compare the new supplied password with the hashed password using timeSafeEqual
|
||||
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
|
||||
resolve(
|
||||
timingSafeEqual(
|
||||
new Uint8Array(hashKeyBuff),
|
||||
new Uint8Array(derivedKey),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
119
packages/db/code-migrations/1-settings.ts
Normal file
119
packages/db/code-migrations/1-settings.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
chQuery,
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getProjectByIdCached,
|
||||
} from '../index';
|
||||
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
|
||||
const pickBestDomain = (domains: string[]): string | null => {
|
||||
// Filter out invalid domains
|
||||
const validDomains = domains.filter(
|
||||
(domain) =>
|
||||
domain &&
|
||||
!domain.includes('*') &&
|
||||
!domain.includes('localhost') &&
|
||||
!domain.includes('127.0.0.1'),
|
||||
);
|
||||
|
||||
if (validDomains.length === 0) return null;
|
||||
|
||||
// Score each domain
|
||||
const scoredDomains = validDomains.map((domain) => {
|
||||
let score = 0;
|
||||
|
||||
// Prefer https (highest priority)
|
||||
if (domain.startsWith('https://')) score += 100;
|
||||
|
||||
// Penalize domains from common providers like vercel, netlify, etc.
|
||||
if (
|
||||
domain.includes('vercel.app') ||
|
||||
domain.includes('netlify.app') ||
|
||||
domain.includes('herokuapp.com') ||
|
||||
domain.includes('github.io') ||
|
||||
domain.includes('gitlab.io') ||
|
||||
domain.includes('surge.sh') ||
|
||||
domain.includes('cloudfront.net') ||
|
||||
domain.includes('firebaseapp.com') ||
|
||||
domain.includes('azurestaticapps.net') ||
|
||||
domain.includes('pages.dev') ||
|
||||
domain.includes('ngrok-free.app') ||
|
||||
domain.includes('ngrok.app')
|
||||
) {
|
||||
score -= 50;
|
||||
}
|
||||
|
||||
// Penalize subdomains
|
||||
const domainParts = domain
|
||||
.replace('https://', '')
|
||||
.replace('http://', '')
|
||||
.split('.');
|
||||
if (domainParts.length <= 2) score += 50;
|
||||
|
||||
// Tiebreaker: prefer shorter domains
|
||||
score -= domain.length;
|
||||
|
||||
return { domain, score };
|
||||
});
|
||||
|
||||
// Sort by score (highest first) and return the best domain
|
||||
const bestDomain = scoredDomains.sort((a, b) => b.score - a.score)[0];
|
||||
return bestDomain?.domain || null;
|
||||
};
|
||||
|
||||
export const up = async () => {
|
||||
const projects = await db.project.findMany({
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
});
|
||||
|
||||
const matches = [];
|
||||
for (const project of projects) {
|
||||
if (project.cors.length > 0 || project.domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (project.clients.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cors = [];
|
||||
let crossDomain = false;
|
||||
for (const client of project.clients) {
|
||||
if (client.crossDomain) {
|
||||
crossDomain = true;
|
||||
}
|
||||
cors.push(
|
||||
...(client.cors?.split(',') ?? []).map((c) =>
|
||||
stripTrailingSlash(c.trim()),
|
||||
),
|
||||
);
|
||||
await getClientByIdCached.clear(client.id);
|
||||
}
|
||||
|
||||
let domain = pickBestDomain(cors);
|
||||
|
||||
if (!domain) {
|
||||
const res = await chQuery<{ origin: string }>(
|
||||
`SELECT origin FROM events_distributed WHERE project_id = '${project.id}' and origin != ''`,
|
||||
);
|
||||
if (res.length) {
|
||||
domain = pickBestDomain(res.map((r) => r.origin));
|
||||
matches.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
cors,
|
||||
crossDomain,
|
||||
domain,
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(project.id);
|
||||
}
|
||||
};
|
||||
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
|
||||
INSERT INTO events_replicated
|
||||
SELECT *
|
||||
FROM events_v2 -- +goose StatementEnd
|
||||
-- +goose StatementBegin
|
||||
FROM events_v2;
|
||||
-- +goose StatementEnd
|
||||
-- +goose StatementBegin
|
||||
INSERT INTO events_bots_replicated
|
||||
SELECT *
|
||||
FROM events_bots;
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
"goose": "pnpm with-env ./migrations/goose",
|
||||
"codegen": "pnpm with-env prisma generate",
|
||||
"migrate": "pnpm with-env prisma migrate dev",
|
||||
"migrate:deploy:db:code": "pnpm with-env jiti ./code-migrations/migrate.ts",
|
||||
"migrate:deploy:db": "pnpm with-env prisma migrate deploy",
|
||||
"migrate:deploy:ch": "pnpm goose up",
|
||||
"migrate:deploy": "pnpm migrate:deploy:db && pnpm migrate:deploy:ch",
|
||||
"migrate:deploy": "pnpm migrate:deploy:db && pnpm migrate:deploy:db:code && pnpm migrate:deploy:ch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^5.0.2",
|
||||
"@clickhouse/client": "^1.2.0",
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/constants": "workspace:*",
|
||||
@@ -22,6 +22,7 @@
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"jiti": "^2.4.1",
|
||||
"prisma-json-types-generator": "^3.1.1",
|
||||
"ramda": "^0.29.1",
|
||||
"sqlstring": "^2.3.3",
|
||||
@@ -30,7 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Session";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
||||
"userId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `provider` to the `accounts` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "accounts" ADD COLUMN "provider" TEXT NOT NULL,
|
||||
ALTER COLUMN "providerId" DROP NOT NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "invites" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"projectAccess" TEXT[],
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `role` to the `invites` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "invites" ADD COLUMN "role" TEXT NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_organizationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_userId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "invites" DROP CONSTRAINT "invites_organizationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "project_access" DROP CONSTRAINT "project_access_organizationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "project_access" DROP CONSTRAINT "project_access_projectId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "project_access" DROP CONSTRAINT "project_access_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "projects" DROP CONSTRAINT "projects_organizationId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_access" ADD CONSTRAINT "project_access_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_access" ADD CONSTRAINT "project_access_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_access" ADD CONSTRAINT "project_access_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "reset_password" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reset_password_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `accountId` to the `reset_password` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "reset_password" ADD COLUMN "accountId" TEXT NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reset_password" ADD CONSTRAINT "reset_password_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "__code_migrations" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "__code_migrations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[name]` on the table `__code_migrations` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "__code_migrations_name_key" ON "__code_migrations"("name");
|
||||
@@ -15,6 +15,15 @@ datasource db {
|
||||
directUrl = env("DATABASE_URL_DIRECT")
|
||||
}
|
||||
|
||||
model CodeMigration {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("__code_migrations")
|
||||
}
|
||||
|
||||
enum ProjectType {
|
||||
website
|
||||
app
|
||||
@@ -35,6 +44,7 @@ model Organization {
|
||||
Dashboard Dashboard[]
|
||||
ShareOverview ShareOverview[]
|
||||
integrations Integration[]
|
||||
invites Invite[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
@@ -51,21 +61,54 @@ model User {
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
deletedAt DateTime?
|
||||
ProjectAccess ProjectAccess[]
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
invites Invite[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider String
|
||||
providerId String?
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
resetPasswords ResetPassword[]
|
||||
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model Member {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
role String
|
||||
email String
|
||||
// userId is nullable because we want to allow invites to be sent to emails that are not registered
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
invitedById String?
|
||||
invitedBy User? @relation("invitedBy", fields: [invitedById], references: [id])
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
meta Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -73,10 +116,26 @@ model Member {
|
||||
@@map("members")
|
||||
}
|
||||
|
||||
model Invite {
|
||||
id String @id
|
||||
email String
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdById String
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
projectAccess String[]
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
role String
|
||||
|
||||
@@map("invites")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
eventsCount Int @default(0)
|
||||
types ProjectType[] @default([])
|
||||
@@ -114,11 +173,11 @@ enum AccessLevel {
|
||||
model ProjectAccess {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
level AccessLevel
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -369,3 +428,14 @@ model Integration {
|
||||
|
||||
@@map("integrations")
|
||||
}
|
||||
|
||||
model ResetPassword {
|
||||
id String @id
|
||||
accountId String
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("reset_password")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
|
||||
import type { Organization, Prisma, ProjectAccess } from '../prisma-client';
|
||||
import type {
|
||||
Invite,
|
||||
Organization,
|
||||
Prisma,
|
||||
ProjectAccess,
|
||||
User,
|
||||
} from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export type IServiceOrganization = ReturnType<typeof transformOrganization>;
|
||||
export type IServiceInvite = Prisma.MemberGetPayload<{
|
||||
include: { user: true };
|
||||
}>;
|
||||
export type IServiceInvite = Invite;
|
||||
export type IServiceMember = Prisma.MemberGetPayload<{
|
||||
include: { user: true };
|
||||
}> & { access: ProjectAccess[] };
|
||||
@@ -21,15 +23,14 @@ export function transformOrganization(org: Organization) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentOrganizations() {
|
||||
const session = auth();
|
||||
if (!session.userId) return [];
|
||||
export async function getOrganizations(userId: string | null) {
|
||||
if (!userId) return [];
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: session.userId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -67,13 +68,25 @@ export async function getOrganizationByProjectId(projectId: string) {
|
||||
}
|
||||
|
||||
export async function getInvites(organizationId: string) {
|
||||
return db.member.findMany({
|
||||
return db.invite.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getInviteById(inviteId: string) {
|
||||
return db.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -112,3 +125,56 @@ export async function getMember(organizationId: string, userId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectUserToOrganization({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: User;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const invite = await db.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new Error('Invite not found');
|
||||
}
|
||||
|
||||
if (invite.expiresAt < new Date()) {
|
||||
throw new Error('Invite expired');
|
||||
}
|
||||
|
||||
const member = await db.member.create({
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
email: user.email,
|
||||
invitedById: invite.createdById,
|
||||
},
|
||||
});
|
||||
|
||||
if (invite.projectAccess.length > 0) {
|
||||
for (const projectId of invite.projectAccess) {
|
||||
await db.projectAccess.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId: user.id,
|
||||
organizationId: invite.organizationId,
|
||||
level: 'write',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.invite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import type { Prisma, Project } from '../prisma-client';
|
||||
import { db } from '../prisma-client';
|
||||
@@ -55,9 +53,14 @@ export async function getProjectsByOrganizationId(organizationId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentProjects(organizationId: string) {
|
||||
const session = auth();
|
||||
if (!session.userId) {
|
||||
export async function getProjects({
|
||||
organizationId,
|
||||
userId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
userId: string | null;
|
||||
}) {
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -72,13 +75,13 @@ export async function getCurrentProjects(organizationId: string) {
|
||||
}),
|
||||
db.member.findMany({
|
||||
where: {
|
||||
userId: session.userId,
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
}),
|
||||
db.projectAccess.findMany({
|
||||
where: {
|
||||
userId: session.userId,
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const session = auth();
|
||||
if (!session.userId) {
|
||||
return null;
|
||||
}
|
||||
return getUserById(session.userId);
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
return db.user.findUniqueOrThrow({
|
||||
where: {
|
||||
@@ -17,3 +7,33 @@ export async function getUserById(id: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserAccount({
|
||||
email,
|
||||
provider,
|
||||
providerId,
|
||||
}: { email: string; provider: string; providerId?: string }) {
|
||||
const res = await db.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider,
|
||||
providerId: providerId ? String(providerId) : undefined,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!res?.accounts[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...res,
|
||||
account: res?.accounts[0],
|
||||
};
|
||||
}
|
||||
|
||||
1
packages/email/index.ts
Normal file
1
packages/email/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './src';
|
||||
24
packages/email/package.json
Normal file
24
packages/email/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@openpanel/email",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"dev": "email dev --dir src/emails -p 3939",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.30",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"resend": "^4.0.1",
|
||||
"responsive-react-email": "^0.0.5",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/react": "^18.2.0",
|
||||
"react-email": "3.0.4",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
88
packages/email/src/components/footer.tsx
Normal file
88
packages/email/src/components/footer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Column,
|
||||
Hr,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
import React from 'react';
|
||||
|
||||
const baseUrl = 'https://openpanel.dev';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<>
|
||||
<Hr />
|
||||
<Section className="w-full p-6">
|
||||
<Text className="text-[21px] font-regular" style={{ margin: 0 }}>
|
||||
An open-source alternative to Mixpanel
|
||||
</Text>
|
||||
|
||||
<br />
|
||||
|
||||
<Row>
|
||||
<Column className="align-middle w-[40px]">
|
||||
<Link href="https://git.new/openpanel">
|
||||
<Img
|
||||
src={`${baseUrl}/icons/github.png`}
|
||||
width="22"
|
||||
height="22"
|
||||
alt="OpenPanel on Github"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column className="align-middle w-[40px]">
|
||||
<Link href="https://x.com/openpaneldev">
|
||||
<Img
|
||||
src={`${baseUrl}/icons/x.png`}
|
||||
width="22"
|
||||
height="22"
|
||||
alt="OpenPanel on X"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
<Column className="align-middle">
|
||||
<Link href="https://go.openpanel.dev/discord">
|
||||
<Img
|
||||
src={`${baseUrl}/icons/discord.png`}
|
||||
width="22"
|
||||
height="22"
|
||||
alt="OpenPanel on Discord"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
<Column className="align-middle">
|
||||
<Link href="mailto:hello@openpanel.dev">
|
||||
<Img
|
||||
src={`${baseUrl}/icons/email.png`}
|
||||
width="22"
|
||||
height="22"
|
||||
alt="Contact OpenPanel with email"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Text className="text-[#B8B8B8] text-xs">
|
||||
OpenPanel AB - Sankt Eriksgatan 100, 113 31, Stockholm, Sweden.
|
||||
</Text>
|
||||
</Row>
|
||||
|
||||
{/* <Row>
|
||||
<Link
|
||||
className="text-[#707070] text-[14px]"
|
||||
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||
title="Unsubscribe"
|
||||
>
|
||||
Notification preferences
|
||||
</Link>
|
||||
</Row> */}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
packages/email/src/components/layout.tsx
Normal file
66
packages/email/src/components/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Font,
|
||||
Html,
|
||||
Img,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
// biome-ignore lint/style/useImportType: resend needs React
|
||||
import React from 'react';
|
||||
import { Footer } from './footer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Layout({ children }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<head>
|
||||
<Font
|
||||
fontFamily="Geist"
|
||||
fallbackFontFamily="Helvetica"
|
||||
webFont={{
|
||||
url: 'https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.0.1/files/geist-sans-latin-400-normal.woff2',
|
||||
format: 'woff2',
|
||||
}}
|
||||
fontWeight={400}
|
||||
fontStyle="normal"
|
||||
/>
|
||||
|
||||
<Font
|
||||
fontFamily="Geist"
|
||||
fallbackFontFamily="Helvetica"
|
||||
webFont={{
|
||||
url: 'https://cdn.jsdelivr.net/npm/@fontsource/geist-sans@5.0.1/files/geist-sans-latin-500-normal.woff2',
|
||||
format: 'woff2',
|
||||
}}
|
||||
fontWeight={500}
|
||||
fontStyle="normal"
|
||||
/>
|
||||
</head>
|
||||
<Body className="bg-[#fff] my-auto mx-auto font-sans">
|
||||
<Container
|
||||
className="border-transparent md:border-[#E8E7E1] my-[40px] mx-auto max-w-[600px]"
|
||||
style={{ borderStyle: 'solid', borderWidth: 1 }}
|
||||
>
|
||||
<Section className="p-6">
|
||||
<Img
|
||||
src={'https://openpanel.dev/logo.png'}
|
||||
width="80"
|
||||
height="80"
|
||||
alt="OpenPanel Logo"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Section>
|
||||
<Section className="p-6">{children}</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
45
packages/email/src/emails/email-invite.tsx
Normal file
45
packages/email/src/emails/email-invite.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Button, Link, Text } from '@react-email/components';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
export const zEmailInvite = z.object({
|
||||
url: z.string(),
|
||||
organizationName: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zEmailInvite>;
|
||||
export default EmailInvite;
|
||||
export function EmailInvite({
|
||||
organizationName = 'Acme Co',
|
||||
url = 'https://openpanel.dev',
|
||||
}: Props) {
|
||||
return (
|
||||
<Layout>
|
||||
<Text>You've been invited to join {organizationName}!</Text>
|
||||
<Text>
|
||||
If you don't have an account yet, click the button below to create one
|
||||
and join the organization:
|
||||
</Text>
|
||||
<Button
|
||||
href={url}
|
||||
style={{
|
||||
backgroundColor: '#000',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Join {organizationName}
|
||||
</Button>
|
||||
<Text>
|
||||
Join link: <Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
<Text style={{ color: '#666' }}>
|
||||
Already have an account? No need to do anything - you'll have access
|
||||
automatically when you sign in.
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
40
packages/email/src/emails/email-reset-password.tsx
Normal file
40
packages/email/src/emails/email-reset-password.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Button, Link, Text } from '@react-email/components';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '../components/layout';
|
||||
|
||||
export const zEmailResetPassword = z.object({
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||
export default EmailResetPassword;
|
||||
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||
return (
|
||||
<Layout>
|
||||
<Text>
|
||||
You have requested to reset your password. Follow the link below to
|
||||
reset your password:
|
||||
</Text>
|
||||
<Button
|
||||
href={url}
|
||||
style={{
|
||||
backgroundColor: '#000',
|
||||
borderRadius: '6px',
|
||||
color: '#fff',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
<Text>
|
||||
Reset password link: <Link href={url}>{url}</Link>
|
||||
</Text>
|
||||
<Text style={{ color: '#666' }}>
|
||||
Have you not requested this? Please ignore this email and contact
|
||||
support if you believe this was a mistake.
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
23
packages/email/src/emails/index.tsx
Normal file
23
packages/email/src/emails/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { z } from 'zod';
|
||||
import { EmailInvite, zEmailInvite } from './email-invite';
|
||||
import EmailResetPassword, {
|
||||
zEmailResetPassword,
|
||||
} from './email-reset-password';
|
||||
|
||||
export const templates = {
|
||||
invite: {
|
||||
subject: (data: z.infer<typeof zEmailInvite>) =>
|
||||
`Invite to join ${data.organizationName}`,
|
||||
Component: EmailInvite,
|
||||
schema: zEmailInvite,
|
||||
},
|
||||
'reset-password': {
|
||||
subject: (data: z.infer<typeof zEmailResetPassword>) =>
|
||||
'Reset your password',
|
||||
Component: EmailResetPassword,
|
||||
schema: zEmailResetPassword,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Templates = typeof templates;
|
||||
export type TemplateKey = keyof Templates;
|
||||
49
packages/email/src/index.tsx
Normal file
49
packages/email/src/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Resend } from 'resend';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { type TemplateKey, type Templates, templates } from './emails';
|
||||
|
||||
const FROM = 'hello@openpanel.dev';
|
||||
|
||||
export async function sendEmail<T extends TemplateKey>(
|
||||
template: T,
|
||||
options: {
|
||||
to: string | string[];
|
||||
data: z.infer<Templates[T]['schema']>;
|
||||
},
|
||||
) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const { to, data } = options;
|
||||
const { subject, Component, schema } = templates[template];
|
||||
const props = schema.safeParse(data);
|
||||
|
||||
if (props.error) {
|
||||
console.error('Failed to parse data', props.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await resend.emails.send({
|
||||
from: FROM,
|
||||
to,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
subject: subject(props.data),
|
||||
// @ts-expect-error - TODO: fix this
|
||||
react: <Component {...props.data} />,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Failed to send email', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
packages/email/tsconfig.json
Normal file
13
packages/email/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/server/*": ["./src/server/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,37 @@ export function createLogger({ name }: { name: string }): ILogger {
|
||||
return info;
|
||||
});
|
||||
|
||||
const sensitiveKeys = [
|
||||
'password',
|
||||
'token',
|
||||
'secret',
|
||||
'authorization',
|
||||
'apiKey',
|
||||
];
|
||||
|
||||
const redactSensitiveInfo = winston.format((info) => {
|
||||
const redactObject = (obj: any): any => {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
|
||||
return Object.keys(obj).reduce((acc, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (sensitiveKeys.some((k) => lowerKey.includes(k))) {
|
||||
acc[key] = '[REDACTED]';
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
acc[key] = redactObject(obj[key]);
|
||||
} else {
|
||||
acc[key] = obj[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
};
|
||||
|
||||
return Object.assign({}, info, redactObject(info));
|
||||
});
|
||||
|
||||
const format = winston.format.combine(
|
||||
errorFormatter(),
|
||||
redactSensitiveInfo(),
|
||||
winston.format.json(),
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"devDependencies": {
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"prisma": "^5.1.1",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "1.0.0-local",
|
||||
"@openpanel/sdk": "workspace:1.0.0-local",
|
||||
"request-ip": "^3.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/web": "1.0.1-local"
|
||||
"@openpanel/web": "workspace:1.0.1-local"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
|
||||
@@ -20,4 +20,4 @@
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "1.0.0-local"
|
||||
"@openpanel/sdk": "workspace:1.0.0-local"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/node": "20.14.8",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
@@ -20,4 +20,4 @@
|
||||
"expo-constants": "14 - 17",
|
||||
"react-native": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/node": "20.14.8",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "1.0.0-local"
|
||||
"@openpanel/sdk": "workspace:1.0.0-local"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.14.12",
|
||||
"@types/node": "20.14.8",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/fastify": "^1.0.0",
|
||||
"@openpanel/auth": "workspace:^",
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/constants": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/email": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@seventy-seven/sdk": "0.0.0-beta.2",
|
||||
"@trpc-limiter/redis": "^0.0.2",
|
||||
"@trpc/server": "^10.45.1",
|
||||
"@trpc/client": "^10.45.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"mathjs": "^12.3.2",
|
||||
"prisma-error-enum": "^0.1.3",
|
||||
@@ -27,7 +31,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"prisma": "^5.1.1",
|
||||
|
||||
@@ -11,3 +11,15 @@ export const TRPCNotFoundError = (message: string) =>
|
||||
code: 'NOT_FOUND',
|
||||
message,
|
||||
});
|
||||
|
||||
export const TRPCInternalServerError = (message: string) =>
|
||||
new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message,
|
||||
});
|
||||
|
||||
export const TRPCBadRequestError = (message: string) =>
|
||||
new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authRouter } from './routers/auth';
|
||||
import { chartRouter } from './routers/chart';
|
||||
import { clientRouter } from './routers/client';
|
||||
import { dashboardRouter } from './routers/dashboard';
|
||||
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
|
||||
ticket: ticketRouter,
|
||||
notification: notificationRouter,
|
||||
integration: integrationRouter,
|
||||
auth: authRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
279
packages/trpc/src/routers/auth.ts
Normal file
279
packages/trpc/src/routers/auth.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Arctic,
|
||||
createSession,
|
||||
deleteSessionTokenCookie,
|
||||
generateSessionToken,
|
||||
github,
|
||||
google,
|
||||
hashPassword,
|
||||
invalidateSession,
|
||||
setSessionTokenCookie,
|
||||
verifyPasswordHash,
|
||||
} from '@openpanel/auth';
|
||||
import { generateSecureId } from '@openpanel/common/server/id';
|
||||
import { connectUserToOrganization, db, getUserAccount } from '@openpanel/db';
|
||||
import { sendEmail } from '@openpanel/email';
|
||||
import {
|
||||
zRequestResetPassword,
|
||||
zResetPassword,
|
||||
zSignInEmail,
|
||||
zSignUpEmail,
|
||||
} from '@openpanel/validation';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { z } from 'zod';
|
||||
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||
import {
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
rateLimitMiddleware,
|
||||
} from '../trpc';
|
||||
|
||||
const zProvider = z.enum(['email', 'google', 'github']);
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
signOut: publicProcedure.mutation(async ({ ctx }) => {
|
||||
deleteSessionTokenCookie(ctx.setCookie);
|
||||
if (ctx.session?.session?.id) {
|
||||
await invalidateSession(ctx.session.session.id);
|
||||
}
|
||||
}),
|
||||
signInOAuth: publicProcedure
|
||||
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
|
||||
.mutation(({ input, ctx }) => {
|
||||
const { provider } = input;
|
||||
|
||||
if (provider === 'github') {
|
||||
const state = Arctic.generateState();
|
||||
const url = github.createAuthorizationURL(state, [
|
||||
'user:email',
|
||||
'user:read',
|
||||
]);
|
||||
|
||||
// if we have an inviteId we want to add it to the redirect url
|
||||
// so we have this information in the callback url later
|
||||
if (input.inviteId) {
|
||||
const redirectUri = url.searchParams.get('redirect_uri');
|
||||
if (redirectUri) {
|
||||
const redirectUrl = new URL(redirectUri);
|
||||
redirectUrl.searchParams.set('inviteId', input.inviteId);
|
||||
url.searchParams.set('redirect_uri', redirectUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
ctx.setCookie('github_oauth_state', state, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'github',
|
||||
url: url.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = Arctic.generateState();
|
||||
const codeVerifier = Arctic.generateCodeVerifier();
|
||||
const url = google.createAuthorizationURL(state, codeVerifier, [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
]);
|
||||
|
||||
ctx.setCookie('google_oauth_state', state, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
ctx.setCookie('google_code_verifier', codeVerifier, {
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'google',
|
||||
url: url.toString(),
|
||||
};
|
||||
}),
|
||||
signUpEmail: publicProcedure
|
||||
.input(zSignUpEmail)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const provider = 'email';
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw TRPCNotFoundError('User already exists');
|
||||
}
|
||||
|
||||
const createdUser = await db.user.create({
|
||||
data: {
|
||||
id: generateSecureId('user'),
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
accounts: {
|
||||
create: {
|
||||
provider,
|
||||
password: await hashPassword(input.password),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (input.inviteId) {
|
||||
await connectUserToOrganization({
|
||||
user: createdUser,
|
||||
inviteId: input.inviteId,
|
||||
});
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, createdUser.id);
|
||||
|
||||
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
||||
return session;
|
||||
}),
|
||||
signInEmail: publicProcedure
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 30_000,
|
||||
}),
|
||||
)
|
||||
.input(zSignInEmail)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const provider = 'email';
|
||||
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw TRPCNotFoundError('User does not exists');
|
||||
}
|
||||
|
||||
if (provider === 'email') {
|
||||
// if the password starts with $argon2 we use the new password hashing
|
||||
// otherwise its legacy from Clerk which uses bcrypt
|
||||
// TODO: Remove this after 2025-06-01 (half year from now)
|
||||
if (user.account.password?.startsWith('$argon2')) {
|
||||
const validPassword = await verifyPasswordHash(
|
||||
user.account.password ?? '',
|
||||
input.password,
|
||||
);
|
||||
|
||||
if (!validPassword) {
|
||||
throw TRPCAccessError('Incorrect email or password');
|
||||
}
|
||||
} else {
|
||||
const validPassword = await bcrypt.compare(
|
||||
input.password,
|
||||
user.account.password ?? '',
|
||||
);
|
||||
|
||||
if (!validPassword) {
|
||||
throw TRPCAccessError('Incorrect email or password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.id);
|
||||
|
||||
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
||||
return {
|
||||
type: 'email',
|
||||
};
|
||||
}),
|
||||
|
||||
resetPassword: publicProcedure
|
||||
.input(zResetPassword)
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 60_000,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, password } = input;
|
||||
|
||||
const resetPassword = await db.resetPassword.findUnique({
|
||||
where: {
|
||||
id: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resetPassword) {
|
||||
throw TRPCNotFoundError('Reset password not found');
|
||||
}
|
||||
|
||||
if (resetPassword.expiresAt < new Date()) {
|
||||
throw TRPCNotFoundError('Reset password expired');
|
||||
}
|
||||
|
||||
await db.account.update({
|
||||
where: { id: resetPassword.accountId },
|
||||
data: {
|
||||
password: await hashPassword(password),
|
||||
},
|
||||
});
|
||||
|
||||
await db.resetPassword.delete({
|
||||
where: { id: token },
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
|
||||
requestResetPassword: publicProcedure
|
||||
.use(
|
||||
rateLimitMiddleware({
|
||||
max: 3,
|
||||
windowMs: 60_000,
|
||||
}),
|
||||
)
|
||||
.input(zRequestResetPassword)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = await getUserAccount({
|
||||
email: input.email,
|
||||
provider: 'email',
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user.account.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await db.resetPassword.deleteMany({
|
||||
where: {
|
||||
accountId: user.account.id,
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateSecureId('pw');
|
||||
// expires in 10 minutes
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 10);
|
||||
|
||||
await db.resetPassword.create({
|
||||
data: {
|
||||
id: token,
|
||||
expiresAt,
|
||||
accountId: user.account.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendEmail('reset-password', {
|
||||
to: input.email,
|
||||
data: {
|
||||
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}),
|
||||
session: publicProcedure.query(async ({ ctx }) => {
|
||||
return ctx.session;
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { clerkClient } from '@clerk/fastify';
|
||||
import { pathOr } from 'ramda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
import { connectUserToOrganization, db } from '@openpanel/db';
|
||||
import { zInviteUser } from '@openpanel/validation';
|
||||
|
||||
import { generateSecureId } from '@openpanel/common/server/id';
|
||||
import { sendEmail } from '@openpanel/email';
|
||||
import { addDays } from 'date-fns';
|
||||
import { getOrganizationAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
export const organizationRouter = createTRPCRouter({
|
||||
@@ -59,68 +60,101 @@ export const organizationRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
let invitationId: string | undefined;
|
||||
const alreadyMember = await db.member.findFirst({
|
||||
where: {
|
||||
userId: userExists?.id,
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userExists) {
|
||||
const ticket = await clerkClient.invitations.createInvitation({
|
||||
emailAddress: email,
|
||||
notify: true,
|
||||
});
|
||||
invitationId = ticket.id;
|
||||
if (alreadyMember && userExists) {
|
||||
throw TRPCBadRequestError(
|
||||
'User is already a member of the organization',
|
||||
);
|
||||
}
|
||||
|
||||
return db.member.create({
|
||||
const alreadyInvited = await db.invite.findFirst({
|
||||
where: {
|
||||
email,
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (alreadyInvited) {
|
||||
throw TRPCBadRequestError(
|
||||
'User is already invited to the organization',
|
||||
);
|
||||
}
|
||||
|
||||
const invite = await db.invite.create({
|
||||
data: {
|
||||
id: generateSecureId('invite'),
|
||||
email,
|
||||
organizationId: input.organizationId,
|
||||
role: input.role,
|
||||
invitedById: ctx.session.userId,
|
||||
meta: {
|
||||
access: input.access,
|
||||
invitationId,
|
||||
createdById: ctx.session.userId,
|
||||
projectAccess: input.access || [],
|
||||
expiresAt: addDays(new Date(), 3),
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
const member = await connectUserToOrganization({
|
||||
user: userExists,
|
||||
inviteId: invite.id,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'is_member',
|
||||
member,
|
||||
};
|
||||
}
|
||||
|
||||
await sendEmail('invite', {
|
||||
to: email,
|
||||
data: {
|
||||
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/onboarding?inviteId=${invite.id}`,
|
||||
organizationName: invite.organization.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'is_invited',
|
||||
invite,
|
||||
};
|
||||
}),
|
||||
revokeInvite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
inviteId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const member = await db.member.findUniqueOrThrow({
|
||||
const invite = await db.invite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: input.memberId,
|
||||
id: input.inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
const access = await getOrganizationAccess({
|
||||
userId: ctx.session.userId,
|
||||
organizationId: member.organizationId,
|
||||
organizationId: invite.organizationId,
|
||||
});
|
||||
|
||||
if (access?.role !== 'org:admin') {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
|
||||
const invitationId = pathOr<string | undefined>(
|
||||
undefined,
|
||||
['meta', 'invitationId'],
|
||||
member,
|
||||
);
|
||||
|
||||
if (invitationId) {
|
||||
await clerkClient.invitations
|
||||
.revokeInvitation(invitationId)
|
||||
.catch(() => {
|
||||
// Ignore errors, this will throw if the invitation is already accepted
|
||||
});
|
||||
}
|
||||
|
||||
return db.member.delete({
|
||||
return db.invite.delete({
|
||||
where: {
|
||||
id: input.memberId,
|
||||
id: input.inviteId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { clerkClient } from '@clerk/fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '@openpanel/db';
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
update: protectedProcedure
|
||||
@@ -14,23 +13,15 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const [updatedUser] = await Promise.all([
|
||||
db.user.update({
|
||||
where: {
|
||||
id: ctx.session.userId,
|
||||
},
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
},
|
||||
}),
|
||||
clerkClient.users.updateUser(ctx.session.userId, {
|
||||
return db.user.update({
|
||||
where: {
|
||||
id: ctx.session.userId,
|
||||
},
|
||||
data: {
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
}),
|
||||
]);
|
||||
|
||||
return updatedUser;
|
||||
},
|
||||
});
|
||||
}),
|
||||
debugPostCookie: protectedProcedure
|
||||
.input(
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
import { getAuth } from '@clerk/fastify';
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
|
||||
import { has } from 'ramda';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { COOKIE_OPTIONS, validateSessionToken } from '@openpanel/auth';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type { ISetCookie } from '@openpanel/validation';
|
||||
import {
|
||||
createTrpcRedisLimiter,
|
||||
defaultFingerPrint,
|
||||
} from '@trpc-limiter/redis';
|
||||
import { getOrganizationAccessCached, getProjectAccessCached } from './access';
|
||||
import { TRPCAccessError } from './errors';
|
||||
|
||||
export function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
export const rateLimitMiddleware = ({
|
||||
max,
|
||||
windowMs,
|
||||
}: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
}) =>
|
||||
createTrpcRedisLimiter<typeof t>({
|
||||
fingerprint: (ctx) => defaultFingerPrint(ctx.req),
|
||||
message: (hitInfo) =>
|
||||
`Too many requests, please try again later. ${hitInfo}`,
|
||||
max,
|
||||
windowMs,
|
||||
redisClient: getRedisCache(),
|
||||
});
|
||||
|
||||
export async function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
const setCookie: ISetCookie = (key, value, options) => {
|
||||
// @ts-ignore
|
||||
res.setCookie(key, value, {
|
||||
maxAge: options.maxAge,
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const session = await validateSessionToken(req.cookies?.session);
|
||||
|
||||
return {
|
||||
req,
|
||||
res,
|
||||
session: getAuth(req),
|
||||
session,
|
||||
// we do not get types for `setCookie` from fastify
|
||||
// so define it here and be safe in routers
|
||||
setCookie: (key: string, value: string, options: any) => {
|
||||
// @ts-ignore
|
||||
res.setCookie(key, value, options);
|
||||
},
|
||||
setCookie,
|
||||
};
|
||||
}
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/node": "20.14.8",
|
||||
"prisma": "^5.1.1",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
|
||||
@@ -300,3 +300,43 @@ export const zProject = z.object({
|
||||
crossDomain: z.boolean().default(false),
|
||||
});
|
||||
export type IProjectEdit = z.infer<typeof zProject>;
|
||||
|
||||
export const zPassword = z
|
||||
.string()
|
||||
.min(8)
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
|
||||
'Password must contain at least 8 characters, one uppercase letter, one lowercase letter, one number and one special character',
|
||||
);
|
||||
|
||||
export const zSignInEmail = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: zPassword,
|
||||
});
|
||||
export type ISignInEmail = z.infer<typeof zSignInEmail>;
|
||||
|
||||
export const zSignUpEmail = z
|
||||
.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: zPassword,
|
||||
confirmPassword: zPassword,
|
||||
inviteId: z.string().nullish(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ['confirmPassword'],
|
||||
message: 'Passwords do not match',
|
||||
});
|
||||
export type ISignUpEmail = z.infer<typeof zSignUpEmail>;
|
||||
|
||||
export const zResetPassword = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
export type IResetPassword = z.infer<typeof zResetPassword>;
|
||||
|
||||
export const zRequestResetPassword = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
export type IRequestResetPassword = z.infer<typeof zRequestResetPassword>;
|
||||
|
||||
@@ -84,3 +84,16 @@ export type FinalChart = {
|
||||
series: IChartSerie[];
|
||||
metrics: Metrics;
|
||||
};
|
||||
|
||||
export type ISetCookie = (
|
||||
key: string,
|
||||
value: string,
|
||||
options: {
|
||||
maxAge?: number;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
sameSite?: 'lax' | 'strict' | 'none';
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
},
|
||||
) => void;
|
||||
|
||||
Reference in New Issue
Block a user