diff --git a/cli/src/commands/pull.ts b/cli/src/commands/pull.ts index 02d00fd3..b0f6113b 100644 --- a/cli/src/commands/pull.ts +++ b/cli/src/commands/pull.ts @@ -4,7 +4,7 @@ import prompts from "prompts"; import { api, ApiRequestError, Blueprint } from "../api.js"; import { isAuthenticated } from "../config.js"; import { writeFile, mkdir, readFile } from "fs/promises"; -import { join, dirname } from "path"; +import { dirname, resolve, relative } from "path"; import { existsSync } from "fs"; import { trackBlueprint, @@ -19,6 +19,19 @@ interface PullOptions { track?: boolean; // Track the blueprint for future syncs (default: true) } +/** + * Resolve a path under a root directory, rejecting traversal attempts. + * Throws if the resolved path escapes the root. + */ +function safePath(root: string, untrusted: string): string { + const resolved = resolve(root, untrusted); + const rel = relative(root, resolved); + if (rel.startsWith("..") || resolve(root, rel) !== resolved) { + throw new Error(`Path traversal blocked: ${untrusted}`); + } + return resolved; +} + // Mapping of blueprint types to filenames const TYPE_TO_FILENAME: Record = { AGENTS_MD: "AGENTS.md", @@ -135,7 +148,14 @@ async function pullHierarchy( for (const bp of blueprints) { // Determine output path - use repository_path if available const filename = bp.repository_path || TYPE_TO_FILENAME[bp.type] || "ai-config.md"; - const outputPath = join(options.output, filename); + let outputPath: string; + try { + outputPath = safePath(resolve(options.output), filename); + } catch { + console.log(chalk.red(` ✗ Skipped (invalid path): ${filename}`)); + skipped++; + continue; + } // Check if file exists if (existsSync(outputPath) && !options.yes) { @@ -290,7 +310,13 @@ async function pullBlueprint( // Determine output filename - use repository_path if available for hierarchy blueprints const filename = blueprint.repository_path || TYPE_TO_FILENAME[blueprint.type] || "ai-config.md"; - const outputPath = join(options.output, filename); + let outputPath: string; + try { + outputPath = safePath(resolve(options.output), filename); + } catch { + console.error(chalk.red(`✗ Invalid file path: ${filename}`)); + process.exit(1); + } // Check if file exists and show diff preview let localContent: string | null = null; diff --git a/src/app/api/auth/sso/callback/oidc/route.ts b/src/app/api/auth/sso/callback/oidc/route.ts index 17c4bc0b..313b4707 100644 --- a/src/app/api/auth/sso/callback/oidc/route.ts +++ b/src/app/api/auth/sso/callback/oidc/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { createHmac } from "crypto"; +import { createHmac, randomUUID } from "crypto"; import { prismaUsers } from "@/lib/db-users"; import { ENABLE_SSO } from "@/lib/feature-flags"; import { decryptSSOConfig } from "@/lib/sso-encryption"; @@ -57,7 +57,7 @@ export async function GET(request: NextRequest) { // Find team and SSO config const team = await prismaUsers.team.findUnique({ where: { slug: teamSlug }, - include: { ssoConfig: true }, + select: { id: true, slug: true, maxSeats: true, ssoConfig: true }, }); if (!team?.ssoConfig || !team.ssoConfig.enabled || team.ssoConfig.provider !== "OIDC") { @@ -147,31 +147,68 @@ export async function GET(request: NextRequest) { } } - // Find or create user, then add to team - let user = await prismaUsers.user.findUnique({ where: { email } }); - - if (!user) { - user = await prismaUsers.user.create({ - data: { - email, - name, - }, - }); + // Atomically find-or-create user and enforce seat limit in a serializable transaction + // to prevent concurrent SSO logins from exceeding maxSeats. + // Retry up to 3 times on serialization conflicts (Prisma P2034). + const MAX_RETRIES = 3; + let user: { id: string; email: string | null; name: string | null } | undefined; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + let seatLimitReached = false; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user = await prismaUsers.$transaction(async (tx: any) => { + let u = await tx.user.findUnique({ where: { email } }); + const existingMembership = u + ? await tx.teamMember.findUnique({ + where: { teamId_userId: { teamId: team.id, userId: u.id } }, + }) + : null; + + if (!existingMembership) { + const currentMembers = await tx.teamMember.count({ + where: { teamId: team.id }, + }); + if (currentMembers >= team.maxSeats) { + seatLimitReached = true; + throw new Error("SEAT_LIMIT_REACHED"); + } + } + + if (!u) { + u = await tx.user.create({ data: { email, name } }); + } + + if (!existingMembership) { + await tx.teamMember.create({ + data: { teamId: team.id, userId: u.id, role: "MEMBER" }, + }); + } + + return u; + }, { isolationLevel: "Serializable" }); + break; // Success — exit retry loop + } catch (err) { + if (seatLimitReached) { + return NextResponse.redirect( + new URL("/auth/signin?error=SSOSeatLimitReached", request.url) + ); + } + // P2034 = serialization failure, P2002 = unique constraint (concurrent insert) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const code = (err as any)?.code; + if ((code === "P2034" || code === "P2002") && attempt < MAX_RETRIES - 1) { + continue; + } + throw err; + } } - // Ensure user is a member of the team - const existingMembership = await prismaUsers.teamMember.findUnique({ - where: { teamId_userId: { teamId: team.id, userId: user.id } }, - }); - - if (!existingMembership) { - await prismaUsers.teamMember.create({ - data: { - teamId: team.id, - userId: user.id, - role: "MEMBER", - }, - }); + // If we get here without user, all retries failed with serialization conflicts + if (!user) { + return NextResponse.redirect( + new URL("/auth/signin?error=SSOTemporaryError", request.url) + ); } // Update last SSO sync @@ -180,15 +217,28 @@ export async function GET(request: NextRequest) { data: { lastSyncAt: new Date() }, }); + // Generate a one-time nonce for the SSO completion handoff + const nonce = randomUUID(); + const expires = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes + + await prismaUsers.verificationToken.create({ + data: { + identifier: `sso:${user.id}`, + token: nonce, + expires, + }, + }); + // Redirect to the SSO sign-in completion page // This page triggers NextAuth signIn() with the SSO credentials const completeUrl = new URL("/auth/sso-complete", baseUrl); completeUrl.searchParams.set("userId", user.id); completeUrl.searchParams.set("email", email); completeUrl.searchParams.set("teamId", team.id); + completeUrl.searchParams.set("nonce", nonce); completeUrl.searchParams.set("callbackUrl", callbackUrl); - // Sign the params to prevent tampering - const signData = `${user.id}:${email}:${team.id}`; + // Sign all params including nonce to prevent tampering + const signData = `${user.id}:${email}:${team.id}:${nonce}`; const signature = createHmac("sha256", secret).update(signData).digest("hex"); completeUrl.searchParams.set("sig", signature); diff --git a/src/app/api/billing/status/route.ts b/src/app/api/billing/status/route.ts index 0d2420ef..02c48d4a 100644 --- a/src/app/api/billing/status/route.ts +++ b/src/app/api/billing/status/route.ts @@ -40,20 +40,22 @@ export async function GET() { ); } - const teamMembership = user.teamMemberships[0]; const isAdmin = user.role === "ADMIN" || user.role === "SUPERADMIN"; + const teams = user.teamMemberships.map((m) => ({ + id: m.team.id, + name: m.team.name, + slug: m.team.slug, + logo: m.team.logo, + role: m.role, + })); return NextResponse.json({ plan: "free", isAdmin, - isTeamsUser: !!teamMembership, - team: teamMembership ? { - id: teamMembership.team.id, - name: teamMembership.team.name, - slug: teamMembership.team.slug, - logo: teamMembership.team.logo, - role: teamMembership.role, - } : null, + isTeamsUser: teams.length > 0, + // Keep backward-compat: "team" returns first membership + team: teams[0] ?? null, + teams, }); } catch (error) { console.error("Error fetching subscription status:", error); diff --git a/src/app/api/blueprints/[id]/download/route.ts b/src/app/api/blueprints/[id]/download/route.ts index c25e5e46..b76081a5 100644 --- a/src/app/api/blueprints/[id]/download/route.ts +++ b/src/app/api/blueprints/[id]/download/route.ts @@ -11,7 +11,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { const session = await getServerSession(authOptions); - const { id } = await params; + const { id: rawId } = await params; try { const body = await request.json().catch(() => ({})); @@ -30,8 +30,11 @@ export async function POST( } } - // Determine template type from ID prefix - const templateType = id.startsWith("sys_") ? "system" : "user"; + // Determine template type from ID prefix and strip prefix for DB queries + const templateType = rawId.startsWith("sys_") ? "system" : "user"; + const dbId = rawId.startsWith("bp_") ? rawId.slice(3) + : rawId.startsWith("sys_") ? rawId.slice(4) + : rawId; // Deduplicate: check for recent download from same IP + template in the last hour const clientIP = request.headers.get("cf-connecting-ip") || @@ -43,7 +46,7 @@ export async function POST( const recentDownload = await prismaUsers.templateDownload.findFirst({ where: { - templateId: id, + templateId: rawId, templateType, ipHash, createdAt: { gte: oneHourAgo }, @@ -60,22 +63,22 @@ export async function POST( await prismaUsers.templateDownload.create({ data: { userId: session?.user?.id || null, - templateId: id, + templateId: rawId, templateType, platform, ipHash, }, }); - // Increment downloads count on template + // Increment downloads count on template — use dbId (without prefix) if (templateType === "system") { await prismaApp.systemTemplate.update({ - where: { id }, + where: { id: dbId }, data: { downloads: { increment: 1 } }, }); } else { await prismaUsers.userTemplate.update({ - where: { id }, + where: { id: dbId }, data: { downloads: { increment: 1 } }, }); } @@ -93,14 +96,45 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const templateType = id.startsWith("sys_") ? "system" : "user"; + const { id: rawId } = await params; + const templateType = rawId.startsWith("sys_") ? "system" : "user"; + const dbId = rawId.startsWith("bp_") ? rawId.slice(3) + : rawId.startsWith("sys_") ? rawId.slice(4) + : rawId; try { - // Get total downloads + // For user blueprints, verify the requester owns it or it's public + if (templateType === "user") { + const template = await prismaUsers.userTemplate.findUnique({ + where: { id: dbId }, + select: { visibility: true, userId: true, teamId: true }, + }); + if (!template) { + return NextResponse.json({ total: 0, byPlatform: {} }); + } + if (template.visibility !== "PUBLIC") { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const isOwner = template.userId === session.user.id; + let isTeamMember = false; + if (template.teamId) { + const membership = await prismaUsers.teamMember.findUnique({ + where: { teamId_userId: { teamId: template.teamId, userId: session.user.id } }, + }); + isTeamMember = !!membership; + } + if (!isOwner && !isTeamMember) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } + } + + // Get total downloads — templateDownload stores the raw ID (with bp_ prefix) const downloadCount = await prismaUsers.templateDownload.count({ where: { - templateId: id, + templateId: rawId, templateType, }, }); @@ -109,7 +143,7 @@ export async function GET( const platformStats = await prismaUsers.templateDownload.groupBy({ by: ["platform"], where: { - templateId: id, + templateId: rawId, templateType, }, _count: true, diff --git a/src/app/api/blueprints/route.ts b/src/app/api/blueprints/route.ts index 8b6db376..5128a9fc 100644 --- a/src/app/api/blueprints/route.ts +++ b/src/app/api/blueprints/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { posix as posixPath } from "path"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; @@ -55,6 +56,13 @@ type BlueprintType = (typeof BLUEPRINT_TYPES)[number]; /** * Determine tier based on total word count (all content including comments/headers). */ +function sanitizeRepositoryPath(input: string | null | undefined): string | null { + if (!input?.trim()) return null; + const normalized = posixPath.normalize(input.trim()).replace(/^[/\\]+/, ""); + if (normalized.startsWith("..") || normalized.includes("/..")) return null; + return normalized || null; +} + function determineTier(content: string): "SHORT" | "INTERMEDIATE" | "LONG" | "SUPERLONG" { const wordCount = content.split(/\s+/).filter(Boolean).length; @@ -501,7 +509,7 @@ export async function POST(request: NextRequest) { // Hierarchy fields hierarchyId: hierarchyId?.trim() || null, parentId: validatedParentId, - repositoryPath: repositoryPath?.trim() || null, + repositoryPath: sanitizeRepositoryPath(repositoryPath) || null, }, }); diff --git a/src/app/api/hierarchies/[id]/blueprints/route.ts b/src/app/api/hierarchies/[id]/blueprints/route.ts index ef8d501c..01ec1c30 100644 --- a/src/app/api/hierarchies/[id]/blueprints/route.ts +++ b/src/app/api/hierarchies/[id]/blueprints/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { posix as posixPath } from "path"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; @@ -10,6 +11,13 @@ function fromHierarchyId(id: string): string { return id.startsWith("ha_") ? id.slice(3) : id; } +function sanitizeRepositoryPath(input: string | null | undefined): string | null { + if (!input?.trim()) return null; + const normalized = posixPath.normalize(input.trim()).replace(/^[/\\]+/, ""); + if (normalized.startsWith("..") || normalized.includes("/..")) return null; + return normalized || null; +} + /** * Helper to strip bp_ prefix from blueprint ID */ @@ -128,7 +136,7 @@ export async function POST( where: { id: bpId }, data: { hierarchyId, - repositoryPath: repositoryPath.trim(), + repositoryPath: sanitizeRepositoryPath(repositoryPath) ?? "", parentId: parentBpId, }, select: { diff --git a/src/app/api/teams/invite/accept/route.ts b/src/app/api/teams/invite/accept/route.ts index 596d6018..19d6bad6 100644 --- a/src/app/api/teams/invite/accept/route.ts +++ b/src/app/api/teams/invite/accept/route.ts @@ -101,45 +101,79 @@ export async function POST(request: NextRequest) { ); } - // Check seat availability - const currentMembers = invitation.team._count.members; - if (currentMembers >= invitation.team.maxSeats) { + // Atomically check seats + create membership in a serializable transaction. + // Retry up to 3 times on serialization conflicts (Prisma P2034). + const MAX_RETRIES = 3; + let accepted = false; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + let seatLimitReached = false; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await prismaUsers.$transaction(async (tx: any) => { + // Re-check membership inside transaction (handles parallel submissions) + const alreadyMember = await tx.teamMember.findUnique({ + where: { teamId_userId: { teamId: invitation.teamId, userId: session.user.id } }, + }); + if (alreadyMember) { + accepted = true; // Treat as idempotent success + return; + } + + const currentMembers = await tx.teamMember.count({ + where: { teamId: invitation.teamId }, + }); + if (currentMembers >= invitation.team.maxSeats) { + seatLimitReached = true; + throw new Error("SEAT_LIMIT_REACHED"); + } + + await tx.teamMember.create({ + data: { + teamId: invitation.teamId, + userId: session.user.id, + role: invitation.role, + isActiveThisCycle: true, + lastActiveAt: new Date(), + }, + }); + + await tx.teamInvitation.update({ + where: { id: invitation.id }, + data: { status: "ACCEPTED", acceptedAt: new Date() }, + }); + + await tx.user.update({ + where: { id: session.user.id }, + data: { subscriptionPlan: "TEAMS", lastLoginAt: new Date() }, + }); + }, { isolationLevel: "Serializable" }); + accepted = true; + break; // Success — exit retry loop + } catch (err) { + if (seatLimitReached) { + return NextResponse.json( + { error: "This team has reached its maximum seat limit. Contact the team admin." }, + { status: 400 } + ); + } + // P2034 = serialization failure, P2002 = unique constraint (concurrent insert) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const code = (err as any)?.code; + if ((code === "P2034" || code === "P2002") && attempt < MAX_RETRIES - 1) { + continue; + } + throw err; + } + } + + if (!accepted) { return NextResponse.json( - { error: "This team has reached its maximum seat limit. Contact the team admin." }, - { status: 400 } + { error: "Failed to accept invitation due to a conflict. Please try again." }, + { status: 409 } ); } - // Accept the invitation: create membership and update invitation - await prismaUsers.$transaction([ - // Create team membership - prismaUsers.teamMember.create({ - data: { - teamId: invitation.teamId, - userId: session.user.id, - role: invitation.role, - isActiveThisCycle: true, - lastActiveAt: new Date(), - }, - }), - // Mark invitation as accepted - prismaUsers.teamInvitation.update({ - where: { id: invitation.id }, - data: { - status: "ACCEPTED", - acceptedAt: new Date(), - }, - }), - // Update user's subscription plan to TEAMS - prismaUsers.user.update({ - where: { id: session.user.id }, - data: { - subscriptionPlan: "TEAMS", - lastLoginAt: new Date(), - }, - }), - ]); - return NextResponse.json({ message: `Welcome to ${invitation.team.name}!`, team: { diff --git a/src/app/api/user/dashboard/route.ts b/src/app/api/user/dashboard/route.ts index 47deb89b..48e856d5 100644 --- a/src/app/api/user/dashboard/route.ts +++ b/src/app/api/user/dashboard/route.ts @@ -8,7 +8,7 @@ import { prismaApp } from "@/lib/db-app"; * Get team membership for a user */ async function getUserTeamInfo(userId: string) { - const membership = await prismaUsers.teamMember.findFirst({ + const memberships = await prismaUsers.teamMember.findMany({ where: { userId }, include: { team: { @@ -24,16 +24,27 @@ async function getUserTeamInfo(userId: string) { }, }, }); - - if (!membership) return null; - + + if (memberships.length === 0) return null; + + // Return first team for backward compat, but expose all + const first = memberships[0]; return { - teamId: membership.team.id, - teamName: membership.team.name, - teamSlug: membership.team.slug, - role: membership.role, - memberCount: membership.team._count.members, - memberIds: membership.team.members.map(m => m.userId), + teamId: first.team.id, + teamName: first.team.name, + teamSlug: first.team.slug, + role: first.role, + memberCount: first.team._count.members, + memberIds: first.team.members.map(m => m.userId), + // All teams (aggregated member IDs across all teams) + allTeams: memberships.map(m => ({ + teamId: m.team.id, + teamName: m.team.name, + teamSlug: m.team.slug, + role: m.role, + memberCount: m.team._count.members, + memberIds: m.team.members.map(member => member.userId), + })), }; } @@ -192,38 +203,46 @@ export async function GET() { // Team-specific data (if user is in a team) // eslint-disable-next-line @typescript-eslint/no-explicit-any let teamBlueprints: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let allTeamsBlueprints: Record = {}; if (teamInfo) { - // Get team-shared blueprints (created by team members and marked as TEAM visibility) - teamBlueprints = await prismaUsers.userTemplate.findMany({ - where: { - userId: { in: teamInfo.memberIds }, - visibility: "TEAM", - teamId: teamInfo.teamId, - }, - orderBy: { createdAt: "desc" }, - take: 6, - select: { - id: true, - name: true, - type: true, - downloads: true, - favorites: true, - isPublic: true, - createdAt: true, - user: { - select: { name: true, displayName: true }, + // Fetch blueprints for ALL teams the user belongs to + const teamsToQuery = teamInfo.allTeams || [teamInfo]; + for (const t of teamsToQuery) { + const bps = await prismaUsers.userTemplate.findMany({ + where: { + userId: { in: t.memberIds }, + visibility: "TEAM", + teamId: t.teamId, }, - }, - }).then(templates => templates.map(template => ({ - id: `bp_${template.id}`, - name: template.name, - type: template.type, - downloads: template.downloads, - favorites: template.favorites, - isPublic: template.isPublic, - createdAt: template.createdAt, - author: template.user?.displayName || template.user?.name || "Team member", - }))); + orderBy: { createdAt: "desc" }, + take: 6, + select: { + id: true, + name: true, + type: true, + downloads: true, + favorites: true, + isPublic: true, + createdAt: true, + user: { + select: { name: true, displayName: true }, + }, + }, + }).then(templates => templates.map(template => ({ + id: `bp_${template.id}`, + name: template.name, + type: template.type, + downloads: template.downloads, + favorites: template.favorites, + isPublic: template.isPublic, + createdAt: template.createdAt, + author: template.user?.displayName || template.user?.name || "Team member", + }))); + allTeamsBlueprints[t.teamId] = bps; + } + // Backward compat: teamBlueprints = first team's blueprints + teamBlueprints = allTeamsBlueprints[teamInfo.teamId] || []; } // Enrich activity with template names @@ -337,6 +356,14 @@ export async function GET() { role: teamInfo.role, memberCount: teamInfo.memberCount, } : null, + teams: teamInfo?.allTeams?.map(t => ({ + id: t.teamId, + name: t.teamName, + slug: t.teamSlug, + role: t.role, + memberCount: t.memberCount, + blueprints: allTeamsBlueprints[t.teamId] || [], + })) || [], teamBlueprints: teamInfo ? teamBlueprints : [], }); } catch (error) { diff --git a/src/app/api/v1/blueprints/route.ts b/src/app/api/v1/blueprints/route.ts index f2c879d4..6db497cb 100644 --- a/src/app/api/v1/blueprints/route.ts +++ b/src/app/api/v1/blueprints/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createHash } from "crypto"; +import { posix as posixPath } from "path"; import { prismaUsers } from "@/lib/db-users"; import { APP_URL } from "@/lib/feature-flags"; import { @@ -19,6 +20,14 @@ function computeChecksum(content: string): string { return createHash("sha256").update(content).digest("hex").slice(0, 16); } +function sanitizeRepositoryPath(input: string | null | undefined): string | null { + if (!input?.trim()) return null; + // Normalize to posix, strip leading slashes, reject traversal + const normalized = posixPath.normalize(input.trim()).replace(/^[/\\]+/, ""); + if (normalized.startsWith("..") || normalized.includes("/..")) return null; + return normalized || null; +} + /** * GET /api/v1/blueprints * List user's blueprints (private templates) @@ -317,7 +326,7 @@ export async function POST(request: NextRequest) { // Hierarchy fields hierarchyId: validatedHierarchyId, parentId: validatedParentId, - repositoryPath: repository_path?.trim() || null, + repositoryPath: sanitizeRepositoryPath(repository_path) || null, contentChecksum, }, select: { diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index ecd4f429..5211ae33 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -46,11 +46,12 @@ function SignInContent() { message?: string; } | null>(null); - // Handle CLI authentication callback when user is already authenticated + // CLI auth requires explicit user consent — no auto-submit + const [cliAuthConsented, setCliAuthConsented] = useState(false); + useEffect(() => { - if (cliSession && status === "authenticated" && session?.user && !cliAuthComplete && !cliAuthError && !cliAuthProcessing) { + if (cliSession && cliAuthConsented && status === "authenticated" && session?.user && !cliAuthComplete && !cliAuthError && !cliAuthProcessing) { setCliAuthProcessing(true); - // Complete CLI authentication fetch("/api/cli-auth/callback", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -73,7 +74,37 @@ function SignInContent() { setCliAuthProcessing(false); }); } - }, [cliSession, status, session, cliAuthComplete, cliAuthError, cliAuthProcessing]); + }, [cliSession, cliAuthConsented, status, session, cliAuthComplete, cliAuthError, cliAuthProcessing]); + + // Show CLI consent screen — user must explicitly authorize + if (cliSession && status === "authenticated" && session?.user && !cliAuthConsented && !cliAuthComplete && !cliAuthError) { + return ( +
+
+
+ +
+

Authorize CLI Access

+

+ A CLI session is requesting access to your account + {session.user.email ? ` (${session.user.email})` : ""}. +

+

+ This will create an API token for the LynxPrompt CLI. Only proceed if you initiated this from your terminal. +

+
+ + +
+
+
+ ); + } // Show loading state while checking CLI session or processing auth if (cliSession && (status === "loading" || cliAuthProcessing)) { diff --git a/src/app/auth/sso-complete/page.tsx b/src/app/auth/sso-complete/page.tsx new file mode 100644 index 00000000..6ae42827 --- /dev/null +++ b/src/app/auth/sso-complete/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { Loader2 } from "lucide-react"; + +function SSOCompleteContent() { + const searchParams = useSearchParams(); + const [error, setError] = useState(null); + + useEffect(() => { + const userId = searchParams.get("userId"); + const email = searchParams.get("email"); + const teamId = searchParams.get("teamId"); + const nonce = searchParams.get("nonce"); + const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; + const sig = searchParams.get("sig"); + + if (!userId || !email || !teamId || !nonce || !sig) { + setError("Invalid SSO completion parameters."); + return; + } + + // Sign in via NextAuth credentials provider with SSO params + signIn("sso", { + userId, + email, + teamId, + nonce, + sig, + callbackUrl, + redirect: true, + }).catch(() => { + setError("Failed to complete SSO sign-in. Please try again."); + }); + }, [searchParams]); + + if (error) { + return ( +
+
+

{error}

+ + Back to sign in + +
+
+ ); + } + + return ( +
+
+ +

Completing SSO sign-in...

+
+
+ ); +} + +export default function SSOCompletePage() { + return ( + + + + } + > + + + ); +} diff --git a/src/app/blueprints/create/page.tsx b/src/app/blueprints/create/page.tsx index 62f615ca..b64c21be 100644 --- a/src/app/blueprints/create/page.tsx +++ b/src/app/blueprints/create/page.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { @@ -100,6 +100,8 @@ export default function ShareBlueprintPage() { const { status } = useSession(); const { enableAI } = useFeatureFlags(); const router = useRouter(); + const searchParams = useSearchParams(); + const preselectedTeamId = searchParams.get("teamId"); const fileInputRef = useRef(null); const [name, setName] = useState(""); @@ -111,6 +113,8 @@ export default function ShareBlueprintPage() { const [visibility, setVisibility] = useState<"PRIVATE" | "TEAM" | "PUBLIC">("PRIVATE"); const [aiAssisted, setAiAssisted] = useState(false); const [teamInfo, setTeamInfo] = useState<{ id: string; name: string; slug: string } | null>(null); + const [allTeams, setAllTeams] = useState<{ id: string; name: string; slug: string }[]>([]); + const [selectedTeamId, setSelectedTeamId] = useState(null); // Computed isPublic for backwards compatibility const isPublic = visibility === "PUBLIC"; @@ -163,19 +167,24 @@ export default function ShareBlueprintPage() { useEffect(() => { const fetchPlanAndTeam = async () => { try { - // Fetch billing status + // Fetch billing status (includes team memberships) const billingRes = await fetch("/api/billing/status"); if (billingRes.ok) { const data = await billingRes.json(); setUserPlan(data.plan || "FREE"); - } - - // Fetch team info (if user is in a team) - const dashboardRes = await fetch("/api/user/dashboard"); - if (dashboardRes.ok) { - const data = await dashboardRes.json(); - if (data.team) { - setTeamInfo(data.team); + const teams = data.teams || (data.team ? [data.team] : []); + if (teams.length > 0) { + setAllTeams(teams); + setTeamInfo(teams[0]); // backward compat + // Preselect team from URL param, or fall back to first team + const targetTeam = preselectedTeamId + ? teams.find((t: { id: string }) => t.id === preselectedTeamId) || teams[0] + : teams[0]; + setSelectedTeamId(targetTeam.id); + // Auto-set visibility to TEAM if linked from a team context + if (preselectedTeamId && teams.some((t: { id: string }) => t.id === preselectedTeamId)) { + setVisibility("TEAM"); + } } } } catch { @@ -253,10 +262,13 @@ export default function ShareBlueprintPage() { const hasSensitiveData = sensitiveMatches.length > 0 && !sensitiveWarningDismissed; - // Redirect to sign in if not authenticated + // Redirect to sign in if not authenticated, preserving query string useEffect(() => { if (status === "unauthenticated") { - router.push("/auth/signin?callbackUrl=/blueprints/create"); + const callbackUrl = typeof window !== "undefined" + ? window.location.pathname + window.location.search + : "/blueprints/create"; + router.push(`/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`); } }, [status, router]); @@ -340,7 +352,7 @@ export default function ShareBlueprintPage() { tags, isPublic, // For backwards compatibility visibility, // New visibility field (PRIVATE, TEAM, PUBLIC) - teamId: visibility === "TEAM" ? teamInfo?.id : null, // Set teamId if sharing with team + teamId: visibility === "TEAM" ? selectedTeamId : null, // Set teamId if sharing with team aiAssisted: isPublic ? aiAssisted : false, // Only relevant if sharing publicly showcaseUrl: showcaseUrl.trim() || null, turnstileToken: requiresTurnstile ? turnstileToken : undefined, @@ -1015,21 +1027,34 @@ export default function ShareBlueprintPage() { {/* Team option - only show if user is in a team */} - {teamInfo && ( - + {allTeams.length > 0 && ( +
+ + {visibility === "TEAM" && allTeams.length > 1 && ( + + )} +
)} {/* Public option */} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ce3eae5d..41420e02 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -130,6 +130,15 @@ interface TeamBlueprint { author: string; } +interface DashboardTeamData { + id: string; + name: string; + slug: string; + role: string; + memberCount: number; + blueprints: TeamBlueprint[]; +} + interface DashboardData { stats: DashboardStats; myTemplates: MyTemplate[]; @@ -137,6 +146,15 @@ interface DashboardData { favoriteTemplates: FavoriteTemplate[]; hierarchicalBlueprints: HierarchyGroup[]; teamBlueprints: TeamBlueprint[]; + teams?: DashboardTeamData[]; +} + +interface TeamInfo { + id: string; + name: string; + slug: string; + logo?: string | null; + role: string; } interface BillingStatus { @@ -145,13 +163,8 @@ interface BillingStatus { isAdmin?: boolean; isTeamsUser?: boolean; hasActiveSubscription?: boolean; - team?: { - id: string; - name: string; - slug: string; - logo: string | null; - role: string; - } | null; + team?: TeamInfo | null; + teams?: TeamInfo[]; } export default function DashboardPage() { @@ -524,16 +537,16 @@ export default function DashboardPage() { - {/* Teams Banner - Only for Teams users */} - {billingStatus?.isTeamsUser && billingStatus?.team && ( -
+ {/* Teams Banner - Show all teams the user belongs to */} + {billingStatus?.isTeamsUser && (billingStatus.teams || (billingStatus.team ? [billingStatus.team] : [])).map((t) => ( +
- {billingStatus.team.logo ? ( + {t.logo ? ( {billingStatus.team.name} @@ -544,11 +557,11 @@ export default function DashboardPage() { )}
-

{billingStatus.team.name}

+

{t.name}

Team - {billingStatus.team.role === "ADMIN" && ( + {t.role === "ADMIN" && ( Admin @@ -556,22 +569,22 @@ export default function DashboardPage() { )}

- {billingStatus.team.role === "ADMIN" - ? "Manage your team members, billing, and shared blueprints" + {t.role === "ADMIN" + ? "Manage your team members, billing, and shared blueprints" : "Access shared team blueprints and collaboration features"}

- )} + ))}
{/* Left Column: Quick Actions + My Templates */} @@ -691,18 +704,18 @@ export default function DashboardPage() {
)} - {/* Team Blueprints - Only for TEAMS users */} - {billingStatus?.isTeamsUser && billingStatus?.team && ( -
+ {/* Team Blueprints - Show per-team sections for all teams */} + {billingStatus?.isTeamsUser && (dashboardData?.teams || (billingStatus?.team ? [{ ...billingStatus.team, blueprints: dashboardData?.teamBlueprints || [] }] : [])).map((teamData) => ( +

Team Blueprints

- {billingStatus.team.name} + {teamData.name}