+ {!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 */}
+
+
+
+
+ {/* Submit Button */}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/secrets-manager/forms/EditSecretForm.tsx b/app/components/@settings/tabs/secrets-manager/forms/EditSecretForm.tsx
new file mode 100644
index 00000000..5dcaa8b6
--- /dev/null
+++ b/app/components/@settings/tabs/secrets-manager/forms/EditSecretForm.tsx
@@ -0,0 +1,326 @@
+import { useState } from 'react';
+import { motion } from 'framer-motion';
+import { EyeOff, Lock, Eye } from 'lucide-react';
+import { classNames } from '~/utils/classNames';
+import { toast } from 'sonner';
+import type { EnvironmentVariableWithDetails } from '~/lib/stores/environmentVariables';
+import { EnvironmentVariableType } from '@prisma/client';
+
+interface EditSecretFormProps {
+ environmentVariable: EnvironmentVariableWithDetails;
+ isSubmitting: boolean;
+ setIsSubmitting: (isSubmitting: boolean) => void;
+ onSuccess: () => void;
+ onDelete: () => void;
+ availableEnvironments: Array<{ id: string; name: string }>;
+}
+
+export default function EditSecretForm({
+ environmentVariable,
+ isSubmitting,
+ setIsSubmitting,
+ onSuccess,
+ onDelete,
+ availableEnvironments,
+}: EditSecretFormProps) {
+ const [key, setKey] = useState(environmentVariable.key);
+ const [value, setValue] = useState(environmentVariable.value);
+ const [description, setDescription] = useState(environmentVariable.description || '');
+ const [environmentId, setEnvironmentId] = useState(environmentVariable.environment.id);
+ const [showValue, setShowValue] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!key.trim() || !value.trim() || !environmentId) {
+ toast.error('Please fill in all required fields');
+ return;
+ }
+
+ // Validate 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;
+ }
+
+ // Check if any changes were made
+ const hasChanges =
+ key.trim().toUpperCase() !== environmentVariable.key ||
+ value.trim() !== environmentVariable.value ||
+ description.trim() !== (environmentVariable.description || '') ||
+ environmentId !== environmentVariable.environment.id;
+
+ if (!hasChanges) {
+ toast.info('No changes were made');
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch(`/api/environment-variables/${environmentVariable.id}`, {
+ method: 'PUT',
+ 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 };
+
+ if (data.success) {
+ toast.success('Secret updated successfully');
+ onSuccess();
+ } else {
+ toast.error(data.error || 'Failed to update secret');
+ }
+ } catch (error) {
+ console.error('Failed to update secret:', error);
+ toast.error('Failed to update secret');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ // Prevent deletion of DATA_SOURCE type environment variables
+ if (environmentVariable.type === EnvironmentVariableType.DATA_SOURCE) {
+ toast.error('Cannot delete data source secrets from here. Please manage them in the Data Sources tab.');
+ setShowDeleteConfirm(false);
+
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch(`/api/environment-variables/${environmentVariable.id}`, {
+ method: 'DELETE',
+ });
+
+ const data = (await response.json()) as { success: boolean; error?: string };
+
+ if (data.success) {
+ toast.success('Secret deleted successfully');
+ onDelete();
+ } else {
+ toast.error(data.error || 'Failed to delete secret');
+ }
+ } catch (error) {
+ console.error('Failed to delete secret:', error);
+ toast.error('Failed to delete secret');
+ } finally {
+ setIsSubmitting(false);
+ setShowDeleteConfirm(false);
+ }
+ };
+
+ const toggleShowValue = () => {
+ setShowValue(!showValue);
+ };
+
+ if (showDeleteConfirm) {
+ 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 Input */}
+
+
+
+ setValue(e.target.value)}
+ placeholder="Enter secret value"
+ className={classNames(
+ 'w-full px-4 py-2.5 pr-12 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 */}
+
+
+
+
+ {/* Submit Button */}
+
+ {/* Delete Button - Only show for non-DATA_SOURCE types */}
+ {environmentVariable.type !== 'DATA_SOURCE' && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/@settings/tabs/secrets-manager/index.ts b/app/components/@settings/tabs/secrets-manager/index.ts
new file mode 100644
index 00000000..e52c4c0a
--- /dev/null
+++ b/app/components/@settings/tabs/secrets-manager/index.ts
@@ -0,0 +1 @@
+export { default } from './SecretsManagerTab';
diff --git a/app/components/DataLoader.tsx b/app/components/DataLoader.tsx
index 6ed94ade..b96d7ed1 100644
--- a/app/components/DataLoader.tsx
+++ b/app/components/DataLoader.tsx
@@ -9,14 +9,17 @@ import { useDataSourceTypesStore } from '~/lib/stores/dataSourceTypes';
import { useRouter } from 'next/navigation';
import type { PluginAccessMap } from '~/lib/plugins/types';
import { useUserStore } from '~/lib/stores/user';
+import { useEnvironmentVariablesStore } from '~/lib/stores/environmentVariables';
import { DATA_SOURCE_CONNECTION_ROUTE, TELEMETRY_CONSENT_ROUTE } from '~/lib/constants/routes';
import { initializeClientTelemetry } from '~/lib/telemetry/telemetry-client';
import type { UserProfile } from '~/lib/services/userService';
import { useAuthProvidersPlugin } from '~/lib/hooks/plugins/useAuthProvidersPlugin';
+import type { EnvironmentVariableWithDetails } from '~/lib/stores/environmentVariables';
export interface RootData {
user: UserProfile | null;
environmentDataSources: EnvironmentDataSource[];
+ environmentVariables: EnvironmentVariableWithDetails[];
pluginAccess: PluginAccessMap;
dataSourceTypes: DataSourceType[];
}
@@ -32,6 +35,7 @@ export function DataLoader({ children, rootData }: DataLoaderProps) {
const { setPluginAccess } = usePluginStore();
const { setDataSourceTypes } = useDataSourceTypesStore();
const { setUser } = useUserStore();
+ const { setEnvironmentVariables } = useEnvironmentVariablesStore();
const { anonymousProvider } = useAuthProvidersPlugin();
const router = useRouter();
const isLoggingIn = useRef(false);
@@ -45,7 +49,18 @@ export function DataLoader({ children, rootData }: DataLoaderProps) {
if (rootData.dataSourceTypes) {
setDataSourceTypes(rootData.dataSourceTypes);
}
- }, [rootData.pluginAccess, rootData.dataSourceTypes, setPluginAccess, setDataSourceTypes]);
+
+ if (rootData.environmentVariables) {
+ setEnvironmentVariables(rootData.environmentVariables);
+ }
+ }, [
+ rootData.pluginAccess,
+ rootData.dataSourceTypes,
+ rootData.environmentVariables,
+ setPluginAccess,
+ setDataSourceTypes,
+ setEnvironmentVariables,
+ ]);
useEffect(() => {
const loadUserData = async () => {
@@ -117,6 +132,17 @@ export function DataLoader({ children, rootData }: DataLoaderProps) {
setEnvironmentDataSources(currentEnvironmentDataSources);
}
+ // Handle environment variables
+ let currentEnvironmentVariables = rootData.environmentVariables || [];
+
+ if ((!rootData.environmentVariables || rootData.environmentVariables.length === 0) && session?.user) {
+ console.debug('🔄 Fetching environment variables...');
+ currentEnvironmentVariables = await fetchEnvironmentVariables();
+ setEnvironmentVariables(currentEnvironmentVariables);
+ } else if (rootData.environmentVariables) {
+ setEnvironmentVariables(currentEnvironmentVariables);
+ }
+
// Handle user onboarding flow with telemetry and data sources
if (currentUser) {
// Redirect to telemetry consent screen if user hasn't answered yet
@@ -195,6 +221,49 @@ export function DataLoader({ children, rootData }: DataLoaderProps) {
}
};
+ const fetchEnvironmentVariables = async (): Promise