diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java index 8d27707..9d0288c 100644 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/credential/CredentialController.java @@ -20,7 +20,7 @@ import java.util.UUID; @RestController -@RequestMapping("/ap1/v1/projects/{projectId}/credentials") +@RequestMapping("/api/v1/projects/{projectId}/credentials") public class CredentialController implements CredentialApi{ private final CreateCredentialUseCase createCredentialUseCase; diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java index 1c4a6a8..a4f7f92 100644 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/security/SecurityController.java @@ -9,7 +9,7 @@ import java.util.Arrays; @RestController -@RequestMapping("/ap1/v1/security") +@RequestMapping("/api/v1/security") public class SecurityController implements SecurityApi{ private final SetupMasterPasswordUseCase setupMasterPasswordUseCase; diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java index 416e0c0..d65ba08 100644 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java +++ b/backend/src/test/java/com/devaulty/backend/adapter/in/web/credential/CredentialControllerIT.java @@ -87,7 +87,7 @@ void createCredential_shouldReturnCreated_whenLoginRequestIsValid() throws Excep } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()) @@ -133,7 +133,7 @@ void createCredential_shouldReturnCreated_whenApiKeyRequestIsValid() throws Exce } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()) @@ -151,7 +151,7 @@ void createCredential_shouldReturnCreated_whenRawTextRequestIsValid() throws Exc } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()) @@ -169,7 +169,7 @@ void createCredential_shouldReturnBadRequest_whenSecretFieldsAreMissingForLogin( } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) @@ -188,7 +188,7 @@ void createCredential_shouldReturnBadRequest_whenTitleIsTooShort() throws Except } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) @@ -208,7 +208,7 @@ void createCredential_shouldReturnNotFound_whenProjectDoesNotExist() throws Exce } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", nonExistentProjectId) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", nonExistentProjectId) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isNotFound()) @@ -227,7 +227,7 @@ void createCredential_shouldReturnLocked_whenVaultIsLocked() throws Exception { } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isLocked()) @@ -246,7 +246,7 @@ void createCredential_shouldReturnForbidden_whenSetupIsRequired() throws Excepti } """; - mockMvc.perform(post("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(post("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isForbidden()) @@ -284,7 +284,7 @@ void getAllCredentials_shouldReturnPagedSummaries() throws Exception { credentialRepository.save(c1); credentialRepository.save(c2); - mockMvc.perform(get("/ap1/v1/projects/{projectId}/credentials", savedProject.getId()) + mockMvc.perform(get("/api/v1/projects/{projectId}/credentials", savedProject.getId()) .param("page", "0") .param("size", "5")) .andExpect(status().isOk()) @@ -316,7 +316,7 @@ void getCredentialById_shouldReturnDecryptedCredential() throws Exception { entity.setCreatedAt(LocalDateTime.now()); credentialRepository.save(entity); - mockMvc.perform(get("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) + mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(credentialId.toString())) .andExpect(jsonPath("$.title").value("Get Credential")) @@ -329,7 +329,7 @@ void getCredentialById_shouldReturnDecryptedCredential() throws Exception { void getCredentialById_shouldReturnNotFound_whenCredentialDoesNotExist() throws Exception { UUID nonExistentId = UUID.randomUUID(); - mockMvc.perform(get("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) + mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("Credential not found with identifier " + nonExistentId)); } @@ -360,7 +360,7 @@ void getCredentialById_shouldReturnNotFound_whenCredentialDoesNotBelongToProject credentialRepository.save(entity); // Fetch using savedProject's ID (not otherProject's ID) - mockMvc.perform(get("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) + mockMvc.perform(get("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("Credential not found with identifier " + credentialId)); } @@ -393,7 +393,7 @@ void updateCredential_shouldUpdateTitleAndMetadataOnly_whenNoPayloadProvided() t } """; - mockMvc.perform(patch("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) + mockMvc.perform(patch("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) @@ -440,7 +440,7 @@ void updateCredential_shouldUpdatePayloadAndReencrypt_whenNewPayloadProvided() t } """; - mockMvc.perform(patch("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) + mockMvc.perform(patch("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) @@ -481,7 +481,7 @@ void deleteCredential_shouldRemoveCredentialAndReturnNoContent() throws Exceptio entity.setCreatedAt(LocalDateTime.now()); credentialRepository.save(entity); - mockMvc.perform(delete("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) + mockMvc.perform(delete("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), credentialId)) .andExpect(status().isNoContent()); assertFalse(credentialRepository.existsById(credentialId)); @@ -491,7 +491,7 @@ void deleteCredential_shouldRemoveCredentialAndReturnNoContent() throws Exceptio void deleteCredential_shouldReturnNotFound_whenCredentialDoesNotExist() throws Exception { UUID nonExistentId = UUID.randomUUID(); - mockMvc.perform(delete("/ap1/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) + mockMvc.perform(delete("/api/v1/projects/{projectId}/credentials/{credentialId}", savedProject.getId(), nonExistentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("Credential not found with identifier " + nonExistentId)); } diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java index 25d8af7..2f20acb 100644 --- a/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java +++ b/backend/src/test/java/com/devaulty/backend/adapter/in/web/security/SecurityControllerIT.java @@ -38,7 +38,7 @@ void setupMasterPassword_shouldReturnNoContent_whenFirstTime() throws Exception } """; - mockMvc.perform(post("/ap1/v1/security/master-password") + mockMvc.perform(post("/api/v1/security/master-password") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isNoContent()); @@ -64,7 +64,7 @@ void setupMasterPassword_shouldReturnBadRequest_whenPasswordTooShort() throws Ex } """; - mockMvc.perform(post("/ap1/v1/security/master-password") + mockMvc.perform(post("/api/v1/security/master-password") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) @@ -87,7 +87,7 @@ void setupMasterPassword_shouldReturnConflict_whenAlreadyConfigured() throws Exc } """; - mockMvc.perform(post("/ap1/v1/security/master-password") + mockMvc.perform(post("/api/v1/security/master-password") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isConflict()) @@ -96,7 +96,7 @@ void setupMasterPassword_shouldReturnConflict_whenAlreadyConfigured() throws Exc @Test void checkMasterPasswordSetup_shouldReturnTrue_whenNotConfigured() throws Exception { - mockMvc.perform(get("/ap1/v1/security/master-password/required-status")) + mockMvc.perform(get("/api/v1/security/master-password/required-status")) .andExpect(status().isOk()) .andExpect(content().string("true")); } @@ -106,14 +106,14 @@ void checkMasterPasswordSetup_shouldReturnFalse_whenAlreadyConfigured() throws E appSettingRepository.save(new AppSettingEntity("master_password_hash", "mockHash")); appSettingRepository.save(new AppSettingEntity("master_password_salt", "mockSalt")); - mockMvc.perform(get("/ap1/v1/security/master-password/required-status")) + mockMvc.perform(get("/api/v1/security/master-password/required-status")) .andExpect(status().isOk()) .andExpect(content().string("false")); } @Test void getSessionStatus_shouldReturnInactiveAndZeroSeconds_whenLocked() throws Exception { - mockMvc.perform(get("/ap1/v1/security/vault/status")) + mockMvc.perform(get("/api/v1/security/vault/status")) .andExpect(status().isOk()) .andExpect(jsonPath("$.active").value(false)) .andExpect(jsonPath("$.secondsLeft").value(0)); @@ -123,7 +123,7 @@ void getSessionStatus_shouldReturnInactiveAndZeroSeconds_whenLocked() throws Exc void getSessionStatus_shouldReturnActiveAndRemainingSeconds_whenUnlocked() throws Exception { masterKeySessionPort.setKey(new SecretKeySpec(new byte[32], "AES")); - mockMvc.perform(get("/ap1/v1/security/vault/status")) + mockMvc.perform(get("/api/v1/security/vault/status")) .andExpect(status().isOk()) .andExpect(jsonPath("$.active").value(true)) .andExpect(jsonPath("$.secondsLeft").value(greaterThan(0))); @@ -138,7 +138,7 @@ void unlockVault_shouldReturnTrue_whenPasswordIsCorrect() throws Exception { } """; - mockMvc.perform(post("/ap1/v1/security/master-password") + mockMvc.perform(post("/api/v1/security/master-password") .contentType(MediaType.APPLICATION_JSON) .content(setupJson)) .andExpect(status().isNoContent()); @@ -154,7 +154,7 @@ void unlockVault_shouldReturnTrue_whenPasswordIsCorrect() throws Exception { } """; - mockMvc.perform(post("/ap1/v1/security/vault/unlock") + mockMvc.perform(post("/api/v1/security/vault/unlock") .contentType(MediaType.APPLICATION_JSON) .content(unlockJson)) .andExpect(status().isOk()) @@ -172,7 +172,7 @@ void unlockVault_shouldReturnUnauthorized_whenPasswordIsWrong() throws Exception } """; - mockMvc.perform(post("/ap1/v1/security/master-password") + mockMvc.perform(post("/api/v1/security/master-password") .contentType(MediaType.APPLICATION_JSON) .content(setupJson)) .andExpect(status().isNoContent()); @@ -186,7 +186,7 @@ void unlockVault_shouldReturnUnauthorized_whenPasswordIsWrong() throws Exception } """; - mockMvc.perform(post("/ap1/v1/security/vault/unlock") + mockMvc.perform(post("/api/v1/security/vault/unlock") .contentType(MediaType.APPLICATION_JSON) .content(unlockJson)) .andExpect(status().isUnauthorized()) @@ -203,7 +203,7 @@ void unlockVault_shouldReturnForbidden_whenNotConfigured() throws Exception { } """; - mockMvc.perform(post("/ap1/v1/security/vault/unlock") + mockMvc.perform(post("/api/v1/security/vault/unlock") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isForbidden()) @@ -218,7 +218,7 @@ void unlockVault_shouldReturnBadRequest_whenPasswordTooShort() throws Exception } """; - mockMvc.perform(post("/ap1/v1/security/vault/unlock") + mockMvc.perform(post("/api/v1/security/vault/unlock") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) @@ -231,7 +231,7 @@ void lockVault_shouldClearSessionAndReturnNoContent() throws Exception { masterKeySessionPort.setKey(new SecretKeySpec(new byte[32], "AES")); assertTrue(masterKeySessionPort.hasKey()); - mockMvc.perform(post("/ap1/v1/security/vault/lock")) + mockMvc.perform(post("/api/v1/security/vault/lock")) .andExpect(status().isNoContent()); assertFalse(masterKeySessionPort.hasKey()); diff --git a/frontend/src/assets/logo-devaulty.svg b/frontend/src/assets/logo-devaulty.svg index 39fa317..ba57c49 100644 --- a/frontend/src/assets/logo-devaulty.svg +++ b/frontend/src/assets/logo-devaulty.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/frontend/src/components/HackerLogo.module.css b/frontend/src/components/HackerLogo.module.css new file mode 100644 index 0000000..d3954f8 --- /dev/null +++ b/frontend/src/components/HackerLogo.module.css @@ -0,0 +1,89 @@ +.container { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.organicAura { + position: absolute; + width: 150%; + height: 150%; + left: -25%; + top: -25%; + border-radius: 50%; + background: radial-gradient( + ellipse at center, + rgba(16, 185, 129, 0.3) 0%, + rgba(16, 185, 129, 0.1) 50%, + rgba(16, 185, 129, 0) 75% + ); + filter: blur(14px); + pointer-events: none; + z-index: 0; + animation: organic-pulse 3s ease-in-out infinite alternate; +} + +:global(:root[data-theme="light"]) .organicAura { + background: radial-gradient( + ellipse at center, + rgba(5, 150, 105, 0.25) 0%, + rgba(5, 150, 105, 0.08) 50%, + rgba(5, 150, 105, 0) 75% + ); +} + +@keyframes organic-pulse { + 0% { + opacity: 0.5; + transform: scale(0.9) rotate(0deg); + } + 100% { + opacity: 0.95; + transform: scale(1.15) rotate(5deg); + } +} + +.particleCanvas { + position: absolute; + inset: -25px -35px; + pointer-events: none; + overflow: visible; + z-index: 10; +} + +.particle { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + color: #10b981; + filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.9)) drop-shadow(0 0 10px rgba(16, 185, 129, 0.5)); + pointer-events: none; + user-select: none; + will-change: transform, opacity; +} + +:global(:root[data-theme="light"]) .particle { + color: #059669; + filter: drop-shadow(0 0 5px rgba(5, 150, 105, 0.8)); +} + +.logoSvg { + position: relative; + z-index: 1; + transition: color 0.4s cubic-bezier(0.16, 1, 0.3, 1), filter 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +.greenLogo { + color: #10b981 !important; + filter: drop-shadow(0 0 5px rgba(16, 185, 129, 0.8)) + drop-shadow(0 0 12px rgba(16, 185, 129, 0.5)) + drop-shadow(0 0 22px rgba(16, 185, 129, 0.25)) !important; +} + +:global(:root[data-theme="light"]) .greenLogo { + color: #059669 !important; + filter: drop-shadow(0 0 4px rgba(5, 150, 105, 0.7)) + drop-shadow(0 0 10px rgba(5, 150, 105, 0.4)) !important; +} diff --git a/frontend/src/components/HackerLogo.tsx b/frontend/src/components/HackerLogo.tsx new file mode 100644 index 0000000..42eadd5 --- /dev/null +++ b/frontend/src/components/HackerLogo.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState, useRef } from "react"; +import { LogoDevaulty } from "./LogoDevaulty"; +import styles from "./HackerLogo.module.css"; + +const BINARY_DIGITS = ["00", "01"]; + +interface Particle { + id: number; + digit: string; + x: number; + y: number; + size: number; + speedY: number; + speedX: number; + opacity: number; +} + +interface HackerLogoProps { + height?: number | string; + width?: number | string; + className?: string; +} + +export const HackerLogo: React.FC = ({ + height = "100%", + width = "auto", + className, +}) => { + const [isVaultActive, setIsVaultActive] = useState( + () => typeof document !== "undefined" && document.documentElement.dataset.vaultActive === "true" + ); + const [particles, setParticles] = useState([]); + const nextIdRef = useRef(0); + + useEffect(() => { + const checkActive = () => { + const active = document.documentElement.dataset.vaultActive === "true"; + setIsVaultActive(active); + }; + + checkActive(); + + const observer = new MutationObserver(checkActive); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-vault-active"], + }); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!isVaultActive) return; + + const interval = setInterval(() => { + setParticles((prev) => { + const newParticle: Particle = { + id: nextIdRef.current++, + digit: BINARY_DIGITS[Math.floor(Math.random() * BINARY_DIGITS.length)], + x: Math.random() * 85 + 5, + y: Math.random() * 20 + 45, + size: Math.floor(Math.random() * 5 + 10), // 10px to 14px font + speedY: Math.random() * 0.7 + 0.5, + speedX: (Math.random() - 0.5) * 0.6, + opacity: 0.9, + }; + + const updated = prev + .map((p) => ({ + ...p, + y: p.y - p.speedY * 1.8, + x: p.x + p.speedX * 1.2, + opacity: p.opacity - 0.025, + })) + .filter((p) => p.opacity > 0 && p.y > -35); + + if (updated.length < 14) { + updated.push(newParticle); + } + + return updated; + }); + }, 70); + + return () => { + clearInterval(interval); + setParticles([]); + }; + }, [isVaultActive]); + + return ( +
+ {isVaultActive && + ); +}; diff --git a/frontend/src/components/LogoDevaulty.tsx b/frontend/src/components/LogoDevaulty.tsx index 8fdc351..39f70fe 100644 --- a/frontend/src/components/LogoDevaulty.tsx +++ b/frontend/src/components/LogoDevaulty.tsx @@ -15,7 +15,7 @@ export const LogoDevaulty: React.FC = ({ height = "100%", width = "au {...props} > { const deleteLinkMutation = useDeleteLinkMutation(projectId); // Workspace sub-navigation state - const [activeTab, setActiveTab] = useState<"snippets" | "problems" | "notes" | "links">("snippets"); + const [activeTab, setActiveTab] = useState< + "snippets" | "problems" | "credentials" | "notes" | "links" + >("snippets"); + + // Security queries + const { data: isSetupRequired, isLoading: isSetupLoading } = + useMasterPasswordSetupStatusQuery(activeTab === "credentials"); + const { data: vaultStatus, isLoading: isVaultStatusLoading } = + useVaultStatusQuery(activeTab === "credentials"); + + const isSecurityLoading = + activeTab === "credentials" && (isSetupLoading || isVaultStatusLoading); + const isVaultActive = vaultStatus?.active === true; + const isVaultLocked = !isSecurityLoading && !isSetupRequired && !isVaultActive; + + // Credentials queries & mutations + const { data: credentialsData, isLoading: isCredentialsLoading } = + useCredentialsQuery(projectId, activeTab === "credentials" && isVaultActive); + const deleteCredentialMutation = useDeleteCredentialMutation(projectId); + + // Auto-lock after 15 minutes of inactivity in credentials workspace + useInactivityAutoLock(activeTab === "credentials" && isVaultActive, () => { + setActiveTab("snippets"); + }); + + // Toggle data-vault-active on root document when viewing credentials workspace + useEffect(() => { + if (activeTab === "credentials") { + document.documentElement.dataset.vaultActive = "true"; + } else { + delete document.documentElement.dataset.vaultActive; + } + return () => { + delete document.documentElement.dataset.vaultActive; + }; + }, [activeTab]); + + // Credentials UI states + const [credentialSearchQuery, setCredentialSearchQuery] = useState(""); + const [credentialTypeFilter, setCredentialTypeFilter] = useState< + "ALL" | CredentialSecretType + >("ALL"); + const [isCredentialFormOpen, setIsCredentialFormOpen] = useState(false); + const [editingCredentialId, setEditingCredentialId] = useState< + string | undefined + >(undefined); + const [viewingCredentialId, setViewingCredentialId] = useState< + string | undefined + >(undefined); // Selected item states const [selectedSnippetId, setSelectedSnippetId] = useState(undefined); @@ -380,7 +449,7 @@ export const ProjectDetailView: React.FC = () => { // Tags association operations const handleAddTag = async ( itemId: string, - itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK", + itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK" | "CREDENTIAL", tagId: string ) => { try { @@ -394,7 +463,7 @@ export const ProjectDetailView: React.FC = () => { const handleRemoveTag = async ( itemId: string, - itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK", + itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK" | "CREDENTIAL", tagId: string ) => { try { @@ -407,7 +476,7 @@ export const ProjectDetailView: React.FC = () => { const handleCreateAndAddTag = async ( itemId: string, - itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK" + itemType: "SNIPPET" | "PROBLEM" | "NOTE" | "LINK" | "CREDENTIAL" ) => { if (!tagSearchQuery.trim()) return; try { @@ -584,7 +653,45 @@ export const ProjectDetailView: React.FC = () => { (l.description && l.description.toLowerCase().includes(linkSearchQuery.toLowerCase())) ); - const handleTabChange = (tab: "snippets" | "problems" | "notes" | "links") => { + const credentials = credentialsData?.content || []; + const filteredCredentials = credentials.filter((c) => { + const matchesSearch = + c.title.toLowerCase().includes(credentialSearchQuery.toLowerCase()) || + (c.relatedUrl && + c.relatedUrl.toLowerCase().includes(credentialSearchQuery.toLowerCase())); + const matchesType = + credentialTypeFilter === "ALL" || c.secretType === credentialTypeFilter; + return matchesSearch && matchesType; + }); + + const handleDeleteCredential = (credId: string, title: string) => { + setConfirmModal({ + isOpen: true, + title: "Delete Vault Credential", + message: "Are you sure you want to delete the credential", + itemName: title, + warningText: + "This action cannot be undone. Encrypted secret data will be permanently deleted.", + onConfirm: async () => { + setConfirmModal((prev) => ({ ...prev, isLoading: true })); + try { + await deleteCredentialMutation.mutateAsync(credId); + toast.success("Credential deleted successfully!"); + closeConfirmModal(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to delete credential." + ); + setConfirmModal((prev) => ({ ...prev, isLoading: false })); + } + }, + isLoading: false, + }); + }; + + const handleTabChange = ( + tab: "snippets" | "problems" | "credentials" | "notes" | "links" + ) => { setActiveTab(tab); setShowTagPopoverId(null); setTagSearchQuery(""); @@ -653,7 +760,11 @@ export const ProjectDetailView: React.FC = () => { )} - @@ -1888,6 +1999,258 @@ export const ProjectDetailView: React.FC = () => { )} + {/* Tab 3: Credentials Workspace */} + {activeTab === "credentials" && ( +
+ {isSecurityLoading ? ( +
+ +
+ ) : isSetupRequired ? ( + + ) : isVaultLocked ? ( + + ) : ( +
+ + +
+
+
+
+ + setCredentialSearchQuery(e.target.value)} + /> +
+ + {/* Type filter tabs */} +
+ {(["ALL", "LOGIN", "API_KEY", "RAW_TEXT"] as const).map((type) => ( + + ))} +
+
+ + +
+ + {/* Grid / List of Credential Cards */} + {isCredentialsLoading ? ( +
+ + LOADING CREDENTIALS... +
+ ) : filteredCredentials.length === 0 ? ( +
+ +
+ {credentialSearchQuery || credentialTypeFilter !== "ALL" + ? "No credentials match the active filters." + : "No credentials stored in this project yet. Click 'New Credential' to add one."} +
+
+ ) : ( +
+ {filteredCredentials.map((cred) => ( +
setViewingCredentialId(cred.id)} + > +
+
+ + {cred.secretType === "LOGIN" && } + {cred.secretType === "API_KEY" && } + {cred.secretType === "RAW_TEXT" && } + {cred.secretType} + +

{cred.title}

+ {cred.relatedUrl && ( + e.stopPropagation()} + > + + {cred.relatedUrl.replace(/^https?:\/\//, "")} + + )} +
+
+ +
+ {/* Tag list & tag popover */} +
e.stopPropagation()}> + {cred.tags && cred.tags.map((tag) => ( + + + {tag.name} + + + ))} + + {/* Add Tag Popover Button */} +
+ + + {showTagPopoverId === `cred-${cred.id}` && ( +
e.stopPropagation()} + > +
+ setTagSearchQuery(e.target.value)} + autoFocus + /> +
+
+ {tagsData + .filter( + (t) => + t.name.toLowerCase().includes(tagSearchQuery.toLowerCase()) && + (!cred.tags || !cred.tags.some((st) => st.id === t.id)) + ) + .map((t) => ( + + ))} + + {tagSearchQuery.trim() && + !tagsData.some( + (t) => t.name.toLowerCase() === tagSearchQuery.toLowerCase() + ) && ( + + )} +
+
+ )} +
+
+ + {/* Actions */} +
e.stopPropagation()}> + + +
+
+
+ ))} +
+ )} +
+
+ )} +
+ )} + {/* Tab 4: Links Workspace */} {activeTab === "links" && ( <> @@ -2187,6 +2550,27 @@ export const ProjectDetailView: React.FC = () => { linkId={editingLinkId} /> + {/* Global Credential Form Modal */} + { + setIsCredentialFormOpen(false); + setEditingCredentialId(undefined); + }} + projectId={projectId} + credentialId={editingCredentialId} + /> + + {/* Global Credential Detail Modal */} + {viewingCredentialId && ( + setViewingCredentialId(undefined)} + projectId={projectId} + credentialId={viewingCredentialId} + /> + )} + {/* Global Confirm Delete Modal */} { >
- +
@@ -168,7 +168,7 @@ const RootLayoutInner: React.FC = () => {