Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 68 additions & 22 deletions archon-ui-main/src/components/settings/APIKeysSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface CustomCredential {
is_encrypted?: boolean;
showValue?: boolean; // Track per-credential visibility
isNew?: boolean; // Track if this is a new unsaved credential
isFromBackend?: boolean; // Track if credential came from backend (write-only once encrypted)
}

export const APIKeysSection = () => {
Expand Down Expand Up @@ -51,17 +52,22 @@ export const APIKeysSection = () => {
});

// Convert to UI format
const uiCredentials = apiKeys.map(cred => ({
key: cred.key,
value: cred.value || '',
description: cred.description || '',
originalValue: cred.value || '',
originalKey: cred.key, // Track original key for updates
hasChanges: false,
is_encrypted: cred.is_encrypted || false,
showValue: false,
isNew: false
}));
const uiCredentials = apiKeys.map(cred => {
const isEncryptedFromBackend = cred.is_encrypted && cred.value === '[ENCRYPTED]';

return {
key: cred.key,
value: cred.value || '',
description: cred.description || '',
originalValue: cred.value || '',
originalKey: cred.key, // Track original key for updates
hasChanges: false,
is_encrypted: cred.is_encrypted || false,
showValue: false,
isNew: false,
isFromBackend: !cred.isNew, // Mark as from backend unless it's a new credential
};
});

Comment on lines +55 to 71
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Block saves/renames while value is masked to prevent persisting “[ENCRYPTED]”.

If a user renames or saves without editing, you’ll POST/PUT the literal placeholder, corrupting the real secret. Guard early.

     for (const cred of customCredentials) {
       if (cred.hasChanges || cred.isNew) {
         if (!cred.key) {
           showToast('Key name cannot be empty', 'error');
           hasErrors = true;
           continue;
         }
+        // Prevent persisting the placeholder for backend-encrypted items
+        if (cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
+          showToast(`Edit ${cred.key} to provide a new value before saving or renaming.`, 'warning');
+          hasErrors = true;
+          continue;
+        }

Optionally, also disable key renames for masked items (prevents confusion):

<input
  type="text"
  value={cred.key}
  onChange={(e) => updateCredential(index, 'key', e.target.value)}
  disabled={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'}
  className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-sm font-mono"
/>

Also applies to: 153-210

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No way to save something without editing it first

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we are good here

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coleam00 You're right that the hasChanges flag only gets set when specific fields are modified. However, there's still a potential edge case I was concerned about:

If a user renames the key of a masked credential (changes cred.key), that sets hasChanges = true, but the value remains [ENCRYPTED]. Then when they save, the system would attempt to persist that placeholder string as the actual credential value.

Looking at the code:

if (field === 'key' || field === 'value' || field === 'is_encrypted') {
  updated.hasChanges = true;
}

So while you can't save without some edit, you could potentially save after just renaming the key, which would be problematic.

That said, if this scenario seems unlikely in practice or if you prefer to handle it differently (like showing a more targeted error when it happens), I understand. The core security goal of not exposing decrypted values is already achieved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coleam00 You're absolutely right! Looking at the code flow more carefully:

  1. Masked credentials start with hasChanges: false
  2. The save logic only processes items where hasChanges || isNew is true
  3. To get hasChanges: true, the credential must be edited via updateCredential()
  4. When editing a masked credential's value, the code automatically clears the "[ENCRYPTED]" placeholder and sets isFromBackend: false

So there's indeed no pathway to save the placeholder - the existing logic already prevents this issue. Thanks for the clarification!

🐰✨ happy rabbit ears perk up with understanding


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

setCustomCredentials(uiCredentials);
} catch (err) {
Expand All @@ -81,7 +87,8 @@ export const APIKeysSection = () => {
hasChanges: true,
is_encrypted: true, // Default to encrypted
showValue: true, // Show value for new entries
isNew: true
isNew: true,
isFromBackend: false // New credentials are not from backend
};

setCustomCredentials([...customCredentials, newCred]);
Expand All @@ -95,18 +102,34 @@ export const APIKeysSection = () => {
if (field === 'key' || field === 'value' || field === 'is_encrypted') {
updated.hasChanges = true;
}
// If user is editing the value of an encrypted credential from backend, make it editable
if (field === 'value' && cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
updated.isFromBackend = false; // Now it's being edited, treat like new credential
updated.showValue = false; // Keep it hidden by default since it was encrypted
updated.value = ''; // Clear the [ENCRYPTED] placeholder so they can enter new value
}
return updated;
}
return cred;
}));
};

const toggleValueVisibility = (index: number) => {
updateCredential(index, 'showValue', !customCredentials[index].showValue);
const cred = customCredentials[index];
if (cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
showToast('Encrypted credentials cannot be viewed. Edit to make changes.', 'warning');
return;
}
updateCredential(index, 'showValue', !cred.showValue);
};

const toggleEncryption = (index: number) => {
updateCredential(index, 'is_encrypted', !customCredentials[index].is_encrypted);
const cred = customCredentials[index];
if (cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
showToast('Edit the credential value to make changes.', 'warning');
return;
}
updateCredential(index, 'is_encrypted', !cred.is_encrypted);
};

const deleteCredential = async (index: number) => {
Expand Down Expand Up @@ -242,15 +265,31 @@ export const APIKeysSection = () => {
value={cred.value}
onChange={(e) => updateCredential(index, 'value', e.target.value)}
placeholder={cred.is_encrypted && !cred.value ? 'Enter new value (encrypted)' : 'Enter value'}
className="w-full px-3 py-2 pr-20 rounded-md bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-sm"
className={`w-full px-3 py-2 pr-20 rounded-md border text-sm ${
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-400'
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-700'
}`}
title={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Click to edit this encrypted credential'
: undefined}
/>

{/* Show/Hide value button */}
<button
type="button"
onClick={() => toggleValueVisibility(index)}
className="absolute right-10 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={cred.showValue ? 'Hide value' : 'Show value'}
disabled={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'}
className={`absolute right-10 top-1/2 -translate-y-1/2 p-1.5 rounded transition-colors ${
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'cursor-not-allowed opacity-50'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title={
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Edit credential to view and modify'
: cred.showValue ? 'Hide value' : 'Show value'
}
>
{cred.showValue ? (
<EyeOff className="w-4 h-4 text-gray-500" />
Expand All @@ -263,14 +302,21 @@ export const APIKeysSection = () => {
<button
type="button"
onClick={() => toggleEncryption(index)}
disabled={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'}
className={`
absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded transition-colors
${cred.is_encrypted
? 'text-pink-600 dark:text-pink-400 hover:bg-pink-100 dark:hover:bg-pink-900/20'
: 'text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
${cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'cursor-not-allowed opacity-50 text-pink-400'
: cred.is_encrypted
? 'text-pink-600 dark:text-pink-400 hover:bg-pink-100 dark:hover:bg-pink-900/20'
: 'text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}
`}
title={cred.is_encrypted ? 'Encrypted' : 'Not encrypted'}
title={
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Edit credential to modify encryption'
: cred.is_encrypted ? 'Encrypted - click to decrypt' : 'Not encrypted - click to encrypt'
}
>
{cred.is_encrypted ? (
<Lock className="w-4 h-4" />
Expand Down Expand Up @@ -347,7 +393,7 @@ export const APIKeysSection = () => {
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<p>
Click the lock icon to toggle encryption for each credential. Encrypted values are stored securely and only decrypted when needed.
Encrypted credentials are masked after saving. Click on a masked credential to edit it - this allows you to change the value and encryption settings.
</p>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions archon-ui-main/src/services/credentialsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ class CredentialsService {
if (value && typeof value === "object" && value.is_encrypted) {
return {
key,
value: undefined,
encrypted_value: value.encrypted_value,
value: "[ENCRYPTED]",
encrypted_value: undefined,
is_encrypted: true,
category,
description: value.description,
Expand Down
12 changes: 7 additions & 5 deletions python/src/server/api_routes/settings_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,12 @@ async def create_credential(request: CredentialRequest):


@router.get("/credentials/{key}")
async def get_credential(key: str, decrypt: bool = True):
async def get_credential(key: str):
"""Get a specific credential by key."""
try:
logfire.info(f"Getting credential | key={key} | decrypt={decrypt}")
value = await credential_service.get_credential(key, decrypt=decrypt)
logfire.info(f"Getting credential | key={key}")
# Never decrypt - always get metadata only for encrypted credentials
value = await credential_service.get_credential(key, decrypt=False)

if value is None:
# Check if this is an optional setting with a default value
Expand All @@ -162,16 +163,17 @@ async def get_credential(key: str, decrypt: bool = True):

logfire.info(f"Credential retrieved successfully | key={key}")

# For encrypted credentials, return metadata instead of the actual value for security
if isinstance(value, dict) and value.get("is_encrypted") and not decrypt:
if isinstance(value, dict) and value.get("is_encrypted"):
return {
"key": key,
"value": "[ENCRYPTED]",
"is_encrypted": True,
"category": value.get("category"),
"description": value.get("description"),
"has_value": bool(value.get("encrypted_value")),
}

# For non-encrypted credentials, return the actual value
return {"key": key, "value": value, "is_encrypted": False}

except HTTPException:
Expand Down
33 changes: 9 additions & 24 deletions python/src/server/services/credential_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ async def get_credentials_by_category(self, category: str) -> dict[str, Any]:
key = item["key"]
if item["is_encrypted"]:
credentials[key] = {
"encrypted_value": item["encrypted_value"],
"value": "[ENCRYPTED]",
"is_encrypted": True,
"description": item["description"],
}
Expand All @@ -330,31 +330,16 @@ async def list_all_credentials(self) -> list[CredentialItem]:

credentials = []
for item in result.data:
# For encrypted values, decrypt them for UI display
if item["is_encrypted"] and item["encrypted_value"]:
try:
decrypted_value = self._decrypt_value(item["encrypted_value"])
cred = CredentialItem(
key=item["key"],
value=decrypted_value,
encrypted_value=None, # Don't expose encrypted value
is_encrypted=item["is_encrypted"],
category=item["category"],
description=item["description"],
)
except Exception as e:
logger.error(f"Failed to decrypt credential {item['key']}: {e}")
# If decryption fails, show placeholder
cred = CredentialItem(
key=item["key"],
value="[DECRYPTION ERROR]",
encrypted_value=None,
is_encrypted=item["is_encrypted"],
category=item["category"],
description=item["description"],
)
cred = CredentialItem(
key=item["key"],
value="[ENCRYPTED]",
encrypted_value=None,
is_encrypted=item["is_encrypted"],
category=item["category"],
description=item["description"],
)
else:
# Plain text values
cred = CredentialItem(
key=item["key"],
value=item["value"],
Expand Down