diff --git a/app/api/environment-variables/[id]/route.ts b/app/api/environment-variables/[id]/route.ts new file mode 100644 index 00000000..a4243748 --- /dev/null +++ b/app/api/environment-variables/[id]/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUserAbility } from '~/auth/session'; +import { prisma } from '~/lib/prisma'; +import { deleteEnvironmentVariable, updateEnvironmentVariable } from '~/lib/services/environmentVariablesService'; +import { logger } from '~/utils/logger'; +import { PermissionAction, PermissionResource } from '@prisma/client'; + +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { userAbility } = await requireUserAbility(request); + + const { id } = await params; + + if (!userAbility.can(PermissionAction.update, PermissionResource.EnvironmentVariable)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + const body = (await request.json()) as { + key: string; + environmentId: string; + value: string; + type: string; + description?: string; + }; + + const { key, environmentId, value, type, description } = body; + + if (!key || !value || !type) { + return NextResponse.json({ success: false, error: 'Missing required fields: key, value, type' }, { status: 400 }); + } + + // Check if user has access to this environment variable + const envVar = await prisma.environmentVariable.findUnique({ + where: { id }, + include: { + environment: true, + }, + }); + + if (!envVar) { + return NextResponse.json({ success: false, error: 'Environment variable not found' }, { status: 404 }); + } + + if (!userAbility.can(PermissionAction.read, PermissionResource.Environment)) { + return NextResponse.json( + { success: false, error: 'Access denied to this environment variable' }, + { status: 403 }, + ); + } + + // Check if key already exists for this environment (excluding current env var) + const existingEnvVar = await prisma.environmentVariable.findFirst({ + where: { + key, + environmentId: environmentId || envVar.environmentId, + id: { not: id }, + }, + }); + + if (existingEnvVar) { + return NextResponse.json( + { success: false, error: 'Environment variable with this key already exists' }, + { status: 409 }, + ); + } + + const updatedEnvironmentVariable = await updateEnvironmentVariable( + id, + key, + value, + type as any, // Type will be validated by the service + environmentId || envVar.environmentId, + description, + ); + + return NextResponse.json({ + success: true, + environmentVariable: updatedEnvironmentVariable, + }); + } catch (error) { + logger.error('Failed to update environment variable:', error); + return NextResponse.json({ success: false, error: 'Failed to update environment variable' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { userAbility } = await requireUserAbility(request); + + const { id } = await params; + + if (!userAbility.can(PermissionAction.delete, PermissionResource.EnvironmentVariable)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + // Check if user has access to this environment variable + const envVar = await prisma.environmentVariable.findUnique({ + where: { id }, + include: { + environment: true, + }, + }); + + if (!envVar) { + return NextResponse.json({ success: false, error: 'Environment variable not found' }, { status: 404 }); + } + + if (!userAbility.can(PermissionAction.read, PermissionResource.Environment)) { + return NextResponse.json( + { success: false, error: 'Access denied to this environment variable' }, + { status: 403 }, + ); + } + + await deleteEnvironmentVariable(id); + + return NextResponse.json({ + success: true, + }); + } catch (error) { + logger.error('Failed to delete environment variable:', error); + return NextResponse.json({ success: false, error: 'Failed to delete environment variable' }, { status: 500 }); + } +} diff --git a/app/api/environment-variables/route.ts b/app/api/environment-variables/route.ts new file mode 100644 index 00000000..4eba60fb --- /dev/null +++ b/app/api/environment-variables/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUserAbility } from '~/auth/session'; +import { + createEnvironmentVariable, + getEnvironmentVariablesWithEnvironmentDetails, +} from '~/lib/services/environmentVariablesService'; +import { logger } from '~/utils/logger'; +import { type EnvironmentVariableType, PermissionAction, PermissionResource } from '@prisma/client'; +import { z } from 'zod'; + +const postBodySchema = z.object({ + key: z.string().min(1), + value: z.string().min(1), + type: z.enum(['GLOBAL', 'DATA_SOURCE']), + environmentId: z.string().min(1), + description: z.string().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const environmentId = searchParams.get('environmentId'); + const type = searchParams.get('type') as EnvironmentVariableType | null; + + // Check if user has permission to read environment variables + const { userAbility } = await requireUserAbility(request); + + if (!userAbility.can(PermissionAction.read, PermissionResource.EnvironmentVariable)) { + return NextResponse.json( + { success: false, error: 'Insufficient permissions to read environment variables' }, + { status: 403 }, + ); + } + + if (!environmentId) { + return NextResponse.json({ success: false, error: 'Environment ID is required' }, { status: 400 }); + } + + const environmentVariables = await getEnvironmentVariablesWithEnvironmentDetails(environmentId, type); + + return NextResponse.json({ + success: true, + environmentVariables, + }); + } catch (error) { + logger.error('Failed to fetch environment variables:', error); + return NextResponse.json({ success: false, error: 'Failed to fetch environment variables' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId, userAbility } = await requireUserAbility(request); + + if (!userAbility.can(PermissionAction.create, PermissionResource.EnvironmentVariable)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + const body = postBodySchema.parse(await request.json()); + + const { key, value, type, environmentId, description } = body; + + // Check if user has access to this environment + if (!userAbility.can(PermissionAction.read, PermissionResource.Environment)) { + return NextResponse.json({ success: false, error: 'Access denied to this environment' }, { status: 403 }); + } + + // Check if environment variable with this key already exists + const existingEnvVar = await prisma.environmentVariable.findUnique({ + where: { + key_environmentId: { + key, + environmentId, + }, + }, + }); + + if (existingEnvVar) { + return NextResponse.json( + { success: false, error: 'Environment variable with this key already exists' }, + { status: 409 }, + ); + } + + const environmentVariable = await createEnvironmentVariable( + key, + value, + type as EnvironmentVariableType, + environmentId, + userId, + description, + ); + + return NextResponse.json({ + success: true, + environmentVariable, + }); + } catch (error) { + logger.error('Failed to create environment variable:', error); + return NextResponse.json({ success: false, error: 'Failed to create environment variable' }, { status: 500 }); + } +} diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 1e698ea7..6827b01a 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -24,6 +24,7 @@ import { AbilityContext } from '~/components/ability/AbilityProvider'; import MembersTab from '~/components/@settings/tabs/users/UsersTab'; import RolesTab from '~/components/@settings/tabs/roles/RolesTab'; import EnvironmentsTab from '~/components/@settings/tabs/environments'; +import SecretsManagerTab from '~/components/@settings/tabs/secrets-manager'; const LAST_ACCESSED_TAB_KEY = 'control-panel-last-tab'; @@ -167,6 +168,8 @@ export const ControlPanel = () => { return ; case 'environments': return ; + case 'secrets-manager': + return ; case 'deployed-apps': return ; case 'github': diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts index 0eff87df..8af81caf 100644 --- a/app/components/@settings/core/constants.ts +++ b/app/components/@settings/core/constants.ts @@ -1,5 +1,5 @@ import type { TabType, TabVisibilityConfig } from './types'; -import { Database, GitBranch, type LucideIcon, Rocket, Users, Server, ShieldUser } from 'lucide-react'; +import { Database, GitBranch, type LucideIcon, Rocket, Users, Server, ShieldUser, Lock } from 'lucide-react'; export const TAB_ICONS: Record = { data: Database, @@ -8,6 +8,7 @@ export const TAB_ICONS: Record = { members: Users, roles: ShieldUser, environments: Server, + 'secrets-manager': Lock, }; export const TAB_LABELS: Record = { @@ -17,6 +18,7 @@ export const TAB_LABELS: Record = { members: 'Members', roles: 'Roles', environments: 'Environments', + 'secrets-manager': 'Secrets Manager', }; export const TAB_DESCRIPTIONS: Record = { @@ -26,13 +28,15 @@ export const TAB_DESCRIPTIONS: Record = { members: 'Manage your members', roles: 'Manage roles and permissions for users', environments: 'Manage your environments', + 'secrets-manager': 'Manage environment variables and secrets', }; export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'data', visible: true, window: 'user', order: 0 }, { id: 'environments', visible: true, window: 'user', order: 1 }, - { id: 'github', visible: true, window: 'user', order: 2 }, - { id: 'deployed-apps', visible: true, window: 'user', order: 3 }, + { id: 'secrets-manager', visible: true, window: 'user', order: 2 }, + { id: 'github', visible: true, window: 'user', order: 3 }, + { id: 'deployed-apps', visible: true, window: 'user', order: 4 }, { id: 'members', visible: true, window: 'admin', order: 1 }, { id: 'roles', visible: true, window: 'admin', order: 2 }, diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts index 69e0bbcc..f88e6dbb 100644 --- a/app/components/@settings/core/types.ts +++ b/app/components/@settings/core/types.ts @@ -1,4 +1,4 @@ -export type TabType = 'data' | 'github' | 'deployed-apps' | 'members' | 'roles' | 'environments'; +export type TabType = 'data' | 'github' | 'deployed-apps' | 'members' | 'roles' | 'environments' | 'secrets-manager'; export type WindowType = 'user' | 'admin'; @@ -31,4 +31,5 @@ export const TAB_LABELS: Record = { members: 'Members', roles: 'Roles', environments: 'Environments', + 'secrets-manager': 'Secrets Manager', }; diff --git a/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx b/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx index 562de7d9..25f0c191 100644 --- a/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx +++ b/app/components/@settings/tabs/data/forms/EditDataSourceForm.tsx @@ -1,11 +1,10 @@ import { classNames } from '~/utils/classNames'; import { useEffect, useState } from 'react'; -import { AlertTriangle, CheckCircle, Info, Loader2, Plug, Save, Trash2, XCircle } from 'lucide-react'; +import { AlertTriangle, CheckCircle, Eye, EyeOff, Info, Loader2, Plug, Save, Trash2, XCircle } from 'lucide-react'; import type { TestConnectionResponse } from '~/components/@settings/tabs/data/DataTab'; import { toast } from 'sonner'; import { BaseSelect } from '~/components/ui/Select'; import { SelectDatabaseTypeOptions, SingleValueWithTooltip } from '~/components/database/SelectDatabaseTypeOptions'; -import { Eye, EyeSlash } from 'iconsax-reactjs'; import { type DataSourceOption, DEFAULT_DATA_SOURCES, @@ -364,7 +363,11 @@ export default function EditDataSourceForm({ tabIndex={-1} > - {showConnStr ? : } + {showConnStr ? ( + + ) : ( + + )} diff --git a/app/components/@settings/tabs/secrets-manager/SecretsManagerTab.tsx b/app/components/@settings/tabs/secrets-manager/SecretsManagerTab.tsx new file mode 100644 index 00000000..0c8baa6c --- /dev/null +++ b/app/components/@settings/tabs/secrets-manager/SecretsManagerTab.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState } from 'react'; +import { ArrowLeft, ChevronRight, Lock, Plus } from 'lucide-react'; +import AddSecretForm from './forms/AddSecretForm'; +import EditSecretForm from './forms/EditSecretForm'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import type { EnvironmentVariableWithDetails } from '~/lib/stores/environmentVariables'; +import { useEnvironmentVariablesStore } from '~/lib/stores/environmentVariables'; +import { useEnvironmentsStore } from '~/lib/stores/environments'; +import { settingsPanelStore, useSettingsStore } from '~/lib/stores/settings'; +import { useStore } from '@nanostores/react'; +import type { EnvironmentWithRelations } from '~/lib/services/environmentService'; +import { BaseSelect } from '~/components/ui/Select'; +import { logger } from '~/utils/logger'; +import { EnvironmentVariableType } from '@prisma/client'; + +interface EnvironmentVariablesResponse { + success: boolean; + environmentVariables: EnvironmentVariableWithDetails[]; +} + +interface EnvironmentsResponse { + success: boolean; + environments: EnvironmentWithRelations[]; +} + +interface EnvironmentOption { + label: string; + value: string; + description?: string; +} + +export default function SecretsManagerTab() { + const { showAddForm } = useStore(settingsPanelStore); + const [showAddFormLocal, setShowAddFormLocal] = useState(showAddForm); + const [showEditForm, setShowEditForm] = useState(false); + const [selectedEnvironmentVariable, setSelectedEnvironmentVariable] = useState( + null, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(''); + const [environmentOptions, setEnvironmentOptions] = useState([]); + const [isLoadingEnvironments, setIsLoadingEnvironments] = useState(true); + const { environmentVariables, setEnvironmentVariables, setLoading } = useEnvironmentVariablesStore(); + const { environments, setEnvironments } = useEnvironmentsStore(); + const { selectedTab } = useSettingsStore(); + + // Update local state when store changes + useEffect(() => { + setShowAddFormLocal(showAddForm); + }, [showAddForm]); + + // Show add form when opened from chat + useEffect(() => { + if (selectedTab === 'secrets-manager') { + setShowAddFormLocal(true); + } + }, [selectedTab]); + + // Load environments on mount + useEffect(() => { + const loadEnvironments = async () => { + try { + setIsLoadingEnvironments(true); + + const response = await fetch('/api/environments'); + const data = (await response.json()) as EnvironmentsResponse; + + if (data.success) { + setEnvironments(data.environments); + + // Transform environments to options + const options: EnvironmentOption[] = [ + { label: 'All Environments', value: 'all', description: 'Global secrets from all environments' }, + ...data.environments.map((env) => ({ + label: env.name, + value: env.id, + description: env.description || undefined, + })), + ]; + setEnvironmentOptions(options); + + // Set "All" as default if environments are available + if (data.environments.length > 0) { + setSelectedEnvironmentId('all'); + } + } + } catch (error) { + logger.error('Failed to load environments:', error); + toast.error('Failed to load environments'); + } finally { + setIsLoadingEnvironments(false); + } + }; + + loadEnvironments(); + }, [setEnvironments]); + + const fetchEnvironmentVariables = async (showLoading = false) => { + if (!selectedEnvironmentId) { + return; + } + + try { + if (showLoading) { + setLoading(true); + } + + // We only show the GLOBAL environment variables (without the DATA_SOURCE environment variables) + const response = await fetch( + `/api/environment-variables?environmentId=${selectedEnvironmentId}&type=${EnvironmentVariableType.GLOBAL}`, + ); + const data = (await response.json()) as EnvironmentVariablesResponse; + + if (data.success) { + setEnvironmentVariables(data.environmentVariables); + } + } catch (error) { + logger.error('Failed to load environment variables:', error); + toast.error('Failed to load environment variables'); + } finally { + if (showLoading) { + setLoading(false); + } + } + }; + + // Load environment variables when environment changes + useEffect(() => { + if (!selectedEnvironmentId) { + return; + } + + fetchEnvironmentVariables(true); + }, [selectedEnvironmentId, setEnvironmentVariables, setLoading]); + + const handleEnvironmentChange = (environmentId: string) => { + setSelectedEnvironmentId(environmentId); + }; + + const handleBack = () => { + setShowAddFormLocal(false); + setShowEditForm(false); + setSelectedEnvironmentVariable(null); + }; + + const handleAdd = () => { + setShowAddFormLocal(true); + setShowEditForm(false); + setSelectedEnvironmentVariable(null); + }; + + return ( +
+ {!showEditForm && !showAddFormLocal && ( +
+
+
+

Secrets Manager

+

Manage your environment secrets

+
+ +
+ + {/* Environment Selector */} + {environmentOptions.length > 0 && ( +
+ + opt.value === selectedEnvironmentId) || null} + onChange={(value: EnvironmentOption | null) => { + if (value) { + handleEnvironmentChange(value.value); + } + }} + options={environmentOptions} + placeholder={isLoadingEnvironments ? 'Loading environments...' : 'Select environment'} + isDisabled={isLoadingEnvironments} + width="100%" + minWidth="100%" + isSearchable={false} + menuPlacement={'bottom'} + /> + {environmentOptions.find((opt) => opt.value === selectedEnvironmentId)?.description && ( +
+ {environmentOptions.find((opt) => opt.value === selectedEnvironmentId)?.description} +
+ )} +
+ )} +
+ )} + + {showAddFormLocal && ( +
+
+
+ +
+

Create Secret

+

Add a new environment secret

+
+
+
+ { + await fetchEnvironmentVariables(); + handleBack(); + }} + selectedEnvironmentId={selectedEnvironmentId === 'all' ? environments[0].id : selectedEnvironmentId} + availableEnvironments={environments} + /> +
+ )} + + {/* Edit Form */} + {showEditForm && selectedEnvironmentVariable && ( +
+
+
+ +
+

Edit Secret

+

Update the environment secret

+
+
+
+ + { + await fetchEnvironmentVariables(); + setShowEditForm(false); + setSelectedEnvironmentVariable(null); + }} + onDelete={async () => { + await fetchEnvironmentVariables(); + setShowEditForm(false); + setSelectedEnvironmentVariable(null); + }} + /> +
+ )} + + {!showEditForm && !showAddFormLocal && ( +
+ {/* Environment Variables List */} + {environmentVariables.length > 0 ? ( +
+ {environmentVariables.map((environmentVariable) => ( +
{ + setSelectedEnvironmentVariable(environmentVariable); + setShowEditForm(true); + }} + > +
+
+
{environmentVariable.key}
+
+ {environmentVariable.environment.name} + {environmentVariable.type === 'DATA_SOURCE' && ( + + Data Source + + )} +
+
+
+ +
+ ))} +
+ ) : ( +
+ +

No secrets found

+

No secrets have been created yet

+
+ )} +
+ )} +
+ ); +} diff --git a/app/components/@settings/tabs/secrets-manager/forms/AddSecretForm.tsx b/app/components/@settings/tabs/secrets-manager/forms/AddSecretForm.tsx new file mode 100644 index 00000000..411b52c3 --- /dev/null +++ b/app/components/@settings/tabs/secrets-manager/forms/AddSecretForm.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Eye, EyeOff, Lock } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'sonner'; +import { EnvironmentVariableType } from '@prisma/client'; + +interface AddSecretFormProps { + isSubmitting: boolean; + setIsSubmitting: (isSubmitting: boolean) => void; + onSuccess: () => void; + selectedEnvironmentId: string; + showEnvironmentSelector?: boolean; + availableEnvironments?: Array<{ id: string; name: string }>; +} + +export default function AddSecretForm({ + isSubmitting, + setIsSubmitting, + onSuccess, + selectedEnvironmentId, + availableEnvironments = [], +}: AddSecretFormProps) { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [description, setDescription] = useState(''); + const [showValue, setShowValue] = useState(false); + const [environmentId, setEnvironmentId] = useState(selectedEnvironmentId); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!key.trim() || !value.trim() || !environmentId) { + toast.error('Please fill in all required fields'); + return; + } + + // Validate a key format (alphanumeric and underscores only) + if (!/^[A-Z0-9_]+$/.test(key.trim())) { + toast.error('Secret key can only contain uppercase letters, numbers, and underscores'); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch('/api/environment-variables', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + key: key.trim().toUpperCase(), + value: value.trim(), + type: EnvironmentVariableType.GLOBAL, + environmentId, + description: description.trim() || undefined, + }), + }); + + const data = (await response.json()) as { success: boolean; error?: string; environmentVariable?: any }; + + if (data.success) { + toast.success('Secret created successfully'); + onSuccess(); + } else { + toast.error(data.error || 'Failed to create secret'); + } + } catch (error) { + console.error('Failed to create secret:', error); + toast.error('Failed to create secret'); + } finally { + setIsSubmitting(false); + } + }; + + const toggleShowValue = () => { + setShowValue(!showValue); + }; + + return ( + + {/* Environment */} +
+ + +
+ + {/* Key */} +
+ + setKey(e.target.value)} + placeholder="MY_SECRET_KEY" + className={classNames( + 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg', + 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white', + 'focus:ring-2 focus:ring-accent-500 focus:border-transparent', + 'placeholder-gray-500 dark:placeholder-gray-400', + )} + required + /> +

Use uppercase letters, numbers, and underscores only

+
+ + {/* Value */} +
+ +
+ setValue(e.target.value)} + placeholder="Enter your secret value" + className={classNames( + 'w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg', + 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white', + 'focus:ring-2 focus:ring-accent-500 focus:border-transparent', + 'placeholder-gray-500 dark:placeholder-gray-400', + )} + required + /> + +
+

This value will be encrypted before storage

+
+ + {/* Description */} +
+ +