From ddf0f3f1964aae09cfba13a245bd892937fab854 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:13:31 +0530 Subject: [PATCH 01/12] feat(*): credentials management for org, project --- app/(main)/settings/credentials/page.tsx | 61 +--- app/(main)/settings/onboarding/page.tsx | 54 ++- .../[projectId]/provider/[provider]/route.ts | 40 +++ .../org/[orgId]/[projectId]/route.ts | 56 +++ app/components/Toast.tsx | 289 +++++++--------- .../icons/common/ErrorCircleIcon.tsx | 23 ++ app/components/icons/document/CloseIcon.tsx | 4 +- app/components/icons/index.tsx | 1 + .../icons/prompt-editor/CheckCircleIcon.tsx | 4 +- app/components/settings/ProviderSidebar.tsx | 52 +++ .../settings/credentials/CredentialForm.tsx | 187 ++-------- .../settings/credentials/ProviderList.tsx | 79 ----- .../onboarding/OnboardingCredentials.tsx | 324 ++++++++++++++++++ .../settings/onboarding/UserList.tsx | 58 +++- app/components/settings/onboarding/index.ts | 1 + app/globals.css | 26 +- app/hooks/useToast.ts | 1 + app/lib/types/onboarding.ts | 1 + 18 files changed, 788 insertions(+), 473 deletions(-) create mode 100644 app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts create mode 100644 app/api/credentials/org/[orgId]/[projectId]/route.ts create mode 100644 app/components/icons/common/ErrorCircleIcon.tsx create mode 100644 app/components/settings/ProviderSidebar.tsx delete mode 100644 app/components/settings/credentials/ProviderList.tsx create mode 100644 app/components/settings/onboarding/OnboardingCredentials.tsx create mode 100644 app/hooks/useToast.ts diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index c80364e..bee115e 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -1,7 +1,7 @@ /** * Credentials Settings Page — orchestrator * State management and API calls only. UI split into: - * ProviderList — left sidebar nav + * ProviderSidebar — left sidebar nav * CredentialForm — right form with fields and actions */ @@ -9,7 +9,6 @@ import { useState, useEffect } from "react"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; -import { colors } from "@/app/lib/colors"; import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { @@ -18,7 +17,7 @@ import { ProviderDef, } from "@/app/lib/types/credentials"; import { getExistingForProvider } from "@/app/lib/utils"; -import ProviderList from "@/app/components/settings/credentials/ProviderList"; +import ProviderSidebar from "@/app/components/settings/ProviderSidebar"; import CredentialForm from "@/app/components/settings/credentials/CredentialForm"; import { apiFetch } from "@/app/lib/apiClient"; @@ -34,15 +33,14 @@ export default function CredentialsPage() { const [isDeleting, setIsDeleting] = useState(false); const [formValues, setFormValues] = useState>({}); const [isActive, setIsActive] = useState(true); - const [visibleFields, setVisibleFields] = useState>(new Set()); const [existingCredential, setExistingCredential] = useState(null); - // Load credentials once we have an API key + // Load credentials once authenticated useEffect(() => { if (!isAuthenticated) return; loadCredentials(); - }, [apiKeys]); + }, [isAuthenticated, apiKeys]); // Re-populate form when provider or credentials change useEffect(() => { @@ -64,7 +62,6 @@ export default function CredentialsPage() { }); setFormValues(blank); } - setVisibleFields(new Set()); }, [selectedProvider, credentials]); const loadCredentials = async () => { @@ -151,7 +148,6 @@ export default function CredentialsPage() { setFormValues(blank); setIsActive(true); } - setVisibleFields(new Set()); }; const handleDelete = async () => { @@ -178,68 +174,35 @@ export default function CredentialsPage() { setFormValues((prev) => ({ ...prev, [key]: value })); }; - const handleToggleVisibility = (key: string) => { - setVisibleFields((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - return ( -
+
-
+
-

+

Credentials

-

+

Manage provider credentials

-
{!isAuthenticated ? ( -
+
Please log in to manage credentials.
) : ( @@ -251,10 +214,8 @@ export default function CredentialsPage() { isLoading={isLoading} isSaving={isSaving} isDeleting={isDeleting} - visibleFields={visibleFields} onChange={handleFieldChange} onActiveChange={setIsActive} - onToggleVisibility={handleToggleVisibility} onSave={handleSave} onCancel={handleCancel} onDelete={handleDelete} diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index f453e60..855a7dd 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -13,6 +13,7 @@ import { ProjectList, StepIndicator, UserList, + OnboardingCredentials, } from "@/app/components/settings/onboarding"; import { Organization, @@ -24,6 +25,12 @@ import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; +import TabNavigation from "@/app/components/TabNavigation"; + +const PROJECT_TABS = [ + { id: "users", label: "Users" }, + { id: "credentials", label: "Credentials" }, +]; type View = "loading" | "list" | "projects" | "users" | "form" | "success"; @@ -69,6 +76,7 @@ export default function OnboardingPage() { const [onboardData, setOnboardData] = useState( null, ); + const [activeProjectTab, setActiveProjectTab] = useState("users"); const { items: organizations, @@ -211,11 +219,47 @@ export default function OnboardingPage() { )} {view === "users" && selectedOrg && selectedProject && ( - +
+ + +
+
+

+ {selectedProject.name} +

+

+ {selectedOrg.name} +

+
+
+ + + + {activeProjectTab === "users" && ( + + )} + + {activeProjectTab === "credentials" && ( + + )} +
)} {view === "form" && ( diff --git a/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts b/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts new file mode 100644 index 0000000..583ed96 --- /dev/null +++ b/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts @@ -0,0 +1,40 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +type Params = { + params: Promise<{ orgId: string; projectId: string; provider: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { orgId, projectId, provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}/provider/${provider}`, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + const { orgId, projectId, provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}/provider/${provider}`, + { method: "DELETE" }, + ); + if (status === 204) return new NextResponse(null, { status: 204 }); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/app/api/credentials/org/[orgId]/[projectId]/route.ts b/app/api/credentials/org/[orgId]/[projectId]/route.ts new file mode 100644 index 0000000..d98638b --- /dev/null +++ b/app/api/credentials/org/[orgId]/[projectId]/route.ts @@ -0,0 +1,56 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +type Params = { params: Promise<{ orgId: string; projectId: string }> }; + +export async function GET(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const body = await request.json(); + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + { method: "PATCH", body: JSON.stringify(body) }, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + { method: "DELETE" }, + ); + if (status === 204) return new NextResponse(null, { status: 204 }); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx index 5912187..564c5b8 100644 --- a/app/components/Toast.tsx +++ b/app/components/Toast.tsx @@ -1,6 +1,19 @@ "use client"; -import React, { createContext, useContext, useState, useCallback } from "react"; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from "react"; +import { + CheckCircleIcon, + ErrorCircleIcon, + WarningTriangleIcon, + InfoIcon, + CloseIcon, +} from "@/app/components/icons"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -21,7 +34,9 @@ interface ToastContextType { warning: (message: string, duration?: number) => void; } -const ToastContext = createContext(undefined); +export const ToastContext = createContext( + undefined, +); export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]); @@ -34,43 +49,31 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { (message: string, type: ToastType = "info", duration: number = 5000) => { const id = `toast-${Date.now()}-${Math.random()}`; const toast: Toast = { id, message, type, duration }; - setToasts((prev) => [...prev, toast]); - - if (duration > 0) { - setTimeout(() => { - removeToast(id); - }, duration); - } }, - [removeToast], + [], ); const success = useCallback( - (message: string, duration?: number) => { - addToast(message, "success", duration); - }, + (message: string, duration?: number) => + addToast(message, "success", duration), [addToast], ); const error = useCallback( - (message: string, duration?: number) => { - addToast(message, "error", duration); - }, + (message: string, duration?: number) => + addToast(message, "error", duration), [addToast], ); const info = useCallback( - (message: string, duration?: number) => { - addToast(message, "info", duration); - }, + (message: string, duration?: number) => addToast(message, "info", duration), [addToast], ); const warning = useCallback( - (message: string, duration?: number) => { - addToast(message, "warning", duration); - }, + (message: string, duration?: number) => + addToast(message, "warning", duration), [addToast], ); @@ -92,7 +95,6 @@ export function useToast() { return context; } -// Toast Container Component function ToastContainer({ toasts, removeToast, @@ -101,7 +103,7 @@ function ToastContainer({ removeToast: (id: string) => void; }) { return ( -
+
{toasts.map((toast) => ( = { + success: { + accent: "#07bc0c", + bg: "#ffffff", + icon: "#07bc0c", + progressBg: "#07bc0c", + }, + error: { + accent: "#e74c3c", + bg: "#ffffff", + icon: "#e74c3c", + progressBg: "#e74c3c", + }, + warning: { + accent: "#f1c40f", + bg: "#ffffff", + icon: "#f1c40f", + progressBg: "#f1c40f", + }, + info: { + accent: "#3498db", + bg: "#ffffff", + icon: "#3498db", + progressBg: "#3498db", + }, +}; + +function ToastIcon({ type }: { type: ToastType }) { + const config = TOAST_CONFIG[type]; + const style = { color: config.icon }; + + switch (type) { + case "success": + return ; + case "error": + return ; + case "warning": + return ; + case "info": + return ; + } +} + function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { - const styles = getToastStyles(toast.type); + const [exiting, setExiting] = useState(false); + const [paused, setPaused] = useState(false); + const [remaining, setRemaining] = useState(toast.duration ?? 5000); + const config = TOAST_CONFIG[toast.type]; + + useEffect(() => { + if (paused || remaining <= 0) return; + + const start = Date.now(); + const timer = setTimeout(() => { + setExiting(true); + }, remaining); + + return () => { + clearTimeout(timer); + setRemaining((prev) => prev - (Date.now() - start)); + }; + }, [paused, remaining]); + + useEffect(() => { + if (!exiting) return; + const timer = setTimeout(onClose, 300); + return () => clearTimeout(timer); + }, [exiting, onClose]); + + const duration = toast.duration ?? 5000; return (
setPaused(true)} + onMouseLeave={() => setPaused(false)} >
- {/* Icon */} -
- {toast.type === "success" && ( - - - - )} - {toast.type === "error" && ( - - - - )} - {toast.type === "warning" && ( - - - - )} - {toast.type === "info" && ( - - - - )} -
+
- {/* Message */} -
- {toast.message} +
+
+ +
+

+ {toast.message} +

- {/* Close Button */} + +
+
+
); } - -function getToastStyles(type: ToastType) { - switch (type) { - case "success": - return { - bg: "#f0fdf4", - border: "#86efac", - text: "#15803d", - icon: "#16a34a", - }; - case "error": - return { - bg: "#fef2f2", - border: "#fca5a5", - text: "#b91c1c", - icon: "#dc2626", - }; - case "warning": - return { - bg: "#fffbeb", - border: "#fcd34d", - text: "#b45309", - icon: "#f59e0b", - }; - case "info": - default: - return { - bg: "#eff6ff", - border: "#93c5fd", - text: "#1e40af", - icon: "#3b82f6", - }; - } -} diff --git a/app/components/icons/common/ErrorCircleIcon.tsx b/app/components/icons/common/ErrorCircleIcon.tsx new file mode 100644 index 0000000..a0f604e --- /dev/null +++ b/app/components/icons/common/ErrorCircleIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; + style?: React.CSSProperties; +} + +export default function ErrorCircleIcon({ className, style }: IconProps) { + return ( + + + + ); +} diff --git a/app/components/icons/document/CloseIcon.tsx b/app/components/icons/document/CloseIcon.tsx index af939c2..59b151c 100644 --- a/app/components/icons/document/CloseIcon.tsx +++ b/app/components/icons/document/CloseIcon.tsx @@ -1,8 +1,9 @@ interface IconProps { className?: string; + style?: React.CSSProperties; } -export default function CloseIcon({ className }: IconProps) { +export default function CloseIcon({ className, style }: IconProps) { return ( void; + className?: string; +} + +export default function ProviderSidebar({ + providers, + selectedProvider, + credentials, + onSelect, + className = "", +}: ProviderSidebarProps) { + return ( +
+
+ +
+
+ ); +} diff --git a/app/components/settings/credentials/CredentialForm.tsx b/app/components/settings/credentials/CredentialForm.tsx index 39a5d74..f2e843b 100644 --- a/app/components/settings/credentials/CredentialForm.tsx +++ b/app/components/settings/credentials/CredentialForm.tsx @@ -1,7 +1,8 @@ "use client"; -import { colors } from "@/app/lib/colors"; import Loader from "@/app/components/Loader"; +import Field from "@/app/components/Field"; +import Button from "@/app/components/Button"; import { Credential, ProviderDef } from "@/app/lib/types/credentials"; import { timeAgo } from "@/app/lib/utils"; @@ -13,10 +14,8 @@ interface Props { isLoading: boolean; isSaving: boolean; isDeleting?: boolean; - visibleFields: Set; onChange: (key: string, value: string) => void; onActiveChange: (active: boolean) => void; - onToggleVisibility: (key: string) => void; onSave: () => void; onCancel: () => void; onDelete?: () => void; @@ -30,156 +29,46 @@ export default function CredentialForm({ isLoading, isSaving, isDeleting, - visibleFields, onChange, onActiveChange, - onToggleVisibility, onSave, onCancel, onDelete, }: Props) { return (
-

+

{provider.name}

-

- {provider.description} -

+

{provider.description}

{isLoading ? ( ) : (
- {/* Active toggle */} - {/* Fields */} - {provider.fields.map((field) => { - const isPassword = field.type === "password"; - const visible = visibleFields.has(field.key); - const hasValue = !!formValues[field.key]; - return ( -
- -
- onChange(field.key, e.target.value)} - placeholder={field.placeholder} - className="w-full px-4 py-2.5 rounded-lg border text-sm outline-none transition-colors" - style={{ - borderColor: colors.border, - backgroundColor: colors.bg.primary, - color: colors.text.primary, - paddingRight: isPassword || hasValue ? "5rem" : undefined, - }} - onFocus={(e) => { - e.target.style.borderColor = colors.accent.primary; - }} - onBlur={(e) => { - e.target.style.borderColor = colors.border; - }} - /> -
- {hasValue && ( - - )} - {isPassword && ( - - )} -
-
-
- ); - })} + {provider.fields.map((field) => ( + onChange(field.key, val)} + placeholder={field.placeholder} + type={field.type || "text"} + /> + ))} - {/* Last updated */} {existingCredential && ( -

+

Last updated:{" "} {existingCredential.updated_at ? timeAgo(existingCredential.updated_at) @@ -187,50 +76,26 @@ export default function CredentialForm({

)} - {/* Actions */}
- - + {existingCredential && onDelete && ( - + )}
diff --git a/app/components/settings/credentials/ProviderList.tsx b/app/components/settings/credentials/ProviderList.tsx deleted file mode 100644 index 0d3804c..0000000 --- a/app/components/settings/credentials/ProviderList.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { colors } from "@/app/lib/colors"; -import { Credential, ProviderDef } from "@/app/lib/types/credentials"; -import { getExistingForProvider } from "@/app/lib/utils"; - -interface Props { - providers: ProviderDef[]; - selectedProvider: ProviderDef; - credentials: Credential[]; - onSelect: (provider: ProviderDef) => void; -} - -export default function ProviderList({ - providers, - selectedProvider, - credentials, - onSelect, -}: Props) { - return ( -
-
- -
-
- ); -} diff --git a/app/components/settings/onboarding/OnboardingCredentials.tsx b/app/components/settings/onboarding/OnboardingCredentials.tsx new file mode 100644 index 0000000..3e6bba3 --- /dev/null +++ b/app/components/settings/onboarding/OnboardingCredentials.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/components/Toast"; +import { apiFetch } from "@/app/lib/apiClient"; +import { + PROVIDERS, + Credential, + ProviderDef, +} from "@/app/lib/types/credentials"; +import { getExistingForProvider, timeAgo } from "@/app/lib/utils"; +import { Button, Field } from "@/app/components"; +import ProviderSidebar from "@/app/components/settings/ProviderSidebar"; + +interface OnboardingCredentialsProps { + organizationId: number; + projectId: number; +} + +function CredentialFormPanel({ + provider, + existingCredential, + formValues, + isActive, + hasChanges, + isLoading, + isSaving, + isDeleting, + onChange, + onActiveChange, + onSave, + onCancel, + onDelete, +}: { + provider: ProviderDef; + existingCredential: Credential | null; + formValues: Record; + isActive: boolean; + hasChanges: boolean; + isLoading: boolean; + isSaving: boolean; + isDeleting: boolean; + onChange: (key: string, value: string) => void; + onActiveChange: (active: boolean) => void; + onSave: () => void; + onCancel: () => void; + onDelete: () => void; +}) { + if (isLoading) { + return ( +
+
+
+

Loading credentials...

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

+ {provider.name} +

+

+ {provider.description} +

+ +
+ {/* Active toggle */} + + + {provider.fields.map((field) => ( + onChange(field.key, val)} + placeholder={field.placeholder} + type={field.type || "text"} + /> + ))} + + {existingCredential?.updated_at && ( +

+ Last updated: {timeAgo(existingCredential.updated_at)} +

+ )} + +
+ + + {existingCredential && ( + + )} +
+
+
+
+ ); +} + +export default function OnboardingCredentials({ + organizationId, + projectId, +}: OnboardingCredentialsProps) { + const toast = useToast(); + const { activeKey } = useAuth(); + const apiKey = activeKey?.key ?? ""; + + const [selectedProvider, setSelectedProvider] = useState( + PROVIDERS[0], + ); + const [credentials, setCredentials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [formValues, setFormValues] = useState>({}); + const [isActive, setIsActive] = useState(true); + const [existingCredential, setExistingCredential] = + useState(null); + + const credentialsUrl = `/api/credentials/org/${organizationId}/${projectId}`; + + const loadCredentials = useCallback(async () => { + setIsLoading(true); + try { + const data = await apiFetch<{ data?: Credential[] } | Credential[]>( + `/api/credentials/org/${organizationId}/${projectId}`, + apiKey, + ); + setCredentials(Array.isArray(data) ? data : data.data || []); + } catch (err) { + console.error("Failed to load credentials:", err); + } finally { + setIsLoading(false); + } + }, [apiKey, organizationId, projectId]); + + useEffect(() => { + loadCredentials(); + }, [loadCredentials]); + + // Re-populate form when provider or credentials change + useEffect(() => { + const existing = getExistingForProvider(selectedProvider, credentials); + if (existing) { + setExistingCredential(existing); + setIsActive(existing.is_active); + const populated: Record = {}; + selectedProvider.fields.forEach((f) => { + populated[f.key] = existing.credential[f.key] || ""; + }); + setFormValues(populated); + } else { + setExistingCredential(null); + setIsActive(true); + const blank: Record = {}; + selectedProvider.fields.forEach((f) => { + blank[f.key] = ""; + }); + setFormValues(blank); + } + }, [selectedProvider, credentials]); + + const handleSave = async () => { + const missing = selectedProvider.fields.filter( + (f) => !formValues[f.key]?.trim(), + ); + if (missing.length > 0) { + toast.error(`Please fill in: ${missing.map((f) => f.label).join(", ")}`); + return; + } + + setIsSaving(true); + try { + const innerPayload: Record = {}; + selectedProvider.fields.forEach((f) => { + innerPayload[f.key] = formValues[f.key].trim(); + }); + + if (existingCredential) { + await apiFetch(credentialsUrl, apiKey, { + method: "PATCH", + body: JSON.stringify({ + provider: selectedProvider.credentialKey, + is_active: isActive, + credential: innerPayload, + }), + }); + toast.success(`${selectedProvider.name} credentials updated`); + } else { + await apiFetch(credentialsUrl, apiKey, { + method: "PATCH", + body: JSON.stringify({ + provider: selectedProvider.credentialKey, + is_active: isActive, + credential: { + [selectedProvider.credentialKey]: innerPayload, + }, + }), + }); + toast.success(`${selectedProvider.name} credentials saved`); + } + await loadCredentials(); + } catch (err: unknown) { + toast.error( + err instanceof Error ? err.message : "Failed to save credentials", + ); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + const existing = getExistingForProvider(selectedProvider, credentials); + if (existing) { + setIsActive(existing.is_active); + const populated: Record = {}; + selectedProvider.fields.forEach((f) => { + populated[f.key] = existing.credential[f.key] || ""; + }); + setFormValues(populated); + } else { + const blank: Record = {}; + selectedProvider.fields.forEach((f) => { + blank[f.key] = ""; + }); + setFormValues(blank); + setIsActive(true); + } + }; + + const handleDelete = async () => { + if (!existingCredential) return; + setIsDeleting(true); + try { + await apiFetch( + `${credentialsUrl}/provider/${selectedProvider.credentialKey}`, + apiKey, + { method: "DELETE" }, + ); + toast.success(`${selectedProvider.name} credentials removed`); + await loadCredentials(); + } catch (err: unknown) { + toast.error( + err instanceof Error ? err.message : "Failed to remove credentials", + ); + } finally { + setIsDeleting(false); + } + }; + + const handleFieldChange = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + + return ( +
+
+ + + + (formValues[f.key] || "") !== + (existingCredential.credential[f.key] || ""), + ) + : selectedProvider.fields.some((f) => !!formValues[f.key]?.trim()) + } + isLoading={isLoading} + isSaving={isSaving} + isDeleting={isDeleting} + onChange={handleFieldChange} + onActiveChange={setIsActive} + onSave={handleSave} + onCancel={handleCancel} + onDelete={handleDelete} + /> +
+
+ ); +} diff --git a/app/components/settings/onboarding/UserList.tsx b/app/components/settings/onboarding/UserList.tsx index 3bffccc..459879f 100644 --- a/app/components/settings/onboarding/UserList.tsx +++ b/app/components/settings/onboarding/UserList.tsx @@ -42,6 +42,7 @@ export default function UserList({ organization, project, onBack, + hideHeader, }: UserListProps) { const toast = useToast(); const { activeKey, currentUser } = useAuth(); @@ -91,31 +92,50 @@ export default function UserList({ return (
- + {!hideHeader && ( + <> + -
-
-

- {project.name} -

-

- {organization.name} ·{" "} +

+
+

+ {project.name} +

+

+ {organization.name} ·{" "} + {isLoading + ? "Loading users..." + : `${users.length} user${users.length !== 1 ? "s" : ""}`} +

+
+ {currentUser?.is_superuser && ( + + )} +
+ + )} + + {hideHeader && ( +
+

{isLoading ? "Loading users..." : `${users.length} user${users.length !== 1 ? "s" : ""}`}

+ {currentUser?.is_superuser && ( + + )}
- {currentUser?.is_superuser && ( - - )} -
+ )} {isLoading ? ( diff --git a/app/components/settings/onboarding/index.ts b/app/components/settings/onboarding/index.ts index b9f06c5..27ebaa4 100644 --- a/app/components/settings/onboarding/index.ts +++ b/app/components/settings/onboarding/index.ts @@ -4,6 +4,7 @@ export { default as OrganizationList } from "./OrganizationList"; export { default as ProjectList } from "./ProjectList"; export { default as StepIndicator } from "./StepIndicator"; export { default as UserList } from "./UserList"; +export { default as OnboardingCredentials } from "./OnboardingCredentials"; export { default as AddUserModal } from "./AddUserModal"; export { default as AddProjectModal } from "./AddProjectModal"; export { default as EditProjectModal } from "./EditProjectModal"; diff --git a/app/globals.css b/app/globals.css index b691cc4..1d67ebc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -154,7 +154,31 @@ a { } .animate-slideIn { - animation: slideIn 0.2s ease-out; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +.animate-slideOut { + animation: slideOut 0.3s ease-in forwards; +} + +@keyframes toastProgress { + from { + width: 100%; + } + to { + width: 0%; + } } .loader-spinner { diff --git a/app/hooks/useToast.ts b/app/hooks/useToast.ts new file mode 100644 index 0000000..28eac13 --- /dev/null +++ b/app/hooks/useToast.ts @@ -0,0 +1 @@ +export { useToast } from "@/app/components/Toast"; diff --git a/app/lib/types/onboarding.ts b/app/lib/types/onboarding.ts index ee22401..1c9a338 100644 --- a/app/lib/types/onboarding.ts +++ b/app/lib/types/onboarding.ts @@ -94,6 +94,7 @@ export interface UserListProps { organization: Organization; project: Project; onBack: () => void; + hideHeader?: boolean; } export interface AddProjectModalProps { From d65eb09ae8f5f85f4e1f1e798cb3d2472ba6ecce Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:21:10 +0530 Subject: [PATCH 02/12] fix(*): use the page header component --- app/(main)/settings/credentials/page.tsx | 15 +++++---------- app/components/settings/SettingsSidebar.tsx | 6 +++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index bee115e..3ca22c1 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -9,6 +9,7 @@ import { useState, useEffect } from "react"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; +import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { @@ -180,16 +181,10 @@ export default function CredentialsPage() {
-
-
-

- Credentials -

-

- Manage provider credentials -

-
-
+
= { @@ -17,6 +18,7 @@ export default function SettingsSidebar() { const router = useRouter(); const pathname = usePathname(); const { currentUser, googleProfile, isAuthenticated, logout } = useAuth(); + const { sidebarCollapsed } = useApp(); const [showUserMenu, setShowUserMenu] = useState(false); const userMenuRef = useRef(null); @@ -41,7 +43,9 @@ export default function SettingsSidebar() { .toUpperCase(); return ( -