diff --git a/backend/api.py b/backend/api.py index ffb3f602..55f97a30 100644 --- a/backend/api.py +++ b/backend/api.py @@ -190,6 +190,9 @@ api_router.include_router(pipedream_api.router) from auth import phone_verification_supabase_mfa api_router.include_router(phone_verification_supabase_mfa.router) +from local_env_manager import api as local_env_manager_api +api_router.include_router(local_env_manager_api.router) + @api_router.get("/health") async def health_check(): logger.info("Health check endpoint called") diff --git a/backend/local_env_manager/api.py b/backend/local_env_manager/api.py new file mode 100644 index 00000000..8c575699 --- /dev/null +++ b/backend/local_env_manager/api.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter +from utils.config import config, EnvMode +from fastapi import HTTPException +from typing import Dict +from dotenv import load_dotenv, set_key, find_dotenv, dotenv_values +from utils.logger import logger + +router = APIRouter(tags=["local-env-manager"]) + +@router.get("/env-vars") +def get_env_vars() -> Dict[str, str]: + if config.ENV_MODE != EnvMode.LOCAL: + raise HTTPException(status_code=403, detail="Env vars management only available in local mode") + + try: + env_path = find_dotenv() + if not env_path: + logger.error("Could not find .env file") + return {} + + return dotenv_values(env_path) + except Exception as e: + logger.error(f"Failed to get env vars: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get env variables: {e}") + +@router.post("/env-vars") +def save_env_vars(request: Dict[str, str]) -> Dict[str, str]: + if config.ENV_MODE != EnvMode.LOCAL: + raise HTTPException(status_code=403, detail="Env vars management only available in local mode") + + try: + env_path = find_dotenv() + if not env_path: + raise HTTPException(status_code=500, detail="Could not find .env file") + + for key, value in request.items(): + set_key(env_path, key, value) + + load_dotenv(override=True) + logger.info(f"Env variables saved successfully: {request}") + return {"message": "Env variables saved successfully"} + except Exception as e: + logger.error(f"Failed to save env variables: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save env variables: {e}") \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 094db576..5c539be5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,7 +37,7 @@ services: ports: - "8000:8000" volumes: - - ./backend/.env:/app/.env:ro + - ./backend/.env:/app/.env env_file: - ./backend/.env environment: diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx new file mode 100644 index 00000000..a07ad48e --- /dev/null +++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/env-manager/page.tsx @@ -0,0 +1,9 @@ +import { isLocalMode } from "@/lib/config"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Shield } from "lucide-react"; +import { LocalEnvManager } from "@/components/env-manager/local-env-manager"; + +export default function LocalEnvManagerPage() { + + return +} \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx index 5f4b3d73..462f193a 100644 --- a/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx +++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/layout.tsx @@ -3,6 +3,7 @@ import { Separator } from '@/components/ui/separator'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { isLocalMode } from '@/lib/config'; export default function PersonalAccountSettingsPage({ children, @@ -15,6 +16,7 @@ export default function PersonalAccountSettingsPage({ // { name: "Teams", href: "/settings/teams" }, { name: 'Billing', href: '/settings/billing' }, { name: 'Usage Logs', href: '/settings/usage-logs' }, + ...(isLocalMode() ? [{ name: 'Local .Env Manager', href: '/settings/env-manager' }] : []), ]; return ( <> diff --git a/frontend/src/components/env-manager/local-env-manager.tsx b/frontend/src/components/env-manager/local-env-manager.tsx new file mode 100644 index 00000000..9cf6355f --- /dev/null +++ b/frontend/src/components/env-manager/local-env-manager.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { Eye, EyeOff, Plus, Trash } from "lucide-react"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { isLocalMode } from "@/lib/config"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { backendApi } from "@/lib/api-client"; +import { toast } from "sonner"; +import { useForm } from "react-hook-form"; + +interface APIKeyForm { + [key: string]: string; +} + +export function LocalEnvManager() { + const queryClient = useQueryClient(); + const [visibleKeys, setVisibleKeys] = useState>({}); + const [newApiKeys, setNewApiKeys] = useState<{key: string, value: string, id: string}[]>([]); + + const {data: apiKeys, isLoading} = useQuery({ + queryKey: ['api-keys'], + queryFn: async() => { + const response = await backendApi.get('/env-vars'); + return response.data; + }, + enabled: isLocalMode() + }); + + const { register, handleSubmit, formState: { errors, isDirty }, reset } = useForm({ + defaultValues: apiKeys || {} + }); + + const handleSave = async (data: APIKeyForm) => { + const duplicate_key = newApiKeys.find(entry => data[entry.key.trim()]); + if (duplicate_key) { + toast.error(`Key ${duplicate_key.key} already exists`); + return; + } + const submitData = { + ...data, + ...Object.fromEntries(newApiKeys.map(entry => [entry.key.trim(), entry.value.trim()])) + } + + updateApiKeys.mutate(submitData); + } + + const handleAddNewKey = () => { + setNewApiKeys([...newApiKeys, {key: "", value: "", id: crypto.randomUUID()}]); + } + + const checkKeyIsDuplicate = (key: string) => { + const trimmedKey = key.trim(); + const keyIsDuplicate = + trimmedKey && + ( + (apiKeys && Object.keys(apiKeys).includes(trimmedKey)) || + newApiKeys.filter(e => e.key.trim() === trimmedKey).length > 1 + ); + return keyIsDuplicate; + } + + const handleNewKeyChange = (id: string, field: string, value: string) => { + setNewApiKeys(prev => + prev.map(entry => entry.id === id ? {...entry, [field]: value} : entry) + ); + } + + const handleDeleteKey = (id: string) => { + setNewApiKeys(prev => prev.filter(entry => entry.id !== id)); + } + + const hasEmptyKeyValues = newApiKeys.some(entry => entry.key.trim() === "" || entry.value.trim() === ""); + const hasDuplicateKeys = (): boolean => { + const allKeys = [...Object.keys(apiKeys || {}), ...newApiKeys.map(entry => entry.key.trim())]; + const uniqueKeys = new Set(allKeys); + return uniqueKeys.size !== allKeys.length; + } + + const updateApiKeys = useMutation({ + mutationFn: async (data: APIKeyForm) => { + const response = await backendApi.post('/env-vars', data); + await queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + return response.data; + }, + onSuccess: (data) => { + toast.success(data.message); + setNewApiKeys([]); + }, + onError: () => { + toast.error('Failed to update API keys'); + } + }); + + const keysArray = apiKeys ? Object.entries(apiKeys).map(([key, value]) => ({ + id: key, + name: key, + value: value + })) : []; + + useEffect(() => { + if (apiKeys) { + reset(apiKeys); + } + }, [apiKeys, reset]); + + const toggleKeyVisibility = (keyId: string) => { + setVisibleKeys(prev => ({ + ...prev, + [keyId]: !prev[keyId] + })); + } + + if (isLoading) { + return + + Local .Env Manager + Loading... + + ; + } + + return + + Local .Env Manager + + {isLocalMode() ? ( + <> + Manage your local environment variables + + ) : ( + <> + Local .Env Manager is only available in local mode. + + )} + + + + {isLocalMode() && ( + +
+ {keysArray && keysArray?.map(key => ( + +
+ +
+ + +
+ {errors[key.id] &&

{errors[key.id]?.message}

} +
+ + ))} + +
+ {newApiKeys.map(entry => { + const keyIsDuplicate = checkKeyIsDuplicate(entry.key); + return ( + +
+ +
+ handleNewKeyChange(entry.id, 'key', e.target.value)} + /> + handleNewKeyChange(entry.id, 'value', e.target.value)} + /> + +
+ {keyIsDuplicate &&

Key already exists

} +
+ )})} +
+ +
+ + +
+
+
+ )} +
+} \ No newline at end of file diff --git a/frontend/src/components/sidebar/nav-user-with-teams.tsx b/frontend/src/components/sidebar/nav-user-with-teams.tsx index ff02ff58..f02d150a 100644 --- a/frontend/src/components/sidebar/nav-user-with-teams.tsx +++ b/frontend/src/components/sidebar/nav-user-with-teams.tsx @@ -17,6 +17,7 @@ import { AudioWaveform, Sun, Moon, + KeyRound, } from 'lucide-react'; import { useAccounts } from '@/hooks/use-accounts'; import NewTeamForm from '@/components/basejump/new-team-form'; @@ -48,6 +49,7 @@ import { } from '@/components/ui/dialog'; import { createClient } from '@/lib/supabase/client'; import { useTheme } from 'next-themes'; +import { isLocalMode } from '@/lib/config'; export function NavUserWithTeams({ user, @@ -286,6 +288,12 @@ export function NavUserWithTeams({ Billing + {isLocalMode() && + + + Local .Env Manager + + } {/* diff --git a/frontend/src/components/thread/chat-input/model-selector.tsx b/frontend/src/components/thread/chat-input/model-selector.tsx index 2977b8a0..ef0d21f2 100644 --- a/frontend/src/components/thread/chat-input/model-selector.tsx +++ b/frontend/src/components/thread/chat-input/model-selector.tsx @@ -14,7 +14,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; -import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu } from 'lucide-react'; +import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu, Key, KeyRound } from 'lucide-react'; import { ModelOption, SubscriptionStatus, @@ -32,6 +32,7 @@ import { cn } from '@/lib/utils'; import { useRouter } from 'next/navigation'; import { isLocalMode } from '@/lib/config'; import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog'; +import Link from 'next/link'; interface CustomModel { id: string; @@ -674,6 +675,22 @@ export const ModelSelector: React.FC = ({
All Models {isLocalMode() && ( +
+ + + + + + + + + Local .Env Manager + + + @@ -694,6 +711,7 @@ export const ModelSelector: React.FC = ({ +
)}
{uniqueModels