adding organization

This commit is contained in:
Zhaoqi Li
2025-05-16 13:56:36 -07:00
parent 4e97194de1
commit 7afdfb7fe7
36 changed files with 2270 additions and 40 deletions
@@ -1,5 +1,5 @@
---
description:
globs:
alwaysApply: false
description:
globs:
alwaysApply: true
---
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from 'next/server';
import { organizationService } from '@/lib/organization-service';
import { OrganizationUser } from '@/types/organization';
// Update a member's role
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string; userId: string } }
) {
try {
const { id: organizationId, userId } = params;
const { role } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if current user has permission (admin or owner)
const currentUserRole = await organizationService.getUserOrganizationRole(
currentUser.id,
organizationId
);
if (!currentUserRole || !['admin', 'owner'].includes(currentUserRole)) {
return NextResponse.json(
{ error: 'You do not have permission to update member roles' },
{ status: 403 }
);
}
// Only owners can update admin roles
if (role === 'owner' && currentUserRole !== 'owner') {
return NextResponse.json(
{ error: 'Only owners can promote members to owners' },
{ status: 403 }
);
}
// Check the current role of the target user
const targetUserRole = await organizationService.getUserOrganizationRole(
userId,
organizationId
);
// Prevent demoting the last owner
if (targetUserRole === 'owner') {
const members = await organizationService.getOrganizationMembers(organizationId);
const ownerCount = members.filter((m: OrganizationUser) => m.role === 'owner').length;
if (ownerCount === 1 && role !== 'owner') {
return NextResponse.json(
{ error: 'Cannot demote the last owner of the organization' },
{ status: 400 }
);
}
}
const updatedMember = await organizationService.updateMemberRole(
organizationId,
userId,
role
);
return NextResponse.json(updatedMember);
} catch (error) {
console.error('Error updating member role:', error);
return NextResponse.json(
{ error: 'Failed to update member role' },
{ status: 500 }
);
}
}
// Remove a member from the organization
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string; userId: string } }
) {
try {
const { id: organizationId, userId } = params;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if current user has permission (admin or owner)
const currentUserRole = await organizationService.getUserOrganizationRole(
currentUser.id,
organizationId
);
if (!currentUserRole || !['admin', 'owner'].includes(currentUserRole)) {
return NextResponse.json(
{ error: 'You do not have permission to remove members' },
{ status: 403 }
);
}
// Check the current role of the target user
const targetUserRole = await organizationService.getUserOrganizationRole(
userId,
organizationId
);
// Admins can't remove owners
if (targetUserRole === 'owner' && currentUserRole !== 'owner') {
return NextResponse.json(
{ error: 'Only owners can remove other owners' },
{ status: 403 }
);
}
// Prevent removing the last owner
if (targetUserRole === 'owner') {
const members = await organizationService.getOrganizationMembers(organizationId);
const ownerCount = members.filter((m: OrganizationUser) => m.role === 'owner').length;
if (ownerCount === 1) {
return NextResponse.json(
{ error: 'Cannot remove the last owner of the organization' },
{ status: 400 }
);
}
}
// Users can leave organizations (remove themselves)
const isSelf = userId === currentUser.id;
if (isSelf) {
// If this is the last owner, prevent leaving
if (targetUserRole === 'owner') {
const members = await organizationService.getOrganizationMembers(organizationId);
const ownerCount = members.filter((m: OrganizationUser) => m.role === 'owner').length;
if (ownerCount === 1) {
return NextResponse.json(
{ error: 'As the last owner, you cannot leave the organization. Transfer ownership first.' },
{ status: 400 }
);
}
}
}
await organizationService.removeMember(organizationId, userId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing member:', error);
return NextResponse.json(
{ error: 'Failed to remove member' },
{ status: 500 }
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { organizationService } from '@/lib/organization-service';
// Get organization members
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const organizationId = params.id;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if user is a member of this organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
organizationId
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this organization' },
{ status: 403 }
);
}
const members = await organizationService.getOrganizationMembers(organizationId);
return NextResponse.json(members);
} catch (error) {
console.error('Error fetching organization members:', error);
return NextResponse.json(
{ error: 'Failed to fetch organization members' },
{ status: 500 }
);
}
}
// Add a new member to the organization
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const organizationId = params.id;
const { email, role } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if user is an admin or owner
const userRole = await organizationService.getUserOrganizationRole(
currentUser.id,
organizationId
);
if (!userRole || !['admin', 'owner'].includes(userRole)) {
return NextResponse.json(
{ error: 'You do not have permission to add members' },
{ status: 403 }
);
}
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
try {
const newMember = await organizationService.addUserToOrganization(
organizationId,
email,
role || 'member'
);
return NextResponse.json(newMember);
} catch (err) {
return NextResponse.json(
{ error: 'Failed to add member: ' + (err instanceof Error ? err.message : 'Unknown error') },
{ status: 400 }
);
}
} catch (error) {
console.error('Error adding organization member:', error);
return NextResponse.json(
{ error: 'Failed to add organization member' },
{ status: 500 }
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { organizationService } from '@/lib/organization-service';
// Get organization by ID
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const organizationId = params.id;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if user is a member of this organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
organizationId
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this organization' },
{ status: 403 }
);
}
const organization = await organizationService.getOrganization(organizationId);
if (!organization) {
return NextResponse.json(
{ error: 'Organization not found' },
{ status: 404 }
);
}
return NextResponse.json(organization);
} catch (error) {
console.error('Error fetching organization:', error);
return NextResponse.json(
{ error: 'Failed to fetch organization' },
{ status: 500 }
);
}
}
// Update organization
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const organizationId = params.id;
const { name, description } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Check if user is an admin or owner of this organization
const userRole = await organizationService.getUserOrganizationRole(
currentUser.id,
organizationId
);
if (!userRole || !['admin', 'owner'].includes(userRole)) {
return NextResponse.json(
{ error: 'You do not have permission to update this organization' },
{ status: 403 }
);
}
const updateData: { name?: string; description?: string } = {};
if (name) updateData.name = name;
if (description !== undefined) updateData.description = description;
const organization = await organizationService.updateOrganization(
organizationId,
updateData
);
return NextResponse.json(organization);
} catch (error) {
console.error('Error updating organization:', error);
return NextResponse.json(
{ error: 'Failed to update organization' },
{ status: 500 }
);
}
}
// Delete organization
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const organizationId = params.id;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Only owners can delete organizations
const userRole = await organizationService.getUserOrganizationRole(
currentUser.id,
organizationId
);
if (userRole !== 'owner') {
return NextResponse.json(
{ error: 'Only organization owners can delete organizations' },
{ status: 403 }
);
}
await organizationService.deleteOrganization(organizationId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting organization:', error);
return NextResponse.json(
{ error: 'Failed to delete organization' },
{ status: 500 }
);
}
}
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { organizationService } from '@/lib/organization-service';
// Get organization by slug
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const slug = params.slug;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const organization = await organizationService.getOrganizationBySlug(slug);
if (!organization) {
return NextResponse.json(
{ error: 'Organization not found' },
{ status: 404 }
);
}
// Check if user is a member of this organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
organization.id
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this organization' },
{ status: 403 }
);
}
return NextResponse.json(organization);
} catch (error) {
console.error('Error fetching organization by slug:', error);
return NextResponse.json(
{ error: 'Failed to fetch organization' },
{ status: 500 }
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { organizationService } from '@/lib/organization-service';
// Get all organizations for the current user
export async function GET() {
try {
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const organizations = await organizationService.getUserOrganizations(currentUser.id);
return NextResponse.json(organizations);
} catch (error) {
console.error('Error fetching organizations:', error);
return NextResponse.json(
{ error: 'Failed to fetch organizations' },
{ status: 500 }
);
}
}
// Create a new organization
export async function POST(request: NextRequest) {
try {
const { name, description } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (!name) {
return NextResponse.json(
{ error: 'Organization name is required' },
{ status: 400 }
);
}
const organization = await organizationService.createOrganization(
name,
description || null,
currentUser.id
);
return NextResponse.json(organization);
} catch (error) {
console.error('Error creating organization:', error);
return NextResponse.json(
{ error: 'Failed to create organization' },
{ status: 500 }
);
}
}
+123
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/project-service';
import { organizationService } from '@/lib/organization-service';
export async function GET(
request: NextRequest,
@@ -7,6 +8,14 @@ export async function GET(
) {
try {
const projectId = (await params).projectId;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (!projectId) {
return NextResponse.json(
@@ -24,6 +33,19 @@ export async function GET(
);
}
// Check if user is a member of the project's organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
project.organizationId
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this project' },
{ status: 403 }
);
}
return NextResponse.json(project);
} catch (error) {
console.error('Error fetching project:', error);
@@ -32,4 +54,105 @@ export async function GET(
{ status: 500 }
);
}
}
// Update a project
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const projectId = (await params).projectId;
const { name, description } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const project = await projectService.getProject(projectId);
if (!project) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
// Check if user is a member of the project's organization with proper permissions
const userRole = await organizationService.getUserOrganizationRole(
currentUser.id,
project.organizationId
);
if (!userRole || !['admin', 'owner'].includes(userRole)) {
return NextResponse.json(
{ error: 'You do not have permission to update this project' },
{ status: 403 }
);
}
const updatedProject = await projectService.updateProject(projectId, { name, description });
return NextResponse.json(updatedProject);
} catch (error) {
console.error('Error updating project:', error);
return NextResponse.json(
{ error: 'Failed to update project' },
{ status: 500 }
);
}
}
// Delete a project
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const projectId = (await params).projectId;
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const project = await projectService.getProject(projectId);
if (!project) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
// Check if user is a member of the project's organization with proper permissions
const userRole = await organizationService.getUserOrganizationRole(
currentUser.id,
project.organizationId
);
if (!userRole || !['admin', 'owner'].includes(userRole)) {
return NextResponse.json(
{ error: 'You do not have permission to delete this project' },
{ status: 403 }
);
}
await projectService.deleteProject(projectId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting project:', error);
return NextResponse.json(
{ error: 'Failed to delete project' },
{ status: 500 }
);
}
}
+79 -5
View File
@@ -1,10 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/project-service';
import { organizationService } from '@/lib/organization-service';
import { Organization } from '@/types/organization';
// Get all projects
export async function GET() {
// Get all projects for the current user's organizations
export async function GET(request: NextRequest) {
try {
const projects = await projectService.getProjects();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Get the organization ID from the query params, if provided
const { searchParams } = new URL(request.url);
const organizationId = searchParams.get('organizationId');
// If organization ID is provided, get projects for that organization
// Otherwise, get projects for all organizations the user is a member of
let projects;
if (organizationId) {
// Check if user is a member of this organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
organizationId
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this organization' },
{ status: 403 }
);
}
projects = await projectService.getProjects(organizationId);
} else {
// Get all organizations the user is a member of
const organizations = await organizationService.getUserOrganizations(currentUser.id);
// Get projects for each organization
const projectPromises = organizations.map((org: Organization) =>
projectService.getProjects(org.id)
);
const orgProjects = await Promise.all(projectPromises);
projects = orgProjects.flat();
}
return NextResponse.json(projects);
} catch (error) {
console.error('Error fetching projects:', error);
@@ -18,7 +64,15 @@ export async function GET() {
// Create a new project
export async function POST(request: NextRequest) {
try {
const { name, description } = await request.json();
const { name, description, organizationId } = await request.json();
const currentUser = await organizationService.getCurrentUser();
if (!currentUser) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (!name) {
return NextResponse.json(
@@ -26,8 +80,28 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
if (!organizationId) {
return NextResponse.json(
{ error: 'Organization ID is required' },
{ status: 400 }
);
}
// Check if user is a member of this organization
const isMember = await organizationService.isUserOrganizationMember(
currentUser.id,
organizationId
);
if (!isMember) {
return NextResponse.json(
{ error: 'You do not have access to this organization' },
{ status: 403 }
);
}
const project = await projectService.createProject(name, description);
const project = await projectService.createProject(name, organizationId, description);
return NextResponse.json(project);
} catch (error) {
console.error('Error creating project:', error);
+25
View File
@@ -0,0 +1,25 @@
import { type NextRequest } from 'next/server'
import { createClient } from '@/lib/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
if (!code) {
return redirect('/login')
}
const supabase = await createClient()
// Exchange the code for a session
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
console.error('Error exchanging code for session:', error)
return redirect('/error')
}
// Successful authentication, redirect to home
return redirect('/')
}
+28
View File
@@ -0,0 +1,28 @@
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest } from 'next/server'
import { createClient } from '@/lib/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = searchParams.get('next') ?? '/'
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
})
if (!error) {
// redirect user to specified redirect URL or root of app
redirect(next)
}
}
// redirect the user to an error page with some instructions
redirect('/error')
}
+29
View File
@@ -0,0 +1,29 @@
'use client'
import Link from 'next/link'
export default function LogoutButton() {
return (
<Link
href="/logout"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-gray-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Log out
</Link>
)
}
+5
View File
@@ -0,0 +1,5 @@
'use client'
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>
}
+55
View File
@@ -0,0 +1,55 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/utils/supabase/server'
export async function signInWithMagicLink(formData: FormData) {
const supabase = await createClient()
// Get email from form data
const email = formData.get('email') as string
// Validate email
if (!email || !email.includes('@')) {
// In a real app, you'd want to return an error message
redirect('/error')
}
// Get the origin for creating the full redirect URL
// In production, you should set NEXT_PUBLIC_APP_URL in your environment variables
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${origin}/auth/callback`, // Redirect to our auth callback handler
},
})
if (error) {
redirect('/error')
}
// Redirect to a confirmation page
redirect('/login/confirmation')
}
// Keep this for backward compatibility if needed, but it won't be used in the new flow
export async function login(formData: FormData) {
redirect('/login/confirmation')
}
// Keep this for backward compatibility if needed, but it won't be used in the new flow
export async function signup(formData: FormData) {
redirect('/login/confirmation')
}
export async function logout() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/login')
}
+15
View File
@@ -0,0 +1,15 @@
export default function ConfirmationPage() {
return (
<div className="flex flex-col items-center justify-center space-y-6 py-12">
<h1 className="text-2xl font-bold">Check your email</h1>
<div className="max-w-md text-center">
<p className="mb-4">
We've sent you a magic link to your email address. Click the link in the email to sign in.
</p>
<p className="text-sm text-gray-500">
If you don't see the email, check your spam folder. The link will expire after 24 hours.
</p>
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { signInWithMagicLink } from '@/app/login/actions'
export default function LoginPage() {
return (
<div className="flex flex-col items-center justify-center space-y-4 py-8">
<h1 className="text-2xl font-bold">Sign in to your account</h1>
<form className="w-full max-w-md space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border px-3 py-2"
placeholder="you@example.com"
/>
</div>
<button
formAction={signInWithMagicLink}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Send Magic Link
</button>
<p className="text-center text-sm text-gray-500">
We'll email you a magic link for a password-free sign in.
</p>
</form>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { logout } from '@/app/login/actions'
export default function LogoutPage() {
return (
<div className="flex flex-col items-center justify-center space-y-6 py-12">
<h1 className="text-2xl font-bold">Log out</h1>
<p className="text-center">Are you sure you want to log out?</p>
<form action={logout}>
<button
type="submit"
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Log out
</button>
</form>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { Organization } from '@/types/organization';
import { ProjectGrid } from '@/components/projects/ProjectGrid';
import { CreateProjectDialog } from '@/components/projects/CreateProjectDialog';
import { Button } from '@/components/ui/button';
import { PlusCircle, Users } from 'lucide-react';
import Link from 'next/link';
import { useToast } from '@/components/ui/use-toast';
export default function OrganizationPage() {
const params = useParams();
const slug = params.slug as string;
const [organization, setOrganization] = useState<Organization | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false);
const { toast } = useToast();
useEffect(() => {
const fetchOrganization = async () => {
try {
setIsLoading(true);
// First fetch the organization by slug
const orgResponse = await fetch(`/api/organizations/by-slug/${slug}`);
if (!orgResponse.ok) {
throw new Error('Failed to fetch organization');
}
const orgData = await orgResponse.json();
setOrganization(orgData);
} catch (error) {
console.error('Error fetching organization:', error);
toast({
title: 'Error',
description: 'Failed to load organization data',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
if (slug) {
fetchOrganization();
}
}, [slug, toast]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-pulse flex flex-col gap-4 w-full max-w-4xl">
<div className="h-12 bg-gray-200 rounded w-1/3"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-64 bg-gray-200 rounded w-full mt-4"></div>
</div>
</div>
);
}
if (!organization) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h1 className="text-2xl font-bold">Organization not found</h1>
<p className="text-gray-600 mb-4">The organization you're looking for doesn't exist or you don't have access to it.</p>
<Link href="/">
<Button>Back to Dashboard</Button>
</Link>
</div>
);
}
return (
<div className="container mx-auto py-6 px-4">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold">{organization.name}</h1>
{organization.description && (
<p className="text-gray-600 mt-1">{organization.description}</p>
)}
</div>
<div className="flex gap-2">
<Link href={`/organizations/${slug}/members`}>
<Button variant="outline">
<Users className="mr-2 h-4 w-4" />
Members
</Button>
</Link>
<Button onClick={() => setIsCreateProjectOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
New Project
</Button>
</div>
</div>
<div className="mb-8">
<h2 className="text-xl font-medium mb-4">Projects</h2>
{organization.projects && organization.projects.length > 0 ? (
<ProjectGrid
projects={organization.projects}
isLoading={false}
/>
) : (
<div className="border rounded-lg p-8 text-center">
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
<p className="text-gray-600 mb-4">Create your first project to get started</p>
<Button onClick={() => setIsCreateProjectOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
Create Project
</Button>
</div>
)}
</div>
{/* Create project dialog */}
{organization && (
<CreateProjectDialog
isOpen={isCreateProjectOpen}
onOpenChange={setIsCreateProjectOpen}
organizationId={organization.id}
onSuccess={() => {
// Refresh the organization data to show the new project
window.location.reload();
}}
/>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/utils/supabase/server'
export default async function PrivatePage() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data?.user) {
redirect('/login')
}
return <p>Hello {data.user.email}</p>
}
+8 -4
View File
@@ -23,7 +23,8 @@ import {
Users,
Calendar,
MessageSquare,
HelpCircle
HelpCircle,
LogOut
} from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@@ -39,6 +40,7 @@ function HomeSidebarContent() {
const bottomMenuItems = [
{ id: "settings", label: "Settings", icon: Settings, path: "/settings" },
{ id: "help", label: "Help & Support", icon: HelpCircle, path: "/help" },
{ id: "logout", label: "Log out", icon: LogOut, path: "/logout" },
]
return (
@@ -127,9 +129,11 @@ function HomeSidebarContent() {
<span className="text-xs text-muted-foreground">Admin</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
<Link href="/logout">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Log out">
<LogOut className="h-4 w-4" />
</Button>
</Link>
</div>
</SidebarFooter>
</>
+7 -4
View File
@@ -16,7 +16,7 @@ import {
SidebarTrigger,
useSidebar
} from "@/components/ui/sidebar"
import { Calendar, FileText, FolderOpen, HelpCircle, Home, MessageSquare, Settings, Upload, Users } from "lucide-react"
import { Calendar, FileText, FolderOpen, HelpCircle, Home, LogOut, MessageSquare, Settings, Upload, Users } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
@@ -121,6 +121,7 @@ function SidebarInnerContent() {
const bottomMenuItems = [
{ id: "settings", label: "Settings", icon: Settings, path: "/settings" },
{ id: "help", label: "Help & Support", icon: HelpCircle, path: "/help" },
{ id: "logout", label: "Log out", icon: LogOut, path: "/logout" },
]
return (
@@ -241,9 +242,11 @@ function SidebarInnerContent() {
<span className="text-xs text-muted-foreground">Project Owner</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
<Link href="/logout">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Log out">
<LogOut className="h-4 w-4" />
</Button>
</Link>
</div>
</SidebarFooter>
</>
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
interface CreateOrganizationDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (orgId: string, orgSlug: string) => void;
}
export function CreateOrganizationDialog({
isOpen,
onOpenChange,
onSuccess
}: CreateOrganizationDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast({
title: 'Error',
description: 'Organization name is required',
variant: 'destructive',
});
return;
}
try {
setIsSubmitting(true);
const response = await fetch('/api/organizations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
description: description.trim() || null,
}),
});
if (response.ok) {
const organization = await response.json();
toast({
title: 'Success',
description: `${organization.name} has been created`,
});
// Reset form
setName('');
setDescription('');
// Close dialog
onOpenChange(false);
// Call success callback or redirect
if (onSuccess) {
onSuccess(organization.id, organization.slug);
} else {
router.push(`/organizations/${organization.slug}`);
}
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to create organization');
}
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to create organization',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create organization</DialogTitle>
<DialogDescription>
Create a new organization to manage projects and team members.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Inc."
className="col-span-3"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of your organization"
className="col-span-3"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim()}
>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
PlusCircle
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Organization } from '@/types/organization';
export function OrganizationSelector({
currentOrganizationId,
onCreateNew
}: {
currentOrganizationId?: string;
onCreateNew: () => void;
}) {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchOrganizations = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/organizations');
if (response.ok) {
const data = await response.json();
setOrganizations(data);
}
} catch (error) {
console.error('Error fetching organizations:', error);
} finally {
setIsLoading(false);
}
};
fetchOrganizations();
}, []);
const handleOrganizationChange = (orgId: string) => {
if (orgId !== currentOrganizationId) {
const org = organizations.find(o => o.id === orgId);
if (org) {
router.push(`/organizations/${org.slug}`);
}
}
};
return (
<div className="flex items-center gap-2">
<Select
disabled={isLoading || organizations.length === 0}
value={currentOrganizationId}
onValueChange={handleOrganizationChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select organization'} />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={onCreateNew}
title="Create new organization"
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
interface CreateProjectDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
organizationId: string;
onSuccess?: (projectId: string) => void;
}
export function CreateProjectDialog({
isOpen,
onOpenChange,
organizationId,
onSuccess
}: CreateProjectDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast({
title: 'Error',
description: 'Project name is required',
variant: 'destructive',
});
return;
}
try {
setIsSubmitting(true);
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
description: description.trim() || null,
organizationId,
}),
});
if (response.ok) {
const project = await response.json();
toast({
title: 'Success',
description: `${project.name} has been created`,
});
// Reset form
setName('');
setDescription('');
// Close dialog
onOpenChange(false);
// Call success callback or redirect
if (onSuccess) {
onSuccess(project.id);
} else {
router.push(`/project?projectId=${project.id}`);
}
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to create project');
}
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to create project',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
<DialogDescription>
Create a new project in your organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Project"
className="col-span-3"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of your project"
className="col-span-3"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim()}
>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+84 -15
View File
@@ -1,30 +1,59 @@
'use client';
import React, { useState, useEffect } from "react";
import { SearchBar } from "./SearchBar";
import { ProjectGrid } from "./ProjectGrid";
import { Organization } from "@/types/organization";
import { Project } from "@/types/project";
import { OrganizationSelector } from "@/components/organizations/OrganizationSelector";
import { CreateOrganizationDialog } from "@/components/organizations/CreateOrganizationDialog";
import { Button } from "@/components/ui/button";
import { PlusCircle } from "lucide-react";
import Link from "next/link";
import { useToast } from "@/components/ui/use-toast";
export function HomeContent() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false);
const { toast } = useToast();
useEffect(() => {
const fetchProjects = async () => {
const fetchData = async () => {
try {
const response = await fetch("/api/projects");
if (response.ok) {
const data = await response.json();
setProjects(data);
setIsLoading(true);
// Fetch organizations
const orgsResponse = await fetch("/api/organizations");
if (!orgsResponse.ok) {
throw new Error("Failed to fetch organizations");
}
const orgsData = await orgsResponse.json();
setOrganizations(orgsData);
// Fetch projects (across all organizations)
const projectsResponse = await fetch("/api/projects");
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData);
}
} catch (error) {
console.error("Error fetching projects:", error);
console.error("Error fetching data:", error);
toast({
title: "Error",
description: "Failed to load data",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
fetchProjects();
}, []);
fetchData();
}, [toast]);
// Filter projects based on search
const filteredProjects = projects.filter(project =>
@@ -34,18 +63,58 @@ export function HomeContent() {
return (
<div className="w-full p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="flex items-center gap-2">
<OrganizationSelector
onCreateNew={() => setIsCreateOrgOpen(true)}
/>
</div>
</div>
<SearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="mb-8">
<h2 className="text-xl font-medium mb-4">Your Projects</h2>
<ProjectGrid
projects={filteredProjects}
isLoading={isLoading}
/>
</div>
{organizations.length === 0 && !isLoading ? (
<div className="mt-8 border rounded-lg p-8 text-center">
<h2 className="text-xl font-medium mb-2">Welcome to AutoRFP</h2>
<p className="text-gray-600 mb-4">
Create your first organization to get started
</p>
<Button onClick={() => setIsCreateOrgOpen(true)}>
<PlusCircle className="mr-2 h-4 w-4" />
Create Organization
</Button>
</div>
) : (
<div className="mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium">Your Projects</h2>
{organizations.length > 0 && (
<Link href={`/organizations/${organizations[0].slug}`}>
<Button variant="outline" size="sm">
View All
</Button>
</Link>
)}
</div>
<ProjectGrid
projects={filteredProjects}
isLoading={isLoading}
/>
</div>
)}
<CreateOrganizationDialog
isOpen={isCreateOrgOpen}
onOpenChange={setIsCreateOrgOpen}
onSuccess={(orgId, orgSlug) => {
window.location.href = `/organizations/${orgSlug}`;
}}
/>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { db } from './db';
import { createClient } from '@/lib/utils/supabase/server';
export const organizationService = {
// Organization operations
async createOrganization(name: string, description: string | null = null, userId: string) {
// Create a slug from the name (lowercase, replace spaces with dashes, remove special chars)
const slug = name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-')
.substring(0, 50); // Limit length
// Make the slug unique by adding a random suffix if needed
let uniqueSlug = slug;
let slugExists = await db.organization.findUnique({
where: { slug: uniqueSlug },
});
if (slugExists) {
uniqueSlug = `${slug}-${Math.random().toString(36).substring(2, 7)}`;
}
// Create the organization and add the user as an owner in a transaction
return db.$transaction(async (tx) => {
// Create the organization
const organization = await tx.organization.create({
data: {
name,
slug: uniqueSlug,
description,
},
});
// Add the user as an owner
await tx.organizationUser.create({
data: {
role: 'owner',
userId,
organizationId: organization.id,
},
});
return organization;
});
},
async getOrganization(id: string) {
return db.organization.findUnique({
where: { id },
include: {
projects: {
orderBy: {
updatedAt: 'desc',
},
},
organizationUsers: {
include: {
user: true,
},
},
},
});
},
async getOrganizationBySlug(slug: string) {
return db.organization.findUnique({
where: { slug },
include: {
projects: {
orderBy: {
updatedAt: 'desc',
},
},
organizationUsers: {
include: {
user: true,
},
},
},
});
},
async getUserOrganizations(userId: string) {
return db.organization.findMany({
where: {
organizationUsers: {
some: {
userId,
},
},
},
orderBy: {
updatedAt: 'desc',
},
});
},
async updateOrganization(id: string, data: { name?: string; description?: string }) {
return db.organization.update({
where: { id },
data,
});
},
async deleteOrganization(id: string) {
return db.organization.delete({
where: { id },
});
},
// Organization members operations
async addUserToOrganization(organizationId: string, email: string, role: string = 'member') {
// First check if the user exists
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
throw new Error(`User with email ${email} not found`);
}
// Then create the organization user
return db.organizationUser.create({
data: {
role,
userId: user.id,
organizationId,
},
include: {
user: true,
},
});
},
async getOrganizationMembers(organizationId: string) {
return db.organizationUser.findMany({
where: {
organizationId,
},
include: {
user: true,
},
orderBy: {
createdAt: 'asc',
},
});
},
async updateMemberRole(organizationId: string, userId: string, role: string) {
return db.organizationUser.update({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
data: {
role,
},
});
},
async removeMember(organizationId: string, userId: string) {
return db.organizationUser.delete({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
},
// User methods
async createUserIfNotExists(id: string, email: string, name: string | null = null) {
const user = await db.user.findUnique({
where: { id },
});
if (user) {
return user;
}
return db.user.create({
data: {
id,
email,
name,
},
});
},
async getCurrentUser() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return null;
}
// Ensure user exists in our database
const dbUser = await this.createUserIfNotExists(
user.id,
user.email || '',
user.user_metadata?.name || null
);
return dbUser;
},
async getUserOrganizationRole(userId: string, organizationId: string) {
const orgUser = await db.organizationUser.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
return orgUser?.role || null;
},
async isUserOrganizationMember(userId: string, organizationId: string) {
const orgUser = await db.organizationUser.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
return !!orgUser;
},
};
+33 -2
View File
@@ -3,20 +3,37 @@ import { RfpDocument, RfpSection, RfpQuestion, AnswerSource } from '@/types/api'
export const projectService = {
// Project operations
async createProject(name: string, description?: string) {
async createProject(name: string, organizationId: string, description?: string) {
return db.project.create({
data: {
name,
description,
organizationId,
},
});
},
async getProjects() {
async getProjects(organizationId?: string) {
// If organizationId is provided, get projects for that organization only
if (organizationId) {
return db.project.findMany({
where: {
organizationId,
},
orderBy: {
createdAt: 'desc',
},
});
}
// Otherwise get all projects (mostly for admin purposes)
return db.project.findMany({
orderBy: {
createdAt: 'desc',
},
include: {
organization: true,
},
});
},
@@ -24,6 +41,7 @@ export const projectService = {
return db.project.findUnique({
where: { id },
include: {
organization: true,
questions: {
include: {
answer: {
@@ -37,6 +55,19 @@ export const projectService = {
});
},
async updateProject(id: string, data: { name?: string; description?: string }) {
return db.project.update({
where: { id },
data,
});
},
async deleteProject(id: string) {
return db.project.delete({
where: { id },
});
},
// Question operations
async saveQuestions(projectId: string, sections: RfpSection[]) {
console.log(`Starting to save questions for project ${projectId}. Total sections: ${sections.length}`);
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
+65
View File
@@ -0,0 +1,65 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Do not run code between createServerClient and
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: DO NOT REMOVE auth.getUser()
const {
data: { user },
} = await supabase.auth.getUser()
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it is.
// If you're creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but avoid changing
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
return supabaseResponse
}
+29
View File
@@ -0,0 +1,29 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
+19
View File
@@ -0,0 +1,19 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
+2
View File
@@ -39,6 +39,8 @@
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.7",
"@radix-ui/react-tooltip": "^1.2.4",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+120 -7
View File
@@ -95,6 +95,12 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@19.1.3(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@supabase/ssr':
specifier: ^0.6.1
version: 0.6.1(@supabase/supabase-js@2.49.4)
'@supabase/supabase-js':
specifier: ^2.49.4
version: 2.49.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -118,7 +124,7 @@ importers:
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
llamaindex:
specifier: ^0.10.3
version: 0.10.3(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(p-retry@6.2.1)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(zod@3.24.3)
version: 0.10.3(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(p-retry@6.2.1)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(ws@8.18.2)(zod@3.24.3)
lucide-react:
specifier: ^0.507.0
version: 0.507.0(react@19.1.0)
@@ -130,7 +136,7 @@ importers:
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
openai:
specifier: ^4.97.0
version: 4.97.0(zod@3.24.3)
version: 4.97.0(ws@8.18.2)(zod@3.24.3)
prisma:
specifier: ^6.7.0
version: 6.7.0(typescript@5.8.3)
@@ -1289,6 +1295,33 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@supabase/auth-js@2.69.1':
resolution: {integrity: sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==}
'@supabase/functions-js@2.4.4':
resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==}
'@supabase/node-fetch@2.6.15':
resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
engines: {node: 4.x || >=6.0.0}
'@supabase/postgrest-js@1.19.4':
resolution: {integrity: sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==}
'@supabase/realtime-js@2.11.2':
resolution: {integrity: sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==}
'@supabase/ssr@0.6.1':
resolution: {integrity: sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==}
peerDependencies:
'@supabase/supabase-js': ^2.43.4
'@supabase/storage-js@2.7.1':
resolution: {integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==}
'@supabase/supabase-js@2.49.4':
resolution: {integrity: sha512-jUF0uRUmS8BKt37t01qaZ88H9yV1mbGYnqLeuFWLcdV+x1P4fl0yP9DGtaEhFPZcwSom7u16GkLEH9QJZOqOkw==}
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -1448,6 +1481,9 @@ packages:
'@types/node@22.15.3':
resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
'@types/react-dom@19.1.3':
resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==}
peerDependencies:
@@ -1465,6 +1501,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -1555,6 +1594,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2457,6 +2500,18 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
ws@8.18.2:
resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
peerDependencies:
@@ -2719,11 +2774,11 @@ snapshots:
tree-sitter: 0.22.4
web-tree-sitter: 0.24.7
'@llamaindex/openai@0.3.5(zod@3.24.3)':
'@llamaindex/openai@0.3.5(ws@8.18.2)(zod@3.24.3)':
dependencies:
'@llamaindex/core': 0.6.3
'@llamaindex/env': 0.1.29
openai: 4.97.0(zod@3.24.3)
openai: 4.97.0(ws@8.18.2)(zod@3.24.3)
transitivePeerDependencies:
- '@huggingface/transformers'
- encoding
@@ -3501,6 +3556,53 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@supabase/auth-js@2.69.1':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/functions-js@2.4.4':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/node-fetch@2.6.15':
dependencies:
whatwg-url: 5.0.0
'@supabase/postgrest-js@1.19.4':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/realtime-js@2.11.2':
dependencies:
'@supabase/node-fetch': 2.6.15
'@types/phoenix': 1.6.6
'@types/ws': 8.18.1
ws: 8.18.2
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@supabase/ssr@0.6.1(@supabase/supabase-js@2.49.4)':
dependencies:
'@supabase/supabase-js': 2.49.4
cookie: 1.0.2
'@supabase/storage-js@2.7.1':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/supabase-js@2.49.4':
dependencies:
'@supabase/auth-js': 2.69.1
'@supabase/functions-js': 2.4.4
'@supabase/node-fetch': 2.6.15
'@supabase/postgrest-js': 1.19.4
'@supabase/realtime-js': 2.11.2
'@supabase/storage-js': 2.7.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
@@ -3644,6 +3746,8 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/phoenix@1.6.6': {}
'@types/react-dom@19.1.3(@types/react@19.1.2)':
dependencies:
'@types/react': 19.1.2
@@ -3658,6 +3762,10 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.17.32
'@ungap/structured-clone@1.3.0': {}
abort-controller@3.0.0:
@@ -3752,6 +3860,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
cookie@1.0.2: {}
cssesc@3.0.0: {}
csstype@3.1.3: {}
@@ -4116,13 +4226,13 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.29.2
lightningcss-win32-x64-msvc: 1.29.2
llamaindex@0.10.3(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(p-retry@6.2.1)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(zod@3.24.3):
llamaindex@0.10.3(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(p-retry@6.2.1)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(ws@8.18.2)(zod@3.24.3):
dependencies:
'@llamaindex/cloud': 4.0.4(@llamaindex/core@0.6.3)(@llamaindex/env@0.1.29)
'@llamaindex/core': 0.6.3
'@llamaindex/env': 0.1.29
'@llamaindex/node-parser': 2.0.3(@llamaindex/core@0.6.3)(@llamaindex/env@0.1.29)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)
'@llamaindex/openai': 0.3.5(zod@3.24.3)
'@llamaindex/openai': 0.3.5(ws@8.18.2)(zod@3.24.3)
'@llamaindex/workflow': 1.0.4(@llamaindex/core@0.6.3)(@llamaindex/env@0.1.29)(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(p-retry@6.2.1)(zod@3.24.3)
'@types/lodash': 4.17.16
'@types/node': 22.15.3
@@ -4439,7 +4549,7 @@ snapshots:
object-assign@4.1.1: {}
openai@4.97.0(zod@3.24.3):
openai@4.97.0(ws@8.18.2)(zod@3.24.3):
dependencies:
'@types/node': 18.19.87
'@types/node-fetch': 2.6.12
@@ -4449,6 +4559,7 @@ snapshots:
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.18.2
zod: 3.24.3
transitivePeerDependencies:
- encoding
@@ -4843,6 +4954,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
ws@8.18.2: {}
zod-to-json-schema@3.24.5(zod@3.24.3):
dependencies:
zod: 3.24.3
@@ -0,0 +1,67 @@
/*
Warnings:
- Added the required column `organizationId` to the `projects` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "organizationId" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organization_users" (
"id" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
CONSTRAINT "organization_users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex
CREATE INDEX "organization_users_organizationId_idx" ON "organization_users"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "organization_users_userId_organizationId_key" ON "organization_users"("userId", "organizationId");
-- CreateIndex
CREATE INDEX "projects_organizationId_idx" ON "projects"("organizationId");
-- AddForeignKey
ALTER TABLE "organization_users" ADD CONSTRAINT "organization_users_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_users" ADD CONSTRAINT "organization_users_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;
+54
View File
@@ -14,6 +14,53 @@ datasource db {
directUrl = env("DIRECT_URL")
}
model User {
id String @id
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relationships
organizationUsers OrganizationUser[]
@@map("users")
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relationships
projects Project[]
organizationUsers OrganizationUser[]
@@map("organizations")
}
model OrganizationUser {
id String @id @default(cuid())
role String @default("member") // "owner", "admin", "member"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Foreign keys
userId String
organizationId String
// Relationships
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([userId, organizationId])
@@index([organizationId])
@@map("organization_users")
}
model Project {
id String @id @default(cuid())
name String
@@ -22,6 +69,13 @@ model Project {
updatedAt DateTime @updatedAt
questions Question[]
// Foreign key
organizationId String
// Relationship
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("projects")
}
+30
View File
@@ -0,0 +1,30 @@
import { Project } from './project';
export interface Organization {
id: string;
name: string;
slug: string;
description?: string;
createdAt: string;
updatedAt: string;
projects?: Project[];
organizationUsers?: OrganizationUser[];
}
export interface OrganizationUser {
id: string;
role: 'owner' | 'admin' | 'member';
createdAt: string;
updatedAt: string;
userId: string;
organizationId: string;
user?: User;
}
export interface User {
id: string;
email: string;
name?: string;
createdAt: string;
updatedAt: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { Project } from './project';
export interface Organization {
id: string;
name: string;
slug: string;
description?: string;
createdAt: string;
updatedAt: string;
projects?: Project[];
organizationUsers?: OrganizationUser[];
}
export interface OrganizationUser {
id: string;
role: 'owner' | 'admin' | 'member';
createdAt: string;
updatedAt: string;
userId: string;
organizationId: string;
user?: User;
}
export interface User {
id: string;
email: string;
name?: string;
createdAt: string;
updatedAt: string;
}