mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import NextAuth from 'next-auth';
|
|
import Credentials from 'next-auth/providers/credentials';
|
|
import bcrypt from 'bcryptjs';
|
|
import { db } from '@/lib/db';
|
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
|
import { hasBillingAccess } from '@/lib/billing';
|
|
|
|
// Dummy hash for timing-safe comparison when user doesn't exist
|
|
// This prevents user enumeration via timing attacks
|
|
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
// Note: We don't use PrismaAdapter with Credentials + JWT strategy
|
|
// The adapter is for OAuth providers that need to store accounts/sessions in DB
|
|
providers: [
|
|
Credentials({
|
|
name: 'credentials',
|
|
credentials: {
|
|
email: { label: 'Email', type: 'email' },
|
|
password: { label: 'Password', type: 'password' },
|
|
},
|
|
async authorize(credentials) {
|
|
if (!credentials?.email || !credentials?.password) {
|
|
return null;
|
|
}
|
|
|
|
const email = credentials.email as string;
|
|
const password = credentials.password as string;
|
|
|
|
// Find user by email
|
|
const user = await db.user.findUnique({
|
|
where: { email: email.toLowerCase() },
|
|
});
|
|
|
|
// Always perform bcrypt comparison to prevent timing attacks
|
|
// If user doesn't exist, compare against dummy hash
|
|
const hashToCompare = user?.password || DUMMY_HASH;
|
|
const isValidPassword = await bcrypt.compare(password, hashToCompare);
|
|
|
|
// Only return user if they exist AND password is valid
|
|
if (!user || !user.password || !isValidPassword) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
image: user.image,
|
|
};
|
|
},
|
|
}),
|
|
],
|
|
session: {
|
|
strategy: 'jwt',
|
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
},
|
|
pages: {
|
|
signIn: '/login',
|
|
signOut: '/signout',
|
|
},
|
|
callbacks: {
|
|
async session({ session, token }) {
|
|
if (token.sub && session.user) {
|
|
session.user.id = token.sub;
|
|
session.user.name = token.name || null;
|
|
session.user.isAdmin = token.isAdmin as boolean;
|
|
}
|
|
return session;
|
|
},
|
|
async jwt({ token, user }) {
|
|
if (user) {
|
|
token.sub = user.id;
|
|
token.name = user.name;
|
|
token.email = user.email; // explicitly ensure email is in the token
|
|
}
|
|
|
|
// Check if user is admin based on emails list on EVERY request to ensure env changes are picked up
|
|
if (token.email) {
|
|
const adminEmails = process.env.ADMIN_EMAILS
|
|
? process.env.ADMIN_EMAILS.split(',').map((e: string) => e.trim().toLowerCase())
|
|
: [];
|
|
token.isAdmin = adminEmails.includes((token.email as string).toLowerCase());
|
|
}
|
|
|
|
return token;
|
|
},
|
|
},
|
|
});
|
|
|
|
type ProjectAccessIntent = 'view' | 'manage' | 'delete';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fast-path: pre-fetch access data alongside any existing DB query so that
|
|
// computeProjectAccess() can resolve the result with zero extra round-trips.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Prisma include fragment to attach to any project fetch. */
|
|
export function projectAccessInclude(userId: string | undefined) {
|
|
return {
|
|
workspace: {
|
|
select: {
|
|
id: true,
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
members: userId
|
|
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
|
|
: { take: 0, select: { role: true } },
|
|
},
|
|
},
|
|
members: userId
|
|
? { where: { userId }, take: 1, orderBy: { createdAt: 'asc' as const }, select: { role: true } }
|
|
: { take: 0, select: { role: true } },
|
|
};
|
|
}
|
|
|
|
type ProjectAccessIncludes = ReturnType<typeof projectAccessInclude>;
|
|
type WorkspaceForAccess = ProjectAccessIncludes['workspace']['select'] extends object
|
|
? {
|
|
id: string;
|
|
ownerId: string;
|
|
owner: Parameters<typeof hasBillingAccess>[0] | null;
|
|
members: Array<{ role: WorkspaceMemberRole }>;
|
|
}
|
|
: never;
|
|
|
|
export type EnrichedProjectForAccess = {
|
|
id: string;
|
|
ownerId: string;
|
|
workspaceId: string;
|
|
visibility: string;
|
|
workspace: WorkspaceForAccess;
|
|
members: Array<{ role: ProjectMemberRole }>;
|
|
};
|
|
|
|
/**
|
|
* Pure access computation — no DB queries.
|
|
* Use after fetching a project with `projectAccessInclude(userId)`.
|
|
*/
|
|
export function computeProjectAccess(
|
|
project: EnrichedProjectForAccess,
|
|
userId: string | undefined,
|
|
) {
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
const projectMember = project.members[0] ?? null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
const workspaceOwnerBillingAccess = project.workspace.owner
|
|
? hasBillingAccess(project.workspace.owner)
|
|
: false;
|
|
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
if (userId === project.workspace.ownerId) {
|
|
workspaceRole = 'OWNER';
|
|
} else {
|
|
const wsMember = project.workspace.members[0] ?? null;
|
|
if (wsMember) workspaceRole = wsMember.role;
|
|
}
|
|
|
|
const isWorkspaceMember = !!workspaceRole;
|
|
const isWorkspaceAdmin =
|
|
workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
|
|
|
const hasAccess =
|
|
workspaceOwnerBillingAccess &&
|
|
(isOwner || isProjectMember || isPublic || isWorkspaceMember);
|
|
const canEdit =
|
|
workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
|
|
const canDelete =
|
|
workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
|
|
|
|
return {
|
|
isOwner,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
isWorkspaceMember,
|
|
isWorkspaceAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
};
|
|
}
|
|
|
|
// Helper to check project access including workspace membership
|
|
export async function checkProjectAccess(
|
|
project: { id: string; ownerId: string; workspaceId: string; visibility: string },
|
|
userId: string | undefined,
|
|
options?: { intent?: ProjectAccessIntent }
|
|
) {
|
|
const intent = options?.intent ?? 'view';
|
|
const isOwner = userId === project.ownerId;
|
|
const isPublic = project.visibility === 'PUBLIC';
|
|
|
|
// Get project membership
|
|
const projectMember = userId
|
|
? await db.projectMember.findUnique({
|
|
where: { projectId_userId: { projectId: project.id, userId } },
|
|
})
|
|
: null;
|
|
const isProjectMember = !!projectMember;
|
|
const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN;
|
|
|
|
const needsWorkspaceForAccess = !!userId && !isOwner && !isProjectMember && !isPublic;
|
|
const needsWorkspaceForActions = !!userId && !isOwner && intent !== 'view';
|
|
const shouldLoadWorkspaceRole = needsWorkspaceForAccess || needsWorkspaceForActions;
|
|
|
|
// Check workspace membership/role
|
|
let workspaceRole: WorkspaceMemberRole | 'OWNER' | null = null;
|
|
let workspaceOwnerBillingAccess = false;
|
|
if (shouldLoadWorkspaceRole && userId) {
|
|
const [wsMember, wsOwner] = await Promise.all([
|
|
db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
|
}),
|
|
db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
ownerId: true,
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
if (wsOwner?.ownerId === userId) {
|
|
workspaceRole = 'OWNER';
|
|
} else if (wsMember) {
|
|
workspaceRole = wsMember.role;
|
|
}
|
|
|
|
if (wsOwner?.owner) {
|
|
workspaceOwnerBillingAccess = hasBillingAccess(wsOwner.owner);
|
|
}
|
|
} else {
|
|
const wsOwner = await db.workspace.findUnique({
|
|
where: { id: project.workspaceId },
|
|
select: {
|
|
owner: {
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false;
|
|
}
|
|
const isWorkspaceMember = !!workspaceRole;
|
|
const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER';
|
|
|
|
const hasAccess = workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember);
|
|
const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin);
|
|
const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER');
|
|
|
|
return {
|
|
isOwner,
|
|
isProjectMember,
|
|
isProjectAdmin,
|
|
isWorkspaceMember,
|
|
isWorkspaceAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive: workspaceOwnerBillingAccess,
|
|
};
|
|
}
|
|
|
|
// Helper to check workspace access
|
|
export async function checkWorkspaceAccess(
|
|
workspace: { id: string; ownerId: string },
|
|
userId: string | undefined
|
|
) {
|
|
const isOwner = userId === workspace.ownerId;
|
|
|
|
// Get workspace membership
|
|
const workspaceMember = userId
|
|
? await db.workspaceMember.findUnique({
|
|
where: { workspaceId_userId: { workspaceId: workspace.id, userId } },
|
|
})
|
|
: null;
|
|
const isMember = !!workspaceMember;
|
|
const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN;
|
|
|
|
const owner = await db.user.findUnique({
|
|
where: { id: workspace.ownerId },
|
|
select: {
|
|
subscriptionStatus: true,
|
|
trialEndsAt: true,
|
|
stripeCurrentPeriodEnd: true,
|
|
billingAccessEndedAt: true,
|
|
},
|
|
});
|
|
const ownerBillingActive = owner ? hasBillingAccess(owner) : false;
|
|
|
|
const hasAccess = ownerBillingActive && (isOwner || isMember);
|
|
const canEdit = ownerBillingActive && (isOwner || isAdmin);
|
|
const canDelete = ownerBillingActive && isOwner;
|
|
|
|
return {
|
|
isOwner,
|
|
isMember,
|
|
isAdmin,
|
|
hasAccess,
|
|
canEdit,
|
|
canDelete,
|
|
ownerBillingActive,
|
|
};
|
|
}
|