diff --git a/.cursor/rules/directory-naming-conventions.mdc b/.cursor/rules/directory-naming-conventions.mdc index b93c988b..b6ecb6aa 100644 --- a/.cursor/rules/directory-naming-conventions.mdc +++ b/.cursor/rules/directory-naming-conventions.mdc @@ -1,5 +1,5 @@ --- -description: -globs: -alwaysApply: false +description: +globs: +alwaysApply: true --- diff --git a/app/api/organizations/[id]/members/[userId]/route.ts b/app/api/organizations/[id]/members/[userId]/route.ts new file mode 100644 index 00000000..eef39f25 --- /dev/null +++ b/app/api/organizations/[id]/members/[userId]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/organizations/[id]/members/route.ts b/app/api/organizations/[id]/members/route.ts new file mode 100644 index 00000000..cd6994b6 --- /dev/null +++ b/app/api/organizations/[id]/members/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/organizations/[id]/route.ts b/app/api/organizations/[id]/route.ts new file mode 100644 index 00000000..58c8232c --- /dev/null +++ b/app/api/organizations/[id]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/organizations/by-slug/[slug]/route.ts b/app/api/organizations/by-slug/[slug]/route.ts new file mode 100644 index 00000000..280eead4 --- /dev/null +++ b/app/api/organizations/by-slug/[slug]/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts new file mode 100644 index 00000000..0a190776 --- /dev/null +++ b/app/api/organizations/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index a17f42ce..83c7d11b 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -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 } + ); + } } \ No newline at end of file diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 164d84ad..0ab89535 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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); diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 00000000..94bb6c84 --- /dev/null +++ b/app/auth/callback/route.ts @@ -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('/') +} \ No newline at end of file diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 00000000..3c40a5c5 --- /dev/null +++ b/app/auth/confirm/route.ts @@ -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') +} \ No newline at end of file diff --git a/app/components/LogoutButton.tsx b/app/components/LogoutButton.tsx new file mode 100644 index 00000000..aed5da36 --- /dev/null +++ b/app/components/LogoutButton.tsx @@ -0,0 +1,29 @@ +'use client' + +import Link from 'next/link' + +export default function LogoutButton() { + return ( + + + + + + + Log out + + ) +} \ No newline at end of file diff --git a/app/error/page.tsx b/app/error/page.tsx new file mode 100644 index 00000000..3c170be4 --- /dev/null +++ b/app/error/page.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function ErrorPage() { + return

Sorry, something went wrong

+} \ No newline at end of file diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 00000000..7d2f882b --- /dev/null +++ b/app/login/actions.ts @@ -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') +} \ No newline at end of file diff --git a/app/login/confirmation/page.tsx b/app/login/confirmation/page.tsx new file mode 100644 index 00000000..1a07b097 --- /dev/null +++ b/app/login/confirmation/page.tsx @@ -0,0 +1,15 @@ +export default function ConfirmationPage() { + return ( +
+

Check your email

+
+

+ We've sent you a magic link to your email address. Click the link in the email to sign in. +

+

+ If you don't see the email, check your spam folder. The link will expire after 24 hours. +

+
+
+ ) +} \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 00000000..7f6b3185 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,33 @@ +import { signInWithMagicLink } from '@/app/login/actions' + +export default function LoginPage() { + return ( +
+

Sign in to your account

+
+
+ + +
+ +

+ We'll email you a magic link for a password-free sign in. +

+
+
+ ) +} \ No newline at end of file diff --git a/app/logout/page.tsx b/app/logout/page.tsx new file mode 100644 index 00000000..ddf89222 --- /dev/null +++ b/app/logout/page.tsx @@ -0,0 +1,18 @@ +import { logout } from '@/app/login/actions' + +export default function LogoutPage() { + return ( +
+

Log out

+

Are you sure you want to log out?

+
+ +
+
+ ) +} \ No newline at end of file diff --git a/app/organizations/[slug]/page.tsx b/app/organizations/[slug]/page.tsx new file mode 100644 index 00000000..c4b43f4f --- /dev/null +++ b/app/organizations/[slug]/page.tsx @@ -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(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 ( +
+
+
+
+
+
+
+ ); + } + + if (!organization) { + return ( +
+

Organization not found

+

The organization you're looking for doesn't exist or you don't have access to it.

+ + + +
+ ); + } + + return ( +
+
+
+

{organization.name}

+ {organization.description && ( +

{organization.description}

+ )} +
+
+ + + + +
+
+ +
+

Projects

+ {organization.projects && organization.projects.length > 0 ? ( + + ) : ( +
+

No projects yet

+

Create your first project to get started

+ +
+ )} +
+ + {/* Create project dialog */} + {organization && ( + { + // Refresh the organization data to show the new project + window.location.reload(); + }} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/app/private/page.tsx b/app/private/page.tsx new file mode 100644 index 00000000..15a590c8 --- /dev/null +++ b/app/private/page.tsx @@ -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

Hello {data.user.email}

+} \ No newline at end of file diff --git a/components/global/home-sidebar.tsx b/components/global/home-sidebar.tsx index af66f947..54f5af58 100644 --- a/components/global/home-sidebar.tsx +++ b/components/global/home-sidebar.tsx @@ -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() { Admin - + + + diff --git a/components/global/sidebar.tsx b/components/global/sidebar.tsx index 7e15f4ff..14d445ea 100644 --- a/components/global/sidebar.tsx +++ b/components/global/sidebar.tsx @@ -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() { Project Owner - + + + diff --git a/components/organizations/CreateOrganizationDialog.tsx b/components/organizations/CreateOrganizationDialog.tsx new file mode 100644 index 00000000..503cac11 --- /dev/null +++ b/components/organizations/CreateOrganizationDialog.tsx @@ -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 ( + + +
+ + Create organization + + Create a new organization to manage projects and team members. + + + +
+
+ + setName(e.target.value)} + placeholder="Acme Inc." + className="col-span-3" + required + /> +
+ +
+ +