From dc773ccbf69f6ea84d5bed0f6a68f79ae6c57e8b Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:50:58 +0530 Subject: [PATCH 1/6] feat(organization): implement the organization delete functionality --- app/(main)/settings/onboarding/page.tsx | 142 +++++++++++++++--- .../organization/[orgId]/projects/route.ts | 8 +- app/api/organization/[orgId]/route.ts | 29 ++++ app/components/guardrails/BanListModal.tsx | 18 +-- .../guardrails/ValidatorConfigPanel.tsx | 19 +-- .../knowledge-base/DocumentPickerModal.tsx | 7 +- .../settings/credentials/CredentialForm.tsx | 18 +-- .../credentials/CredentialFormPanel.tsx | 18 +-- .../settings/onboarding/AddProjectModal.tsx | 22 +-- .../onboarding/DeleteOrganizationModal.tsx | 122 +++++++++++++++ .../settings/onboarding/EditProjectModal.tsx | 22 +-- .../settings/onboarding/OrganizationList.tsx | 139 +++++++++++++---- .../settings/onboarding/ProjectList.tsx | 67 ++++++--- app/components/settings/onboarding/index.ts | 1 + app/components/ui/Checkbox.tsx | 81 ++++++++++ app/components/ui/index.ts | 1 + app/lib/constants.ts | 2 +- app/lib/types/onboarding.ts | 11 ++ 18 files changed, 571 insertions(+), 156 deletions(-) create mode 100644 app/api/organization/[orgId]/route.ts create mode 100644 app/components/settings/onboarding/DeleteOrganizationModal.tsx create mode 100644 app/components/ui/Checkbox.tsx diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index 256a6a39..bc1034f2 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -1,19 +1,21 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; import PageHeader from "@/app/components/PageHeader"; import { useAuth } from "@/app/lib/context/AuthContext"; import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { + DeleteOrganizationModal, + OnboardingCredentials, OnboardingForm, OnboardingSuccess, OrganizationList, ProjectList, StepIndicator, UserList, - OnboardingCredentials, } from "@/app/components/settings/onboarding"; +import { useToast } from "@/app/hooks/useToast"; import { Organization, Project, @@ -25,6 +27,8 @@ import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; import { TabNavigation } from "@/app/components/ui"; +const SEARCH_DEBOUNCE_MS = 300; + const PROJECT_TABS = [ { id: "users", label: "Users" }, { id: "credentials", label: "Credentials" }, @@ -71,6 +75,34 @@ export default function OnboardingPage() { null, ); const [activeProjectTab, setActiveProjectTab] = useState("users"); + const [orgToDelete, setOrgToDelete] = useState(null); + const [isDeletingOrg, setIsDeletingOrg] = useState(false); + const [orgSearchInput, setOrgSearchInput] = useState(""); + const [debouncedOrgSearch, setDebouncedOrgSearch] = useState(""); + const [projectSearchInput, setProjectSearchInput] = useState(""); + const [debouncedProjectSearch, setDebouncedProjectSearch] = useState(""); + const toast = useToast(); + + // Debounce the search inputs so we don't fire a request per keystroke. + useEffect(() => { + const t = setTimeout( + () => setDebouncedOrgSearch(orgSearchInput.trim()), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(t); + }, [orgSearchInput]); + useEffect(() => { + const t = setTimeout( + () => setDebouncedProjectSearch(projectSearchInput.trim()), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(t); + }, [projectSearchInput]); + + const orgExtraParams = useMemo( + () => (debouncedOrgSearch ? { search: debouncedOrgSearch } : undefined), + [debouncedOrgSearch], + ); const { items: organizations, @@ -82,6 +114,7 @@ export default function OnboardingPage() { } = usePaginatedList({ endpoint: "/api/organization", limit: DEFAULT_PAGE_LIMIT, + extraParams: orgExtraParams, }); const scrollRef = useInfiniteScroll({ @@ -90,34 +123,34 @@ export default function OnboardingPage() { isLoading: isLoadingOrgs || isLoadingMore, }); + // Decide between "list" and "form" view ONCE on initial load. Without this + // gate the effect would re-run during search refetches: an empty search + // result would briefly look like "no orgs at all" and bounce the user to + // the create-org form instead of just showing the empty search state. + const initialOrgViewDecidedRef = useRef(false); useEffect(() => { - if (isLoadingOrgs) { - setView("loading"); - return; - } - if (view === "loading") { - setView(organizations.length > 0 ? "list" : "form"); - } + if (initialOrgViewDecidedRef.current) return; + if (isLoadingOrgs) return; + initialOrgViewDecidedRef.current = true; + setView(organizations.length > 0 ? "list" : "form"); }, [isLoadingOrgs, organizations.length]); - const fetchProjects = useCallback( - async (org: Organization) => { - setSelectedOrg(org); - setView("projects"); + const loadProjects = useCallback( + async (orgId: number, search: string) => { setIsLoadingProjects(true); - setProjects([]); - try { + const qs = search ? `?search=${encodeURIComponent(search)}` : ""; const result = await apiFetch( - `/api/organization/${org.id}/projects`, + `/api/organization/${orgId}/projects${qs}`, activeKey?.key ?? "", ); - if (result.success && result.data) { setProjects(result.data); + } else { + setProjects([]); } } catch { - // keep empty list + setProjects([]); } finally { setIsLoadingProjects(false); } @@ -125,11 +158,33 @@ export default function OnboardingPage() { [activeKey], ); + const fetchProjects = useCallback( + async (org: Organization) => { + setSelectedOrg(org); + setView("projects"); + setProjectSearchInput(""); + setDebouncedProjectSearch(""); + setProjects([]); + await loadProjects(org.id, ""); + }, + [loadProjects], + ); + + // Refetch projects whenever the debounced search changes while we're on + // the projects view (initial fetch is handled by `fetchProjects`). + useEffect(() => { + if (view !== "projects" || !selectedOrg) return; + void loadProjects(selectedOrg.id, debouncedProjectSearch); + }, [debouncedProjectSearch, view, selectedOrg, loadProjects]); + const refreshProjects = useCallback(async () => { if (!selectedOrg) return; try { + const qs = debouncedProjectSearch + ? `?search=${encodeURIComponent(debouncedProjectSearch)}` + : ""; const result = await apiFetch( - `/api/organization/${selectedOrg.id}/projects`, + `/api/organization/${selectedOrg.id}/projects${qs}`, activeKey?.key ?? "", ); if (result.success && result.data) { @@ -138,7 +193,7 @@ export default function OnboardingPage() { } catch { // keep current list } - }, [selectedOrg, activeKey]); + }, [selectedOrg, activeKey, debouncedProjectSearch]); const handleSuccess = (data: OnboardResponseData) => { setOnboardData(data); @@ -165,6 +220,8 @@ export default function OnboardingPage() { setSelectedOrg(null); setSelectedProject(null); setProjects([]); + setProjectSearchInput(""); + setDebouncedProjectSearch(""); setView("list"); }; @@ -173,6 +230,34 @@ export default function OnboardingPage() { setView("projects"); }; + const handleConfirmDeleteOrg = async (hardDelete: boolean) => { + if (!orgToDelete) return; + setIsDeletingOrg(true); + try { + await apiFetch( + `/api/organization/${orgToDelete.id}`, + activeKey?.key ?? "", + { + method: "DELETE", + body: JSON.stringify({ hard_delete: hardDelete }), + }, + ); + toast.success( + hardDelete + ? `"${orgToDelete.name}" permanently deleted` + : `"${orgToDelete.name}" deactivated`, + ); + setOrgToDelete(null); + refetchOrganizations(); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Failed to delete organization", + ); + } finally { + setIsDeletingOrg(false); + } + }; + return (
@@ -191,9 +276,13 @@ export default function OnboardingPage() { {view === "list" && ( setView("form")} onSelectOrg={fetchProjects} + onDeleteOrg={setOrgToDelete} + search={orgSearchInput} + onSearchChange={setOrgSearchInput} /> )} @@ -205,6 +294,8 @@ export default function OnboardingPage() { onBack={handleBackToOrgs} onSelectProject={handleSelectProject} onProjectAdded={refreshProjects} + search={projectSearchInput} + onSearchChange={setProjectSearchInput} /> )} @@ -313,6 +404,17 @@ export default function OnboardingPage() {
+ + {orgToDelete && ( + { + if (!isDeletingOrg) setOrgToDelete(null); + }} + onConfirm={handleConfirmDeleteOrg} + /> + )} ); } diff --git a/app/api/organization/[orgId]/projects/route.ts b/app/api/organization/[orgId]/projects/route.ts index d3c01482..2bef2d99 100644 --- a/app/api/organization/[orgId]/projects/route.ts +++ b/app/api/organization/[orgId]/projects/route.ts @@ -7,10 +7,10 @@ export async function GET( ) { try { const { orgId } = await params; - const { status, data } = await apiClient( - request, - `/api/v1/projects/organization/${orgId}`, - ); + const { searchParams } = new URL(request.url); + const qs = searchParams.toString(); + const endpoint = `/api/v1/projects/organization/${orgId}${qs ? `?${qs}` : ""}`; + const { status, data } = await apiClient(request, endpoint); return NextResponse.json(data, { status }); } catch { return NextResponse.json( diff --git a/app/api/organization/[orgId]/route.ts b/app/api/organization/[orgId]/route.ts new file mode 100644 index 00000000..a6831d87 --- /dev/null +++ b/app/api/organization/[orgId]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + try { + const body = await request.json().catch(() => ({})); + const { status, data } = await apiClient( + request, + `/api/v1/organizations/${orgId}`, + { + method: "DELETE", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} diff --git a/app/components/guardrails/BanListModal.tsx b/app/components/guardrails/BanListModal.tsx index a0813949..24b03be0 100644 --- a/app/components/guardrails/BanListModal.tsx +++ b/app/components/guardrails/BanListModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { guardrailsFetch } from "@/app/lib/guardrailsClient"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { Button, Field, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; interface BanListModalProps { onClose: () => void; @@ -132,17 +132,11 @@ export default function BanListModal({ placeholder="e.g. healthcare, finance" /> - + diff --git a/app/components/guardrails/ValidatorConfigPanel.tsx b/app/components/guardrails/ValidatorConfigPanel.tsx index 84d374cc..78805397 100644 --- a/app/components/guardrails/ValidatorConfigPanel.tsx +++ b/app/components/guardrails/ValidatorConfigPanel.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react"; import { Validator } from "@/app/lib/types/guardrails"; import { Button, + Checkbox, Field, InfoTooltip, - Select, - MultiSelect, Loader, + MultiSelect, + Select, } from "@/app/components/ui"; import { CloseIcon } from "@/app/components/icons"; import { @@ -323,15 +324,11 @@ export default function ValidatorConfigPanel({ /> - + {!readOnly && ( diff --git a/app/components/knowledge-base/DocumentPickerModal.tsx b/app/components/knowledge-base/DocumentPickerModal.tsx index 42f67373..6122fc22 100644 --- a/app/components/knowledge-base/DocumentPickerModal.tsx +++ b/app/components/knowledge-base/DocumentPickerModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo } from "react"; -import { Button, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Modal } from "@/app/components/ui"; import { CheckLineIcon, SearchIcon } from "@/app/components/icons"; import { Document } from "@/app/lib/types/document"; @@ -70,11 +70,10 @@ export default function DocumentPickerModal({ : "border-border hover:bg-neutral-50" }`} > - onToggleDocument(doc.id)} - className="mr-3 w-4 h-4 cursor-pointer accent-accent-primary" + inputClassName="mr-3 cursor-pointer" />

) : (

- + {provider.fields.map((field) => (
- + {provider.fields.map((field) => ( -
- -

- Inactive projects won't be available for use. -

-
+
+ +
+ + ); +} diff --git a/app/components/settings/onboarding/EditProjectModal.tsx b/app/components/settings/onboarding/EditProjectModal.tsx index f239203b..414cc8c5 100644 --- a/app/components/settings/onboarding/EditProjectModal.tsx +++ b/app/components/settings/onboarding/EditProjectModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Button, Field, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; import { useToast } from "@/app/hooks/useToast"; import { EditProjectModalProps, @@ -95,20 +95,12 @@ export default function EditProjectModal({ placeholder="Enter project description (optional)" /> -
- -

- Inactive projects won't be available for use. -

-
+
+ )} +
+
+ + onSearchChange(e.target.value)} + placeholder="Search organizations..." + className="w-full pl-10 pr-4 py-2.5 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral focus:outline-none focus:ring-1 focus:ring-accent-primary focus:bg-bg-primary transition-colors" + />
- {currentUser?.is_superuser && ( - - )}
-
- {organizations.map((org) => ( - +
+ {canDelete && ( + + )} + +
- - - ))} + )) + )}
{isLoadingMore && ( diff --git a/app/components/settings/onboarding/ProjectList.tsx b/app/components/settings/onboarding/ProjectList.tsx index c5cc0d50..970b50c7 100644 --- a/app/components/settings/onboarding/ProjectList.tsx +++ b/app/components/settings/onboarding/ProjectList.tsx @@ -7,6 +7,7 @@ import { ArrowLeftIcon, ChevronRightIcon, EditIcon, + SearchIcon, } from "@/app/components/icons"; import { Button } from "@/app/components/ui"; import { useAuth } from "@/app/lib/context/AuthContext"; @@ -42,6 +43,8 @@ export default function ProjectList({ onBack, onSelectProject, onProjectAdded, + search, + onSearchChange, }: ProjectListProps) { const { activeKey, currentUser } = useAuth(); const [showAddModal, setShowAddModal] = useState(false); @@ -49,36 +52,56 @@ export default function ProjectList({ return (
- + {/* Sticky header: back button + title + count + add button + search. + Negative margins extend the bg to the scroll container's edges so + list rows don't peek above. */} +
+ -
-
-

- {organization.name} -

-

- {isLoading - ? "Loading projects..." - : `${projects.length} project${projects.length !== 1 ? "s" : ""}`} -

+
+
+

+ {organization.name} +

+

+ {isLoading + ? "Loading projects..." + : `${projects.length} project${projects.length !== 1 ? "s" : ""}`} +

+
+ {currentUser?.is_superuser && ( + + )} +
+ +
+ + onSearchChange(e.target.value)} + placeholder="Search projects..." + className="w-full pl-10 pr-4 py-2.5 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral focus:outline-none focus:ring-1 focus:ring-accent-primary focus:bg-bg-primary transition-colors" + />
- {currentUser?.is_superuser && ( - - )}
+
+ {isLoading ? ( ) : projects.length === 0 ? (
- No projects found for this organization. + {search.trim() + ? `No projects match "${search.trim()}"` + : "No projects found for this organization."}
) : (
diff --git a/app/components/settings/onboarding/index.ts b/app/components/settings/onboarding/index.ts index 27ebaa4d..274f8900 100644 --- a/app/components/settings/onboarding/index.ts +++ b/app/components/settings/onboarding/index.ts @@ -8,3 +8,4 @@ export { default as OnboardingCredentials } from "./OnboardingCredentials"; export { default as AddUserModal } from "./AddUserModal"; export { default as AddProjectModal } from "./AddProjectModal"; export { default as EditProjectModal } from "./EditProjectModal"; +export { default as DeleteOrganizationModal } from "./DeleteOrganizationModal"; diff --git a/app/components/ui/Checkbox.tsx b/app/components/ui/Checkbox.tsx new file mode 100644 index 00000000..7d60f6dc --- /dev/null +++ b/app/components/ui/Checkbox.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { InputHTMLAttributes, ReactNode } from "react"; + +type CheckboxAccent = "primary" | "success" | "error"; + +interface CheckboxProps extends Omit< + InputHTMLAttributes, + "onChange" | "type" | "checked" +> { + checked: boolean; + onChange: (checked: boolean) => void; + /** Optional label rendered to the right of the box. Omit to render just the input (e.g. inside list rows whose parent handles the click). */ + label?: ReactNode; + /** Optional secondary text below the label. */ + description?: ReactNode; + /** Accent colour for the checked state. Defaults to `primary`. */ + accent?: CheckboxAccent; + disabled?: boolean; + /** Extra classes on the outer `