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

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

* minor fixes

* remove notification preferences

* decrease live events interval

fix(api): cookies..

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

View File

@@ -0,0 +1,19 @@
// Sorry co.uk, but you're not a top domain
const parseCookieDomain = (url: string) => {
const domain = new URL(url);
return {
domain: domain.hostname.split('.').slice(-2).join('.'),
secure: domain.protocol === 'https:',
};
};
const parsed = parseCookieDomain(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? '');
export const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
export const COOKIE_OPTIONS = {
domain: parsed.domain,
secure: parsed.secure,
sameSite: 'lax',
httpOnly: true,
path: '/',
} as const;

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

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

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

@@ -0,0 +1,10 @@
import { unstable_cache } from 'next/cache';
import { cookies } from 'next/headers';
import { validateSessionToken } from './src/session';
export const auth = async () => {
const token = (await cookies().get('session')?.value) ?? null;
return cachedAuth(token);
};
export const cachedAuth = unstable_cache(validateSessionToken);

View File

@@ -0,0 +1,27 @@
{
"name": "@openpanel/auth",
"version": "0.0.1",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@openpanel/db": "workspace:^",
"@openpanel/validation": "workspace:^",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"arctic": "^2.3.0"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@types/node": "20.14.8",
"@types/react": "^18.2.0",
"prisma": "^5.1.1",
"typescript": "^5.2.2"
},
"peerDependencies": {
"next": "14.2.1",
"react": "18.2.0"
}
}

View File

@@ -0,0 +1,18 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
);

View File

@@ -0,0 +1,20 @@
import type { ISetCookie } from '@openpanel/validation';
import { COOKIE_OPTIONS } from '../constants';
export function setSessionTokenCookie(
setCookie: ISetCookie,
token: string,
expiresAt: Date,
): void {
setCookie('session', token, {
maxAge: expiresAt.getTime() - new Date().getTime(),
...COOKIE_OPTIONS,
});
}
export function deleteSessionTokenCookie(setCookie: ISetCookie): void {
setCookie('session', '', {
maxAge: 0,
...COOKIE_OPTIONS,
});
}

View File

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

View File

@@ -0,0 +1,18 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
);

View File

@@ -0,0 +1,41 @@
import { hash, verify } from '@node-rs/argon2';
import { sha1 } from '@oslojs/crypto/sha1';
import { encodeHexLowerCase } from '@oslojs/encoding';
export async function hashPassword(password: string): Promise<string> {
return await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}
export async function verifyPasswordHash(
hash: string,
password: string,
): Promise<boolean> {
return await verify(hash, password);
}
export async function verifyPasswordStrength(
password: string,
): Promise<boolean> {
if (password.length < 8 || password.length > 255) {
return false;
}
const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password)));
const hashPrefix = hash.slice(0, 5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${hashPrefix}`,
);
const data = await response.text();
const items = data.split('\n');
for (const item of items) {
const hashSuffix = item.slice(0, 35).toLowerCase();
if (hash === hashPrefix + hashSuffix) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,83 @@
import crypto from 'node:crypto';
import { type Session, type User, db } from '@openpanel/db';
import { sha256 } from '@oslojs/crypto/sha2';
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from '@oslojs/encoding';
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: string,
): Promise<Session> {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
createdAt: new Date(),
updatedAt: new Date(),
};
await db.session.create({
data: session,
});
return session;
}
export const EMPTY_SESSION: SessionValidationResult = {
session: null,
user: null,
userId: null,
};
export async function validateSessionToken(
token: string | null,
): Promise<SessionValidationResult> {
if (!token) {
return EMPTY_SESSION;
}
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const result = await db.session.findUnique({
where: {
id: sessionId,
},
include: {
user: true,
},
});
if (result === null) {
return EMPTY_SESSION;
}
const { user, ...session } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await db.session.delete({ where: { id: sessionId } });
return EMPTY_SESSION;
}
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await db.session.update({
where: {
id: session.id,
},
data: {
expiresAt: session.expiresAt,
},
});
}
return { session, user, userId: user.id };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.session.delete({ where: { id: sessionId } });
}
export type SessionValidationResult =
| { session: Session; user: User; userId: string }
| { session: null; user: null; userId: null };

View File

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

View File

@@ -23,7 +23,7 @@
"@openpanel/db": "workspace:^",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/node": "^20.14.10",
"@types/node": "20.14.8",
"@types/progress": "^2.0.7",
"@types/ramda": "^0.30.1",
"tsup": "^7.2.0",

View File

@@ -19,7 +19,7 @@
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*",
"@types/node": "^18.16.0",
"@types/node": "20.14.8",
"@types/ramda": "^0.29.6",
"@types/ua-parser-js": "^0.7.39",
"prisma": "^5.1.1",

View File

@@ -49,7 +49,12 @@ export async function verifyPassword(
reject(err);
}
// compare the new supplied password with the hashed password using timeSafeEqual
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
resolve(
timingSafeEqual(
new Uint8Array(hashKeyBuff),
new Uint8Array(derivedKey),
),
);
});
});
}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { db } from '../index';
import { printBoxMessage } from './helpers';
const simpleCsvParser = (csv: string): Record<string, unknown>[] => {
const rows = csv.split('\n');
const headers = rows[0]!.split(',');
return rows.slice(1).map((row) =>
row.split(',').reduce(
(acc, curr, index) => {
acc[headers[index]!] = curr;
return acc;
},
{} as Record<string, unknown>,
),
);
};
async function checkFileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true; // File exists
} catch (error) {
return false; // File does not exist
}
}
export async function up() {
const accountCount = await db.account.count();
const userCount = await db.user.count();
if (accountCount > 0) {
printBoxMessage('⏭️ Skipping Migration ⏭️', ['Accounts already migrated']);
return;
}
if (userCount === 0) {
printBoxMessage('⏭️ Skipping Migration ⏭️', [
'No users found, skipping migration',
]);
return;
}
const dumppath = path.join(__dirname, 'users-dump.csv');
// check if file exists
if (!(await checkFileExists(dumppath))) {
printBoxMessage('⚠️ Missing Required File ⚠️', [
`File not found: ${dumppath}`,
'This file is required to run this migration',
'',
'You can export it from:',
'Clerk > Configure > Settings > Export all users',
]);
throw new Error('Required users dump file not found');
}
const csv = await fs.readFile(path.join(__dirname, 'users-dump.csv'), 'utf8');
const data = simpleCsvParser(csv);
for (const row of data) {
const email =
row.primary_email_address ||
row.verified_email_addresses ||
row.unverified_email_addresses;
if (!email) {
continue;
}
const user = await db.user.findUnique({
where: {
email: String(email),
},
});
if (!user) {
continue;
}
await db.account.create({
data: {
userId: user.id,
provider: row.password_digest ? 'email' : 'oauth',
providerId: null,
password: row.password_digest ? String(row.password_digest) : null,
},
});
}
}

View File

@@ -0,0 +1,13 @@
export function printBoxMessage(title: string, lines: (string | unknown)[]) {
console.log('┌──┐');
console.log('│');
if (title) {
console.log(`${title}`);
console.log('│');
}
lines.forEach((line) => {
console.log(`${line}`);
});
console.log('│');
console.log('└──┘');
}

View File

@@ -0,0 +1,62 @@
import fs from 'node:fs';
import path from 'node:path';
import { ch, db } from '../index';
import { printBoxMessage } from './helpers';
async function migrate() {
const args = process.argv.slice(2);
const migration = args[0];
const migrationsDir = path.join(__dirname, '..', 'code-migrations');
const migrations = fs.readdirSync(migrationsDir).filter((file) => {
const version = file.split('-')[0];
return (
!Number.isNaN(Number.parseInt(version ?? '')) && file.endsWith('.ts')
);
});
if (migration) {
await runMigration(migrationsDir, migration);
} else {
const finishedMigrations = await db.codeMigration.findMany();
for (const file of migrations) {
if (finishedMigrations.some((migration) => migration.name === file)) {
printBoxMessage('⏭️ Skipping Migration ⏭️', [`${file}`]);
continue;
}
await runMigration(migrationsDir, file);
}
}
console.log('Migrations finished');
process.exit(0);
}
async function runMigration(migrationsDir: string, file: string) {
printBoxMessage('⚡️ Running Migration ⚡️ ', [`${file}`]);
try {
const migration = await import(path.join(migrationsDir, file));
await migration.up();
await db.codeMigration.upsert({
where: {
name: file,
},
update: {
name: file,
},
create: {
name: file,
},
});
} catch (error) {
printBoxMessage('❌ Migration Failed ❌', [
`Error running migration ${file}:`,
error,
]);
process.exit(1);
}
}
migrate();

View File

@@ -252,8 +252,9 @@ CREATE TABLE IF NOT EXISTS profile_aliases_distributed ON CLUSTER '{cluster}' AS
-- +goose StatementBegin
INSERT INTO events_replicated
SELECT *
FROM events_v2 -- +goose StatementEnd
-- +goose StatementBegin
FROM events_v2;
-- +goose StatementEnd
-- +goose StatementBegin
INSERT INTO events_bots_replicated
SELECT *
FROM events_bots;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

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

View 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"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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