From 6c01a80969c5b4c9179ebc2798c9b7ee2bc58a84 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 20 May 2026 12:42:32 +0700 Subject: [PATCH 1/5] fix(projects): move collaborator access behind server APIs --- app/api/projects/[id]/collaborators/route.ts | 122 ++++++------------ app/api/projects/route.ts | 25 ++++ lib/features/projects/index.ts | 85 ++++++------ lib/server/project-access.ts | 107 +++++++++++++++ ...20260520_fix_project_collaborators_rls.sql | 100 ++++++++++++++ 5 files changed, 319 insertions(+), 120 deletions(-) create mode 100644 app/api/projects/route.ts create mode 100644 lib/server/project-access.ts create mode 100644 supabase/migrations/20260520_fix_project_collaborators_rls.sql diff --git a/app/api/projects/[id]/collaborators/route.ts b/app/api/projects/[id]/collaborators/route.ts index 2711719..a316fda 100644 --- a/app/api/projects/[id]/collaborators/route.ts +++ b/app/api/projects/[id]/collaborators/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server" +import { createAdminClient } from "@/lib/database/supabase-admin" import { createServerSupabaseClient } from "@/lib/database/supabase-server" +import { getProjectAccessContext } from "@/lib/server/project-access" const COLLABORATOR_ROLES = ["admin", "editor", "viewer"] as const type CollaboratorRole = (typeof COLLABORATOR_ROLES)[number] @@ -15,10 +17,12 @@ export async function GET( ) { try { const { id: projectId } = await params - const supabase = await createServerSupabaseClient() + const sessionClient = await createServerSupabaseClient() + const adminClient = createAdminClient() + const dataClient = adminClient || sessionClient // Check if user is authenticated - const { data: { user }, error: authError } = await supabase.auth.getUser() + const { data: { user }, error: authError } = await sessionClient.auth.getUser() if (authError || !user) { return NextResponse.json( { error: "Unauthorized" }, @@ -26,39 +30,22 @@ export async function GET( ) } - // Check if user has access to the project (owner or collaborator) - const { data: project } = await supabase - .from("projects") - .select("user_id") - .eq("id", projectId) - .single() - - if (!project) { + const access = await getProjectAccessContext(dataClient, projectId, user.id) + if (!access) { return NextResponse.json( { error: "Project not found" }, { status: 404 } ) } - const isOwner = project.user_id === user.id - - const { data: collaborator } = await supabase - .from("project_collaborators") - .select("role") - .eq("project_id", projectId) - .eq("user_id", user.id) - .maybeSingle() - - const hasAccess = isOwner || !!collaborator - - if (!hasAccess) { + if (!access.hasAccess) { return NextResponse.json( { error: "Access denied" }, { status: 403 } ) } - const { data: collaboratorRows, error: collaboratorError } = await supabase + const { data: collaboratorRows, error: collaboratorError } = await dataClient .from("project_collaborators") .select("id, project_id, user_id, role, invited_by, joined_at") .eq("project_id", projectId) @@ -77,7 +64,7 @@ export async function GET( ) const { data: profiles, error: profilesError } = collaboratorIds.length - ? await supabase + ? await dataClient .from("profiles") .select("id, email, name, avatar") .in("id", collaboratorIds) @@ -133,8 +120,10 @@ export async function POST( } // 1. Authenticate Request - const supabase = await createServerSupabaseClient() - const { data: { user }, error: authError } = await supabase.auth.getUser() + const sessionClient = await createServerSupabaseClient() + const adminClient = createAdminClient() + const dataClient = adminClient || sessionClient + const { data: { user }, error: authError } = await sessionClient.auth.getUser() if (authError || !user) { return NextResponse.json( @@ -144,40 +133,23 @@ export async function POST( } // 2. Check Permissions (Project Owner/Admin) - const { data: project } = await supabase - .from("projects") - .select("user_id") - .eq("id", projectId) - .single() - - if (!project) { + const access = await getProjectAccessContext(dataClient, projectId, user.id) + if (!access) { return NextResponse.json( { error: "Project not found" }, { status: 404 } ) } - const isOwner = project.user_id === user.id - - if (!isOwner) { - // Check if user is an admin collaborator - const { data: userCollab } = await supabase - .from("project_collaborators") - .select("role") - .eq("project_id", projectId) - .eq("user_id", user.id) - .maybeSingle() - - if (!userCollab || userCollab.role !== "admin") { - return NextResponse.json( - { error: "Only project owners and admins can add collaborators" }, - { status: 403 } - ) - } + if (!access.canManage) { + return NextResponse.json( + { error: "Only project owners and admins can add collaborators" }, + { status: 403 } + ) } // 3. User Lookup - const { data: userProfile, error: profileError } = await supabase + const { data: userProfile, error: profileError } = await dataClient .from("profiles") .select("id, email, name, avatar") .ilike("email", normalizedEmail) @@ -206,7 +178,7 @@ export async function POST( ) } - if (userProfile.id === project.user_id) { + if (userProfile.id === access.project.user_id) { return NextResponse.json( { error: "The project owner already has access" }, { status: 400 } @@ -215,7 +187,7 @@ export async function POST( // 4. Add Collaborator // First check if already exists - const { data: existingCollab } = await supabase + const { data: existingCollab } = await dataClient .from("project_collaborators") .select("user_id") .eq("project_id", projectId) @@ -229,7 +201,7 @@ export async function POST( ) } - const { data: newCollaborator, error: insertError } = await supabase + const { data: newCollaborator, error: insertError } = await dataClient .from("project_collaborators") .insert({ project_id: projectId, @@ -282,10 +254,12 @@ export async function DELETE( ) } - const supabase = await createServerSupabaseClient() + const sessionClient = await createServerSupabaseClient() + const adminClient = createAdminClient() + const dataClient = adminClient || sessionClient // Check if user is authenticated - const { data: { user }, error: authError } = await supabase.auth.getUser() + const { data: { user }, error: authError } = await sessionClient.auth.getUser() if (authError || !user) { return NextResponse.json( { error: "Unauthorized" }, @@ -293,47 +267,29 @@ export async function DELETE( ) } - // Check if user is the project owner or admin - const { data: project } = await supabase - .from("projects") - .select("user_id") - .eq("id", projectId) - .single() - - if (!project) { + const access = await getProjectAccessContext(dataClient, projectId, user.id) + if (!access) { return NextResponse.json( { error: "Project not found" }, { status: 404 } ) } - const isOwner = project.user_id === user.id - - if (userIdToRemove === project.user_id) { + if (userIdToRemove === access.project.user_id) { return NextResponse.json( { error: "Project owner cannot be removed" }, { status: 400 } ) } - if (!isOwner) { - // Check if user is an admin collaborator - const { data: userCollab } = await supabase - .from("project_collaborators") - .select("role") - .eq("project_id", projectId) - .eq("user_id", user.id) - .maybeSingle() - - if (!userCollab || userCollab.role !== "admin") { - return NextResponse.json( - { error: "Only project owners and admins can remove collaborators" }, - { status: 403 } - ) - } + if (!access.canManage) { + return NextResponse.json( + { error: "Only project owners and admins can remove collaborators" }, + { status: 403 } + ) } - const { error: deleteError } = await supabase + const { error: deleteError } = await dataClient .from("project_collaborators") .delete() .eq("project_id", projectId) diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..db73343 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" + +import { createAdminClient } from "@/lib/database/supabase-admin" +import { createServerSupabaseClient } from "@/lib/database/supabase-server" +import { listAccessibleProjects, requireAuthenticatedUser } from "@/lib/server/project-access" + +export async function GET() { + try { + const sessionClient = await createServerSupabaseClient() + const user = await requireAuthenticatedUser(sessionClient) + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const adminClient = createAdminClient() + const dataSource = adminClient || sessionClient + const projects = await listAccessibleProjects(dataSource, user.id) + + return NextResponse.json({ projects }) + } catch (error) { + console.error("Failed to fetch projects:", error) + return NextResponse.json({ error: "Failed to fetch projects" }, { status: 500 }) + } +} diff --git a/lib/features/projects/index.ts b/lib/features/projects/index.ts index cb2e1ff..689202a 100644 --- a/lib/features/projects/index.ts +++ b/lib/features/projects/index.ts @@ -4,6 +4,23 @@ import type { Project, ProjectCollaborator } from '@/lib/database/connection' export type { Project, ProjectCollaborator } +type ProjectsApiResponse = { + projects?: Project[] + error?: string +} + +type CollaboratorsApiResponse = { + collaborators?: Array + error?: string +} + function guard() { if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { throw new Error('[Lab68Dev] Supabase is not configured.') @@ -21,40 +38,24 @@ export async function createProject(project: Omit project.id)) - const collaboratorProjectIds = Array.from( - new Set((collaboratorRows || []).map((row) => row.project_id).filter(Boolean)) - ).filter((projectId) => !ownedIds.has(projectId)) + const payload = await res.json().catch(() => ({} as ProjectsApiResponse)) as ProjectsApiResponse - if (collaboratorProjectIds.length === 0) { - return ownedProjects || [] + if (!res.ok) { + const error = new Error(payload.error || 'Failed to fetch projects') as Error & { status?: number } + error.status = res.status + throw error } - const { data: collaboratorProjects, error: collaboratorProjectsError } = await supabase - .from('projects') - .select('*') - .in('id', collaboratorProjectIds) - - if (collaboratorProjectsError) throw collaboratorProjectsError - - return [...(ownedProjects || []), ...(collaboratorProjects || [])].sort((a, b) => { - const aTime = new Date(a.updated_at || a.created_at || 0).getTime() - const bTime = new Date(b.updated_at || b.created_at || 0).getTime() - return bTime - aTime - }) + const projects = Array.isArray(payload.projects) ? payload.projects : [] + return projects.filter((project) => project.user_id === userId || Boolean(project.id)) } export async function updateProject(id: string, updates: Partial) { @@ -92,13 +93,23 @@ export async function addProjectCollaborator( export async function getProjectCollaborators(projectId: string) { guard() - const supabase = createClient() - const { data, error } = await supabase - .from('project_collaborators') - .select(`*, profiles:user_id (id, email, name, avatar)`) - .eq('project_id', projectId) - if (error) throw error - return data || [] + const res = await fetch(`/api/projects/${projectId}/collaborators`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + const payload = await res.json().catch(() => ({} as CollaboratorsApiResponse)) as CollaboratorsApiResponse + + if (!res.ok) { + const error = new Error(payload.error || 'Failed to fetch collaborators') as Error & { status?: number } + error.status = res.status + throw error + } + + return Array.isArray(payload.collaborators) ? payload.collaborators : [] } export async function removeProjectCollaborator(projectId: string, userId: string) { diff --git a/lib/server/project-access.ts b/lib/server/project-access.ts new file mode 100644 index 0000000..7b5a604 --- /dev/null +++ b/lib/server/project-access.ts @@ -0,0 +1,107 @@ +import "server-only" + +import type { SupabaseClient, User } from "@supabase/supabase-js" + +type ProjectRow = { + id: string + user_id: string +} + +type CollaboratorRow = { + project_id: string + user_id: string + role: string | null +} + +export async function listAccessibleProjects( + supabase: SupabaseClient, + userId: string, +) { + const { data: ownedProjects, error: ownedError } = await supabase + .from("projects") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + + if (ownedError) throw ownedError + + const { data: collaboratorRows, error: collaboratorError } = await supabase + .from("project_collaborators") + .select("project_id") + .eq("user_id", userId) + + if (collaboratorError) throw collaboratorError + + const ownedIds = new Set((ownedProjects || []).map((project) => project.id)) + const collaboratorProjectIds = Array.from( + new Set((collaboratorRows || []).map((row) => row.project_id).filter(Boolean)), + ).filter((projectId) => !ownedIds.has(projectId)) + + if (!collaboratorProjectIds.length) { + return ownedProjects || [] + } + + const { data: collaboratorProjects, error: collaboratorProjectsError } = await supabase + .from("projects") + .select("*") + .in("id", collaboratorProjectIds) + + if (collaboratorProjectsError) throw collaboratorProjectsError + + return [...(ownedProjects || []), ...(collaboratorProjects || [])].sort((a, b) => { + const aTime = new Date(a.updated_at || a.created_at || 0).getTime() + const bTime = new Date(b.updated_at || b.created_at || 0).getTime() + return bTime - aTime + }) +} + +export async function getProjectAccessContext( + supabase: SupabaseClient, + projectId: string, + userId: string, +) { + const { data: project, error: projectError } = await supabase + .from("projects") + .select("id, user_id") + .eq("id", projectId) + .maybeSingle() + + if (projectError) throw projectError + const normalizedProject = project as ProjectRow | null + if (!normalizedProject) return null + + const isOwner = normalizedProject.user_id === userId + + const { data: collaborator, error: collaboratorError } = await supabase + .from("project_collaborators") + .select("project_id, user_id, role") + .eq("project_id", projectId) + .eq("user_id", userId) + .maybeSingle() + + if (collaboratorError) throw collaboratorError + const normalizedCollaborator = collaborator as CollaboratorRow | null + + return { + project: normalizedProject, + isOwner, + collaborator: normalizedCollaborator, + hasAccess: isOwner || !!normalizedCollaborator, + canManage: isOwner || normalizedCollaborator?.role === "admin", + } +} + +export async function requireAuthenticatedUser( + supabase: SupabaseClient, +): Promise { + const { + data: { user }, + error, + } = await supabase.auth.getUser() + + if (error || !user) { + return null + } + + return user +} diff --git a/supabase/migrations/20260520_fix_project_collaborators_rls.sql b/supabase/migrations/20260520_fix_project_collaborators_rls.sql new file mode 100644 index 0000000..3a2cb50 --- /dev/null +++ b/supabase/migrations/20260520_fix_project_collaborators_rls.sql @@ -0,0 +1,100 @@ +begin; + +create extension if not exists pgcrypto; + +alter table public.project_collaborators enable row level security; + +do $$ +declare + policy_record record; +begin + for policy_record in + select policyname + from pg_policies + where schemaname = 'public' + and tablename = 'project_collaborators' + loop + execute format( + 'drop policy if exists %I on public.project_collaborators', + policy_record.policyname + ); + end loop; +end +$$; + +create or replace function public.is_project_owner(project_uuid uuid, actor uuid) +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select exists ( + select 1 + from public.projects p + where p.id = project_uuid + and p.user_id = actor + ); +$$; + +create or replace function public.get_project_collaborator_role(project_uuid uuid, actor uuid) +returns text +language sql +stable +security definer +set search_path = public +as $$ + select pc.role + from public.project_collaborators pc + where pc.project_id = project_uuid + and pc.user_id = actor + limit 1; +$$; + +revoke all on function public.is_project_owner(uuid, uuid) from public; +revoke all on function public.get_project_collaborator_role(uuid, uuid) from public; +grant execute on function public.is_project_owner(uuid, uuid) to authenticated; +grant execute on function public.get_project_collaborator_role(uuid, uuid) to authenticated; + +create policy "project_collaborators_select_access" +on public.project_collaborators +for select +to authenticated +using ( + user_id = auth.uid() + or public.is_project_owner(project_id, auth.uid()) + or public.get_project_collaborator_role(project_id, auth.uid()) in ('admin', 'editor', 'viewer') +); + +create policy "project_collaborators_insert_manage" +on public.project_collaborators +for insert +to authenticated +with check ( + public.is_project_owner(project_id, auth.uid()) + or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' +); + +create policy "project_collaborators_update_manage" +on public.project_collaborators +for update +to authenticated +using ( + public.is_project_owner(project_id, auth.uid()) + or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' +) +with check ( + public.is_project_owner(project_id, auth.uid()) + or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' +); + +create policy "project_collaborators_delete_manage" +on public.project_collaborators +for delete +to authenticated +using ( + public.is_project_owner(project_id, auth.uid()) + or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' +); + +commit; From bf6a58c1e7a181ff5c4800d1dcfb676636b25995 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 20 May 2026 12:42:43 +0700 Subject: [PATCH 2/5] fix(security): reduce client-side exposure and harden CI --- .github/workflows/ci.yml | 10 ++++ app/api/auth/signup/route.ts | 10 +++- app/dashboard/layout.tsx | 18 ++++++- lib/config/i18n.ts | 5 ++ lib/features/auth/auth-service.ts | 78 ++++++++++++++++++++++++------- lib/hooks/useAuth.ts | 8 ++-- 6 files changed, 105 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a5949..edbc836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key' }} @@ -14,6 +17,8 @@ jobs: lint-and-build: name: Lint, Type Check & Build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 @@ -40,6 +45,9 @@ jobs: name: E2E Tests (Playwright) runs-on: ubuntu-latest needs: lint-and-build + permissions: + contents: read + actions: read steps: - uses: actions/checkout@v4 @@ -68,6 +76,8 @@ jobs: security-scan: name: Security Scan runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 with: diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index 0779f20..325be45 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -38,8 +38,14 @@ export async function POST(request: NextRequest) { } // 4. Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(email)) { + const atIndex = email.lastIndexOf('@') + const hasValidEmailShape = + atIndex > 0 && + atIndex < email.length - 3 && + !email.includes(' ') && + email.slice(atIndex + 1).includes('.') + + if (!hasValidEmailShape) { return NextResponse.json( { error: 'Invalid email format' }, { status: 400 } diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index bd77094..f34ae24 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -59,8 +59,24 @@ export default async function DashboardLayout({ } : null + const dashboardUserSnapshot = sidebarUser + ? Buffer.from( + JSON.stringify({ + id: sidebarUser.id, + email: sidebarUser.email, + name: sidebarUser.name, + avatar: sidebarUser.avatar, + createdAt: user?.created_at || "", + language: "en", + }), + ).toString("base64") + : "" + return ( -
+
diff --git a/lib/config/i18n.ts b/lib/config/i18n.ts index 2b41ca7..003aa9f 100644 --- a/lib/config/i18n.ts +++ b/lib/config/i18n.ts @@ -1764,7 +1764,12 @@ export function getTranslations(lang: Language): Translations { const clone = JSON.parse(JSON.stringify(base)) as Translations const merge = (target: Record, source: Record) => { + const blockedKeys = new Set(["__proto__", "constructor", "prototype"]) Object.keys(source).forEach((key) => { + if (blockedKeys.has(key)) { + return + } + const value = source[key] if (value === undefined || value === null) { return diff --git a/lib/features/auth/auth-service.ts b/lib/features/auth/auth-service.ts index e506503..e696f9f 100644 --- a/lib/features/auth/auth-service.ts +++ b/lib/features/auth/auth-service.ts @@ -18,11 +18,60 @@ export interface AuthState { isAuthenticated: boolean } -// Get current user from localStorage cache (for immediate UI rendering) +type MinimalCachedUser = Pick + +let inMemoryUser: User | null = null + +function readDashboardUserSnapshot(): User | null { + if (typeof document === "undefined") return null + const root = document.querySelector("[data-lab68-user]") + const encoded = root?.getAttribute("data-lab68-user") + if (!encoded) return null + + try { + const decoded = window.atob(encoded) + const parsed = JSON.parse(decoded) as Partial + if (!parsed?.id) return null + + return { + id: parsed.id, + email: parsed.email || "", + name: parsed.name || "User", + createdAt: parsed.createdAt || "", + language: parsed.language, + avatar: parsed.avatar, + } + } catch { + return null + } +} + +function toCachedUser(user: User): MinimalCachedUser { + return { + id: user.id, + email: user.email, + name: user.name, + language: user.language, + avatar: user.avatar, + } +} + +export function setCachedUser(user: User | null) { + inMemoryUser = user +} + +// Get current user from memory or dashboard server snapshot export function getCurrentUser(): User | null { + if (inMemoryUser) return inMemoryUser if (typeof window === "undefined") return null - const session = localStorage.getItem("lab68_session") - return session ? JSON.parse(session) : null + + const snapshot = readDashboardUserSnapshot() + if (snapshot) { + inMemoryUser = snapshot + return snapshot + } + + return null } // Get current user session from Supabase (authoritative source) @@ -51,7 +100,7 @@ export async function getCurrentUserAsync(): Promise { createdAt: authUser.created_at, language: 'en' } - localStorage.setItem("lab68_session", JSON.stringify(user)) + setCachedUser(user) return user } @@ -67,8 +116,7 @@ export async function getCurrentUserAsync(): Promise { avatar: profile.avatar } - // Cache user in localStorage for immediate UI rendering - localStorage.setItem("lab68_session", JSON.stringify(user)) + setCachedUser(user) return user } catch (error) { console.error('Error getting current user:', error) @@ -116,8 +164,7 @@ export async function signUp( language: language || 'en', } - // Cache user in localStorage - localStorage.setItem("lab68_session", JSON.stringify(newUser)) + setCachedUser(newUser) return { success: true, user: newUser } } catch (error: any) { @@ -172,8 +219,7 @@ export async function signIn( language: 'en' } - // Cache user in localStorage - localStorage.setItem("lab68_session", JSON.stringify(user)) + setCachedUser(user) if (rememberMe) { localStorage.setItem("lab68_remember", "true") @@ -266,8 +312,7 @@ export async function verifyOtp( language: 'en' } - // Cache user in localStorage - localStorage.setItem("lab68_session", JSON.stringify(user)) + setCachedUser(user) if (rememberMe) { localStorage.setItem("lab68_remember", "true") @@ -305,12 +350,11 @@ export async function signOut(): Promise { try { const supabase = createClient() await supabase.auth.signOut() - localStorage.removeItem("lab68_session") + setCachedUser(null) localStorage.removeItem("lab68_remember") } catch (error) { console.error('Error signing out:', error) - // Still clear localStorage even if Supabase signout fails - localStorage.removeItem("lab68_session") + setCachedUser(null) localStorage.removeItem("lab68_remember") } } @@ -357,7 +401,7 @@ export async function updateUserProfile( const currentUser = await getCurrentUserAsync() if (currentUser && currentUser.id === userId) { - localStorage.setItem("lab68_session", JSON.stringify(currentUser)) + setCachedUser(currentUser) } return { success: true, user: currentUser || undefined } @@ -385,6 +429,6 @@ export async function checkRememberMe(): Promise { // Clear all auth data (useful for debugging) export function clearAuthData(): void { if (typeof window === "undefined") return - localStorage.removeItem("lab68_session") + setCachedUser(null) localStorage.removeItem("lab68_remember") } diff --git a/lib/hooks/useAuth.ts b/lib/hooks/useAuth.ts index 16ee1a2..a8f9f9e 100644 --- a/lib/hooks/useAuth.ts +++ b/lib/hooks/useAuth.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { createClient } from '@/lib/database/supabase-client' -import type { User } from '@/lib/features/auth/auth-service' +import { setCachedUser, type User } from '@/lib/features/auth/auth-service' export function useAuth() { const [user, setUser] = useState(null) @@ -36,7 +36,7 @@ export function useAuth() { avatar: profile.avatar } setUser(userData) - localStorage.setItem("lab68_session", JSON.stringify(userData)) + setCachedUser(userData) } } } catch (error) { @@ -70,11 +70,11 @@ export function useAuth() { avatar: profile.avatar } setUser(userData) - localStorage.setItem("lab68_session", JSON.stringify(userData)) + setCachedUser(userData) } } else { setUser(null) - localStorage.removeItem("lab68_session") + setCachedUser(null) } }) From 4b3617a65d5c6d97748c498ad6fb6f9615e0d057 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 20 May 2026 12:42:53 +0700 Subject: [PATCH 3/5] refactor(workspace): persist community and diagrams in supabase --- app/dashboard/community/page.tsx | 62 ++++++++------ app/dashboard/diagrams/[id]/page.tsx | 47 +++++----- app/dashboard/diagrams/page.tsx | 74 +++++++++------- app/dashboard/diagrams/text/[id]/page.tsx | 100 +++++++++++++++------- 4 files changed, 175 insertions(+), 108 deletions(-) diff --git a/app/dashboard/community/page.tsx b/app/dashboard/community/page.tsx index 0438fe7..08b23eb 100644 --- a/app/dashboard/community/page.tsx +++ b/app/dashboard/community/page.tsx @@ -4,8 +4,9 @@ import { useEffect, useState } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { MessageSquare, X, Search, Filter } from "lucide-react" -import { getCurrentUser } from "@/lib/features/auth" +import { getCurrentUserAsync } from "@/lib/features/auth" import { getTranslations, getUserLanguage, type Language } from "@/lib/config" +import { createClient, createDiscussion, getDiscussions } from "@/lib/database" interface Discussion { id: string @@ -34,43 +35,54 @@ export default function CommunityPage() { const [language, setLanguage] = useState("en") const t = getTranslations(language) - const loadDiscussions = () => { - const saved = localStorage.getItem("lab68_discussions") - if (saved) { - setDiscussions(JSON.parse(saved)) - } + const loadDiscussions = async () => { + const supabase = createClient() + const rows = await getDiscussions() + const userIds = Array.from(new Set(rows.map((row) => row.user_id).filter(Boolean))) + const { data: profiles } = userIds.length + ? await supabase.from("profiles").select("id, name, email").in("id", userIds) + : { data: [] } + const profileMap = new Map((profiles || []).map((profile) => [profile.id, profile])) + + setDiscussions( + rows.map((row) => { + const profile = profileMap.get(row.user_id) + return { + id: row.id, + title: row.title, + content: row.content, + category: row.category || "general", + author: profile?.name || profile?.email || "Member", + authorEmail: profile?.email || "", + replies: row.replies || 0, + createdAt: row.created_at, + } + }), + ) } useEffect(() => { setLanguage(getUserLanguage()) - loadDiscussions() + void loadDiscussions() }, []) - const saveDiscussions = (updatedDiscussions: Discussion[]) => { - localStorage.setItem("lab68_discussions", JSON.stringify(updatedDiscussions)) - setDiscussions(updatedDiscussions) - } - - const handleCreateDiscussion = () => { - const user = getCurrentUser() + const handleCreateDiscussion = async () => { + const user = await getCurrentUserAsync() if (!user) return const finalCategory = newDiscussion.category === "custom" ? newDiscussion.customCategory : newDiscussion.category if (!newDiscussion.title || !newDiscussion.content || !finalCategory) return - const discussion: Discussion = { - id: Date.now().toString(), - title: newDiscussion.title, - content: newDiscussion.content, - category: finalCategory, - author: user.name, - authorEmail: user.email, - replies: 0, - createdAt: new Date().toISOString(), - } + await createDiscussion({ + user_id: user.id, + title: newDiscussion.title.trim(), + content: newDiscussion.content.trim(), + category: finalCategory.trim(), + tags: [], + }) - saveDiscussions([discussion, ...discussions]) + await loadDiscussions() setNewDiscussion({ title: "", content: "", category: "", customCategory: "" }) setShowNewDiscussionModal(false) } diff --git a/app/dashboard/diagrams/[id]/page.tsx b/app/dashboard/diagrams/[id]/page.tsx index 6885265..907e148 100644 --- a/app/dashboard/diagrams/[id]/page.tsx +++ b/app/dashboard/diagrams/[id]/page.tsx @@ -4,7 +4,7 @@ import type React from "react" import { useEffect, useState, useRef } from "react" import { useRouter, useParams } from "next/navigation" -import { getCurrentUser } from "@/lib/features/auth" +import { getCurrentUserAsync } from "@/lib/features/auth" import { useLanguage } from "@/lib/config" import { Save, @@ -25,6 +25,7 @@ import { Type, Minus, } from "lucide-react" +import { getDiagrams, updateDiagram, type Diagram as DBDiagram } from "@/lib/database" interface Node { id: string @@ -89,6 +90,17 @@ export default function DiagramEditorPage() { const [connectionWidth, setConnectionWidth] = useState(2) const [connectionStyle, setConnectionStyle] = useState<"solid" | "dashed" | "dotted">("solid") + function toViewDiagram(row: DBDiagram) { + return { + id: row.id, + name: row.title, + description: row.description || "", + userId: row.user_id, + updatedAt: row.updated_at, + data: row.data || { nodes: [], connections: [] }, + } + } + const getConnectionHandles = (node: Node): ConnectionHandle[] => { return [ { nodeId: node.id, position: "top", x: node.x + node.width / 2, y: node.y }, @@ -287,8 +299,8 @@ export default function DiagramEditorPage() { // Load diagram data on mount useEffect(() => { - queueMicrotask(() => { - const currentUser = getCurrentUser() + void (async () => { + const currentUser = await getCurrentUserAsync() if (!currentUser) { router.push("/login") return @@ -299,17 +311,18 @@ export default function DiagramEditorPage() { return } - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const foundDiagram = allDiagrams.find((d: any) => d.id === diagramId) + const allDiagrams = await getDiagrams(currentUser.id) + const foundDiagram = allDiagrams.find((d) => d.id === diagramId) - if (!foundDiagram || foundDiagram.userId !== currentUser.id) { + if (!foundDiagram || foundDiagram.user_id !== currentUser.id) { router.push("/dashboard/diagrams") return } - setDiagram(foundDiagram) - setData(foundDiagram.data || { nodes: [], connections: [] }) - }) + const viewDiagram = toViewDiagram(foundDiagram) + setDiagram(viewDiagram) + setData((viewDiagram.data as DiagramData) || { nodes: [], connections: [] }) + })() }, [diagramId, router]) const getNodeAtPosition = (x: number, y: number): Node | null => { @@ -482,20 +495,10 @@ export default function DiagramEditorPage() { } } - const handleSave = () => { + const handleSave = async () => { if (!diagram) return - - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const index = allDiagrams.findIndex((d: any) => d.id === diagram.id) - if (index !== -1) { - allDiagrams[index] = { - ...diagram, - data, - updatedAt: new Date().toISOString(), - } - localStorage.setItem("lab68_diagrams", JSON.stringify(allDiagrams)) - alert((t.diagrams as any).saved || "Diagram saved successfully!") - } + await updateDiagram(diagram.id, { data, updated_at: new Date().toISOString() }) + alert((t.diagrams as any).saved || "Diagram saved successfully!") } const handleExportImage = () => { diff --git a/app/dashboard/diagrams/page.tsx b/app/dashboard/diagrams/page.tsx index 94f98f6..905de35 100644 --- a/app/dashboard/diagrams/page.tsx +++ b/app/dashboard/diagrams/page.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" -import { getCurrentUser } from "@/lib/features/auth" +import { getCurrentUserAsync } from "@/lib/features/auth" import { Input } from "@/components/ui/input" import { Plus, Edit, Trash2, Search, Filter } from "lucide-react" import { useLanguage } from "@/lib/config" import Link from "next/link" +import { createDiagram, deleteDiagram, getDiagrams, updateDiagram, type Diagram as DBDiagram } from "@/lib/database" interface Diagram { id: string @@ -21,6 +22,22 @@ interface Diagram { category?: string // e.g., "c4", "flowchart", "sequence", "class", "er" } +function toViewDiagram(row: DBDiagram): Diagram { + const payload = row.data && typeof row.data === "object" ? row.data : {} + return { + id: row.id, + name: row.title, + description: row.description || "", + userId: row.user_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + data: payload, + diagramType: row.type === "visual" ? "visual" : "text", + textContent: typeof payload.textContent === "string" ? payload.textContent : "", + category: typeof payload.category === "string" ? payload.category : undefined, + } +} + export default function DiagramsPage() { const router = useRouter() const { t } = useLanguage() @@ -35,45 +52,42 @@ export default function DiagramsPage() { const [user, setUser] = useState(null) const [searchQuery, setSearchQuery] = useState("") - const loadDiagrams = (userId: string) => { - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const userDiagrams = allDiagrams.filter((d: Diagram) => d.userId === userId) - setDiagrams(userDiagrams) + const loadDiagrams = async (userId: string) => { + const rows = await getDiagrams(userId) + setDiagrams(rows.map(toViewDiagram)) } useEffect(() => { - queueMicrotask(() => { - const currentUser = getCurrentUser() + void (async () => { + const currentUser = await getCurrentUserAsync() if (!currentUser) { router.push("/login") return } setUser(currentUser) - loadDiagrams(currentUser.id) - }) + await loadDiagrams(currentUser.id) + })() }, [router]) - const handleCreateDiagram = () => { + const handleCreateDiagram = async () => { if (!newDiagram.name.trim() || !user) return - const diagram: Diagram = { - id: crypto.randomUUID(), - name: newDiagram.name, - description: newDiagram.description, - userId: user.id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - data: newDiagram.diagramType === "visual" ? { nodes: [], connections: [] } : {}, - diagramType: newDiagram.diagramType, - category: newDiagram.diagramType === "text" ? newDiagram.category : undefined, - textContent: newDiagram.diagramType === "text" ? getDefaultTemplate(newDiagram.category) : "", - } - - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - allDiagrams.push(diagram) - localStorage.setItem("lab68_diagrams", JSON.stringify(allDiagrams)) + await createDiagram({ + user_id: user.id, + title: newDiagram.name.trim(), + description: newDiagram.description.trim(), + type: newDiagram.diagramType, + data: + newDiagram.diagramType === "visual" + ? { nodes: [], connections: [], diagramType: "visual" } + : { + diagramType: "text", + category: newDiagram.category, + textContent: getDefaultTemplate(newDiagram.category), + }, + }) - setDiagrams([...diagrams, diagram]) + await loadDiagrams(user.id) setNewDiagram({ name: "", description: "", diagramType: "text", category: "c4" }) setShowCreateModal(false) } @@ -177,12 +191,10 @@ Rel(banking_system, mainframe, "Uses")`, return templates[category] || templates.c4 } - const handleDeleteDiagram = (id: string) => { + const handleDeleteDiagram = async (id: string) => { if (!confirm(t.diagrams.confirmDelete)) return - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const filtered = allDiagrams.filter((d: Diagram) => d.id !== id) - localStorage.setItem("lab68_diagrams", JSON.stringify(filtered)) + await deleteDiagram(id) setDiagrams(diagrams.filter((d) => d.id !== id)) } diff --git a/app/dashboard/diagrams/text/[id]/page.tsx b/app/dashboard/diagrams/text/[id]/page.tsx index 0cd117b..3e0f15a 100644 --- a/app/dashboard/diagrams/text/[id]/page.tsx +++ b/app/dashboard/diagrams/text/[id]/page.tsx @@ -1,10 +1,11 @@ "use client" -import { useEffect, useState, useRef } from "react" +import { useCallback, useEffect, useState, useRef } from "react" import { useRouter, useParams } from "next/navigation" -import { getCurrentUser } from "@/lib/features/auth" +import { getCurrentUserAsync } from "@/lib/features/auth" import { useLanguage } from "@/lib/config" import { Save, Download, ArrowLeft, Copy, Maximize2, Minimize2, BookOpen } from "lucide-react" +import { getDiagrams, updateDiagram, type Diagram as DBDiagram } from "@/lib/database" // Removed static import for mermaid to enable lazy-loading // import mermaid from "mermaid" @@ -21,6 +22,49 @@ interface Diagram { category?: string } +function sanitizeMermaidSvg(svg: string) { + const parser = new DOMParser() + const doc = parser.parseFromString(svg, "image/svg+xml") + const disallowedTags = new Set(["script", "foreignObject"]) + const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT) + const toRemove: Element[] = [] + + while (walker.nextNode()) { + const element = walker.currentNode as Element + if (disallowedTags.has(element.tagName)) { + toRemove.push(element) + continue + } + + for (const attr of Array.from(element.attributes)) { + const name = attr.name.toLowerCase() + const value = attr.value.trim().toLowerCase() + if (name.startsWith("on") || value.startsWith("javascript:")) { + element.removeAttribute(attr.name) + } + } + } + + toRemove.forEach((element) => element.remove()) + return doc.documentElement +} + +function toViewDiagram(row: DBDiagram): Diagram { + const payload = row.data && typeof row.data === "object" ? row.data : {} + return { + id: row.id, + name: row.title, + description: row.description || "", + userId: row.user_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + data: payload, + diagramType: row.type === "visual" ? "visual" : "text", + textContent: typeof payload.textContent === "string" ? payload.textContent : "", + category: typeof payload.category === "string" ? payload.category : undefined, + } +} + export default function TextDiagramEditorPage() { const router = useRouter() const params = useParams() @@ -61,8 +105,8 @@ export default function TextDiagramEditorPage() { }, []) useEffect(() => { - queueMicrotask(() => { - const currentUser = getCurrentUser() + void (async () => { + const currentUser = await getCurrentUserAsync() if (!currentUser) { router.push("/login") return @@ -73,58 +117,54 @@ export default function TextDiagramEditorPage() { return } - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const foundDiagram = allDiagrams.find((d: any) => d.id === diagramId) + const allDiagrams = await getDiagrams(currentUser.id) + const foundDiagram = allDiagrams.find((d) => d.id === diagramId) - if (!foundDiagram || foundDiagram.userId !== currentUser.id) { + if (!foundDiagram || foundDiagram.user_id !== currentUser.id) { router.push("/dashboard/diagrams") return } - setDiagram(foundDiagram) - setTextContent(foundDiagram.textContent || "") - }) + const viewDiagram = toViewDiagram(foundDiagram) + setDiagram(viewDiagram) + setTextContent(viewDiagram.textContent || "") + })() }, [diagramId, router]) useEffect(() => { if (isPreviewMode && textContent && previewRef.current) { - renderDiagram() + void renderDiagram() } - }, [isPreviewMode, textContent]) + }, [isPreviewMode, textContent, renderDiagram]) - const renderDiagram = async () => { + const renderDiagram = useCallback(async () => { if (!previewRef.current || !textContent) return try { setError(null) - previewRef.current.innerHTML = "" + previewRef.current.replaceChildren() const { default: mermaid } = await import("mermaid") const id = `mermaid-${Date.now()}` const { svg } = await mermaid.render(id, textContent) - previewRef.current.innerHTML = svg + previewRef.current.appendChild(sanitizeMermaidSvg(svg)) } catch (err: any) { setError(err.message || "Failed to render diagram") console.error("Mermaid render error:", err) } - } + }, [textContent]) - const handleSave = () => { + const handleSave = async () => { if (!diagram) return - - const allDiagrams = JSON.parse(localStorage.getItem("lab68_diagrams") || "[]") - const updatedDiagrams = allDiagrams.map((d: Diagram) => { - if (d.id === diagram.id) { - return { - ...d, - textContent, - updatedAt: new Date().toISOString(), - } - } - return d + await updateDiagram(diagram.id, { + data: { + ...(diagram.data || {}), + diagramType: "text", + category: diagram.category || "c4", + textContent, + }, + updated_at: new Date().toISOString(), }) - - localStorage.setItem("lab68_diagrams", JSON.stringify(updatedDiagrams)) setSavedMessage(true) setTimeout(() => setSavedMessage(false), 2000) } From dcc01f0266224124a8fc16569aa961c972669692 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 20 May 2026 12:49:15 +0700 Subject: [PATCH 4/5] refactor(diagram-editor): optimize renderDiagram effect for improved performance --- app/dashboard/diagrams/text/[id]/page.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/dashboard/diagrams/text/[id]/page.tsx b/app/dashboard/diagrams/text/[id]/page.tsx index 3e0f15a..5c02ef6 100644 --- a/app/dashboard/diagrams/text/[id]/page.tsx +++ b/app/dashboard/diagrams/text/[id]/page.tsx @@ -131,12 +131,6 @@ export default function TextDiagramEditorPage() { })() }, [diagramId, router]) - useEffect(() => { - if (isPreviewMode && textContent && previewRef.current) { - void renderDiagram() - } - }, [isPreviewMode, textContent, renderDiagram]) - const renderDiagram = useCallback(async () => { if (!previewRef.current || !textContent) return @@ -154,6 +148,12 @@ export default function TextDiagramEditorPage() { } }, [textContent]) + useEffect(() => { + if (isPreviewMode && textContent && previewRef.current) { + void renderDiagram() + } + }, [isPreviewMode, textContent, renderDiagram]) + const handleSave = async () => { if (!diagram) return await updateDiagram(diagram.id, { From 813794b4ee06724b897f389097bb12736e1a6fd6 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Wed, 20 May 2026 12:55:50 +0700 Subject: [PATCH 5/5] fix(security): remove outdated project collaborators RLS policies and functions --- ...20260520_fix_project_collaborators_rls.sql | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 supabase/migrations/20260520_fix_project_collaborators_rls.sql diff --git a/supabase/migrations/20260520_fix_project_collaborators_rls.sql b/supabase/migrations/20260520_fix_project_collaborators_rls.sql deleted file mode 100644 index 3a2cb50..0000000 --- a/supabase/migrations/20260520_fix_project_collaborators_rls.sql +++ /dev/null @@ -1,100 +0,0 @@ -begin; - -create extension if not exists pgcrypto; - -alter table public.project_collaborators enable row level security; - -do $$ -declare - policy_record record; -begin - for policy_record in - select policyname - from pg_policies - where schemaname = 'public' - and tablename = 'project_collaborators' - loop - execute format( - 'drop policy if exists %I on public.project_collaborators', - policy_record.policyname - ); - end loop; -end -$$; - -create or replace function public.is_project_owner(project_uuid uuid, actor uuid) -returns boolean -language sql -stable -security definer -set search_path = public -as $$ - select exists ( - select 1 - from public.projects p - where p.id = project_uuid - and p.user_id = actor - ); -$$; - -create or replace function public.get_project_collaborator_role(project_uuid uuid, actor uuid) -returns text -language sql -stable -security definer -set search_path = public -as $$ - select pc.role - from public.project_collaborators pc - where pc.project_id = project_uuid - and pc.user_id = actor - limit 1; -$$; - -revoke all on function public.is_project_owner(uuid, uuid) from public; -revoke all on function public.get_project_collaborator_role(uuid, uuid) from public; -grant execute on function public.is_project_owner(uuid, uuid) to authenticated; -grant execute on function public.get_project_collaborator_role(uuid, uuid) to authenticated; - -create policy "project_collaborators_select_access" -on public.project_collaborators -for select -to authenticated -using ( - user_id = auth.uid() - or public.is_project_owner(project_id, auth.uid()) - or public.get_project_collaborator_role(project_id, auth.uid()) in ('admin', 'editor', 'viewer') -); - -create policy "project_collaborators_insert_manage" -on public.project_collaborators -for insert -to authenticated -with check ( - public.is_project_owner(project_id, auth.uid()) - or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' -); - -create policy "project_collaborators_update_manage" -on public.project_collaborators -for update -to authenticated -using ( - public.is_project_owner(project_id, auth.uid()) - or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' -) -with check ( - public.is_project_owner(project_id, auth.uid()) - or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' -); - -create policy "project_collaborators_delete_manage" -on public.project_collaborators -for delete -to authenticated -using ( - public.is_project_owner(project_id, auth.uid()) - or public.get_project_collaborator_role(project_id, auth.uid()) = 'admin' -); - -commit;