From e8cafd2d3cfdf864921e40a9b184d1725a4ee6ed Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:15:50 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[#74]=20feat:=20SecretClient=20revealPayloa?= =?UTF-8?q?d=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Dependencies/SecretClient.swift | 30 ++++++++++- .../Dependencies/SecretClient+Live.swift | 53 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index aaa7a8a0..2a5146db 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -16,6 +16,9 @@ public struct SecretClient: Sendable { public var softDelete: @Sendable (_ id: Secret.ID) async throws -> Secret public var restore: @Sendable (_ id: Secret.ID) async throws -> Secret public var permanentlyDelete: @Sendable (_ id: Secret.ID) async throws -> Void + /// 생체인증 후 Secret의 암호화된 payload를 복호화해 `CreateSecretPayload`로 반환한다. + /// secretType/subType에 따라 적절한 도메인 payload 타입으로 dispatch하고 metadata JSON을 병합한다. + public var revealPayload: @Sendable (_ secret: Secret) async throws -> CreateSecretPayload // MARK: - Project @@ -53,9 +56,34 @@ private extension SecretClient { softDelete: { _ in .preview }, restore: { _ in .preview }, permanentlyDelete: { _ in }, + revealPayload: { secret in + switch secret.secretType { + case .apiKeyToken: + return .apiKey(APIKeyPayload(value: "preview_api_key"), nil) + case .oauth: + return .oauthClient( + OAuthClientPayload(clientId: "preview_id", clientSecret: "preview_secret"), + nil + ) + case .database: + return .database(DatabasePayload(linkString: "postgresql://preview:5432/db"), nil) + case .sshAndCredentials: + return .sshKey( + SSHKeyPayload( + privateKey: "-----BEGIN RSA PRIVATE KEY-----\npreview\n-----END RSA PRIVATE KEY-----", + passphrase: nil + ), + nil + ) + case .environmentVariableSet: + return .environmentVariableSet(EnvSetPayload(content: "KEY=value\nOTHER=123")) + case .etc: + return .licenseKey(LicenseKeyPayload(licenseKey: "PREVIEW-LICENSE-1234"), nil) + } + }, fetchProjects: { .preview }, createProject: { name in - Project(id: UUID(), name: name, createdAt: .now, updatedAt: .now) + Project(id: UUID(), name: name, createdAt: .now, updatedAt: .now) }, linkProject: { _, _ in } ) diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index bf033fe7..dd6a98f7 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -44,7 +44,9 @@ extension SecretClient: @retroactive DependencyKey { permanentlyDelete: { id in try await deleteSecretUseCase.permanentlyDelete(id: id) }, - fetchProjects: { + revealPayload: { secret in + try await dispatchRevealPayload(secret: secret, useCase: fetchSecretUseCase) + }, fetchProjects: { try await fetchProjectUseCase.fetchAll() }, createProject: { name in @@ -56,3 +58,52 @@ extension SecretClient: @retroactive DependencyKey { ) }() } + +// MARK: - Payload Dispatch + +private func dispatchRevealPayload( + secret: Secret, + useCase: any FetchSecretUseCase +) async throws -> CreateSecretPayload { + func decodeMeta(_ type: M.Type) -> M? { + secret.metadata.flatMap { try? JSONDecoder().decode(M.self, from: $0.metadataJSON) } + } + + switch (secret.secretType, secret.subType) { + case (.apiKeyToken, .apiKey), (.apiKeyToken, nil): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .apiKey(p, decodeMeta(APIKeyMetadata.self)) + case (.apiKeyToken, .accessToken): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .accessToken(p, decodeMeta(APIKeyMetadata.self)) + case (.apiKeyToken, .webhookSecret): + let p = try await useCase.revealPayload(id: secret.id, as: APIKeyPayload.self) + return .webhookSecret(p, decodeMeta(APIKeyMetadata.self)) + case (.oauth, .oauthClient), (.oauth, nil): + let p = try await useCase.revealPayload(id: secret.id, as: OAuthClientPayload.self) + return .oauthClient(p, decodeMeta(OAuthClientMetadata.self)) + case (.oauth, .serviceAccount): + let p = try await useCase.revealPayload(id: secret.id, as: ServiceAccountPayload.self) + return .serviceAccount(p, decodeMeta(ServiceAccountMetadata.self)) + case (.database, _): + let p = try await useCase.revealPayload(id: secret.id, as: DatabasePayload.self) + return .database(p, decodeMeta(DatabaseMetadata.self)) + case (.sshAndCredentials, .sshKey), (.sshAndCredentials, nil): + let p = try await useCase.revealPayload(id: secret.id, as: SSHKeyPayload.self) + return .sshKey(p, decodeMeta(SSHKeyMetadata.self)) + case (.sshAndCredentials, .sslTlsCertificate): + let p = try await useCase.revealPayload(id: secret.id, as: SSLCertPayload.self) + return .sslTlsCertificate(p, decodeMeta(SSLCertMetadata.self)) + case (.environmentVariableSet, _): + let p = try await useCase.revealPayload(id: secret.id, as: EnvSetPayload.self) + return .environmentVariableSet(p) + case (.etc, .licenseKey), (.etc, nil): + let p = try await useCase.revealPayload(id: secret.id, as: LicenseKeyPayload.self) + return .licenseKey(p, decodeMeta(LicenseKeyMetadata.self)) + case (.etc, .custom): + let p = try await useCase.revealPayload(id: secret.id, as: CustomPayload.self) + return .custom(p) + default: + throw SecretUseCaseError.unexpected + } +} From 1ba081f2388bf4fa84461f6e842621ac864dafe8 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:16:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[#74]=20feat:=20SecretDetailFeature=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/SecretDetailError.swift | 98 ++++++++++++ .../SecretDetail/SecretDetailFeature.swift | 144 +++++++++++++++++ .../SecretDetail/SecretDetailView.swift | 150 ++++++++++++++++++ .../SecretDetailFeatureTests.swift | 134 ++++++++++++++++ 4 files changed, 526 insertions(+) create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift create mode 100644 Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift new file mode 100644 index 00000000..db03441a --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift @@ -0,0 +1,98 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVDomain +import Foundation + +// MARK: - SecretDetailError + +/// Secret 조회/복호화 실패 시 Presentation 계층 오류. +enum SecretDetailError: Equatable { + /// 생체인증·패스코드 인증 요구. + case authRequired + /// 복호화 실패 (키 없음·복호화 오류·디코딩 실패). + case decryptionFailed + /// 그 외 예기치 않은 오류. + case unexpected + + static func map(_ error: SecretUseCaseError) -> SecretDetailError { + switch error { + case .authenticationFailure: + return .authRequired + case .cryptoFailure(let crypto): + switch crypto { + case .keyUnavailable, .keychainFailure, + .decryptionFailed, .decodingFailed: + return .decryptionFailed + case .encryptionFailed, .encodingFailed: + return .unexpected + } + case .secretNotFound, .repositoryFailure, + .invalidName, .invalidSecretType, .unexpected: + return .unexpected + } + } +} + +// MARK: - AlertState Presets + +extension AlertState where Action == SecretDetailFeature.Action.Alert { + + static func payloadRevealFailed(_ error: SecretDetailError) -> Self { + switch error { + case .authRequired: + return Self { + TextState("Authentication required", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "Please authenticate to view the secret.", + bundle: .module + ) + } + + case .decryptionFailed: + return Self { + TextState("Failed to reveal secret", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "The secret could not be decrypted. Check that your device passcode is enabled.", + bundle: .module + ) + } + + case .unexpected: + return Self { + TextState("Failed to reveal secret", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "An unexpected error occurred. Please try again.", + bundle: .module + ) + } + } + } + + static var confirmDiscard: Self { + Self { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmDiscard) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } message: { + TextState( + "Your unsaved changes will be lost.", + bundle: .module + ) + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift new file mode 100644 index 00000000..7cdcf197 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -0,0 +1,144 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVDomain +import Foundation + +// MARK: - SecretDetailFeature + +@Reducer +public struct SecretDetailFeature { + + // MARK: - Mode + + public enum Mode: Equatable { + /// 조회 모드: Text 전용 뷰 트리. 인터랙티브 컨트롤 없음. + case viewing + /// 수정 모드: 기존 SectionView 재사용. editFields 바인딩 있음. + case editing + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + /// 원본 시크릿. let — 절대 바인딩 대상이 아님. + public let secret: Secret + public var mode: Mode = .viewing + /// 수정 모드에서만 유효. viewing일 때는 반드시 nil. + var editFields: SecretMetaFields? + public var availableProjects: [Project] = [] + public var isSaving = false + public var isLoadingProjects = false + /// 복호화된 payload. .idle → .loading → .loaded / .failed 순서로 전이. + public var payloadState: LoadingState = .idle + @Presents public var alert: AlertState? + + public init(secret: Secret) { + self.secret = secret + } + } + + // MARK: - Action + + public enum Action: BindableAction, Equatable { + + // MARK: View + case task + case binding(BindingAction) + case didTapClose + case didTapEdit + case didTapCancelEdit + case didTapSave + + // MARK: Internal + case projectsResponse(Result<[Project], ProjectUseCaseError>) + case payloadResponse(Result) + + // MARK: Child + case alert(PresentationAction) + + // MARK: Delegate + case delegate(Delegate) + + public enum Alert: Equatable { + case confirmDiscard + } + + public enum Delegate: Equatable { + case closed + case secretUpdated(Secret) + } + } + + // MARK: - Dependencies + + @Dependency(\.projectClient) var projectClient + @Dependency(\.secretClient) var secretClient + + // MARK: - Init + + public init() {} + + // MARK: - Body + + public var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .task: + state.isLoadingProjects = true + state.payloadState = .loading + return .merge( + .run { send in + do { + let projects = try await projectClient.fetchProjects() + await send(.projectsResponse(.success(projects))) + } catch is CancellationError { + } catch { + await send(.projectsResponse(.failure(.unexpected))) + } + }, + .run { [secret = state.secret] send in + do { + let payload = try await secretClient.revealPayload(secret) + await send(.payloadResponse(.success(payload))) + } catch is CancellationError { + } catch { + await send(.payloadResponse(.failure(SecretUseCaseError.map(error)))) + } + } + ) + + case .didTapClose: + return .send(.delegate(.closed)) + + case .projectsResponse(.success(let projects)): + state.isLoadingProjects = false + state.availableProjects = projects + return .none + + case .projectsResponse(.failure): + state.isLoadingProjects = false + return .none + + case .payloadResponse(.success(let payload)): + state.payloadState = .loaded(payload) + return .none + + case .payloadResponse(.failure(let error)): + state.payloadState = .failed(error) + state.alert = .payloadRevealFailed(SecretDetailError.map(error)) + return .none + + // Issue C에서 구현 + case .didTapEdit, .didTapCancelEdit, .didTapSave: + return .none + + case .binding, .alert, .delegate: + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift new file mode 100644 index 00000000..b23ee6d9 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -0,0 +1,150 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVDesign +import DVDomain +import SwiftUI + +// MARK: - SecretDetailView + +public struct SecretDetailView: View { + + // MARK: - Properties + + @Bindable public var store: StoreOf + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + VStack(spacing: 0) { + topBar + Group { + if store.mode == .viewing { + viewingBody + } else { + editingBody + } + } + if store.mode == .editing { + // Issue C에서 FooterActionsView 연결 + Divider() + HStack { + Spacer() + Button("Cancel") { store.send(.didTapCancelEdit) } + .buttonStyle(.plain) + Button("Save") { store.send(.didTapSave) } + .buttonStyle(.borderedProminent) + } + .padding() + } + } + .task { store.send(.task) } + } +} + +// MARK: - Subviews + +extension SecretDetailView { + + private var topBar: some View { + VStack(spacing: 0) { + HStack { + Button { store.send(.didTapClose) } label: { + Image(systemName: "xmark") + .imageScale(.medium) + } + .buttonStyle(.plain) + Spacer() + if store.mode == .viewing { + Button { store.send(.didTapEdit) } label: { + Image(systemName: "pencil") + .imageScale(.medium) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + Divider() + } + } + + // MARK: Viewing — 인터랙티브 컨트롤 완전 배제 (Issue B에서 완성) + + @ViewBuilder + private var viewingBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(store.secret.name) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray900)) + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + } + + // MARK: Editing — CreateSecret SectionView 재사용 (Issue C에서 완성) + + @ViewBuilder + private var editingBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(store.secret.name) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray900)) + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + } +} + +// MARK: - Preview + +#if DEBUG + +private let _previewSecret = Secret( + id: UUID(), + name: "GitHub Personal Token", + secretType: .apiKeyToken, + service: "GitHub", + environment: "production", + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) +) + +#Preview("SecretDetail · Viewing") { + SecretDetailView( + store: Store( + initialState: SecretDetailFeature.State(secret: _previewSecret) + ) { + SecretDetailFeature() + } + ) + .frame(width: 420, height: 700) +} + +#Preview("SecretDetail · Editing") { + SecretDetailView( + store: Store( + initialState: { + var state = SecretDetailFeature.State(secret: _previewSecret) + state.mode = .editing + return state + }() + ) { + SecretDetailFeature() + } + ) + .frame(width: 420, height: 700) +} + +#endif diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift new file mode 100644 index 00000000..acd6c77c --- /dev/null +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -0,0 +1,134 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVDomain +import Foundation +import Testing + +@testable import DVPresentation + +@Suite("SecretDetailFeature") +@MainActor +struct SecretDetailFeatureTests { + + // MARK: - Helpers + + private static func makeSecret( + name: String = "Test Secret", + secretType: SecretType = .apiKeyToken + ) -> Secret { + Secret( + id: UUID(), + name: name, + secretType: secretType, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + } + + // MARK: - Viewing 진입 + + @Test("초기 mode는 .viewing이다") + func initialModeIsViewing() async { + let secret = Self.makeSecret() + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } + + store.assert { state in + #expect(state.mode == .viewing) + #expect(state.editFields == nil) + } + } + + @Test("task 성공: projects + payload 모두 로드됨") + func task_success() async { + let secret = Self.makeSecret() + let projects = [ + Project(id: UUID(), name: "Backend", createdAt: Date(), updatedAt: Date()), + ] + let payload = CreateSecretPayload.apiKey(APIKeyPayload(value: "test_token"), nil) + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { projects } + $0.secretClient.revealPayload = { _ in payload } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + // projects, payload 응답 순서는 비결정적이므로 둘 다 receive + await store.receive(.projectsResponse(.success(projects))) { + $0.isLoadingProjects = false + $0.availableProjects = projects + } + await store.receive(.payloadResponse(.success(payload))) { + $0.payloadState = .loaded(payload) + } + } + + @Test("task payload 실패: payloadState .failed + alert 노출") + func task_payloadFailure() async { + let secret = Self.makeSecret() + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { [] } + $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)))) { + $0.payloadState = .failed(.cryptoFailure(.decryptionFailed)) + $0.alert = .payloadRevealFailed(.decryptionFailed) + } + } + + @Test("task payload 인증 실패: authRequired alert 노출") + func task_payloadAuthFailure() async { + let secret = Self.makeSecret() + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.projectClient.fetchProjects = { [] } + $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } + } + + await store.send(.task) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)))) { + $0.payloadState = .failed(.authenticationFailure(.cancelled)) + $0.alert = .payloadRevealFailed(.authRequired) + } + } + + // MARK: - Close delegate + + @Test("didTapClose는 delegate(.closed)를 emit한다") + func didTapClose_emitsClosedDelegate() async { + let secret = Self.makeSecret() + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } + + await store.send(.didTapClose) + await store.receive(.delegate(.closed)) + } +} From 5f87a5d8f4dc50086a4f7ae19d0726aacbc94ef0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:16:45 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[#74]=20feat:=20MainFeature=20SecretDetail?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 7 +- .../Sources/Features/Main/MainFeature.swift | 25 ++++++ .../Sources/Features/Main/MainView.swift | 14 +++- .../SecretDetail/SecretDetailFeature.swift | 1 - .../SecretDetail/SecretDetailView.swift | 5 +- .../Tests/Main/MainFeatureTests.swift | 82 +++++++++++++++++++ 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 913bfa52..0dab7300 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -9,9 +9,6 @@ }, "De" : { - }, - "Detail" : { - }, "Failed to load" : { "comment" : "A message displayed when loading projects fails.", @@ -45,6 +42,10 @@ }, "Vault" : { + }, + "선택한 Secret이 없습니다." : { + "comment" : "A message displayed when a secret is not selected.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 57b4b35d..6b5a2d30 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -20,6 +20,8 @@ public struct MainFeature { @Presents var createProject: CreateProjectFeature.State? /// sheet가 아닌 2-column NavigationSplitView 전환 용도이므로 @Presents 미사용 var createSecret: CreateSecretFeature.State? + /// sheet가 아닌 3-column NavigationSplitView detail 컬럼 용도이므로 @Presents 미사용 + var secretDetail: SecretDetailFeature.State? public init() {} } @@ -40,6 +42,7 @@ public struct MainFeature { case selectSecretType(SelectSecretTypeFeature.Action) case createProject(PresentationAction) case createSecret(CreateSecretFeature.Action) + case secretDetail(SecretDetailFeature.Action) // MARK: - Delegate @@ -76,9 +79,28 @@ public struct MainFeature { case .sidebar: return .none + case .secretList(.delegate(.secretSelected(let id))): + if let id, case .loaded(let secrets) = state.secretList.secretsState, let secret = secrets[id: id] { + state.secretDetail = SecretDetailFeature.State(secret: secret) + } else { + state.secretDetail = nil + } + return .none + case .secretList: return .none + case .secretDetail(.delegate(.closed)): + state.secretDetail = nil + state.secretList.selectedSecretID = nil + return .none + + case .secretDetail(.delegate(.secretUpdated)): + return .none + + case .secretDetail: + return .none + case .selectSecretType(.delegate(.typeSelected(let secretType))): state.createSecret = CreateSecretFeature.State(secretType: secretType) return .none @@ -123,6 +145,9 @@ public struct MainFeature { .ifLet(\.createSecret, action: \.createSecret) { CreateSecretFeature() } + .ifLet(\.secretDetail, action: \.secretDetail) { + SecretDetailFeature() + } } } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index bfebf1c3..82946505 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -77,8 +77,18 @@ extension MainView { } private var detailColumn: some View { - Text("Detail") - .navigationTitle("") + Group { + if let detailStore = store.scope(state: \.secretDetail, action: \.secretDetail) { + SecretDetailView(store: detailStore) + } else { + Text("선택한 Secret이 없습니다.") + .dvFont(.captionLG) + .foregroundStyle(Color.dv(.gray700)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("") + .navigationSplitViewColumnWidth(min: 420, ideal: 480) } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 7cdcf197..5d7022af 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -131,7 +131,6 @@ public struct SecretDetailFeature { state.alert = .payloadRevealFailed(SecretDetailError.map(error)) return .none - // Issue C에서 구현 case .didTapEdit, .didTapCancelEdit, .didTapSave: return .none diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index b23ee6d9..00e84cb3 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -32,7 +32,6 @@ public struct SecretDetailView: View { } } if store.mode == .editing { - // Issue C에서 FooterActionsView 연결 Divider() HStack { Spacer() @@ -75,7 +74,7 @@ extension SecretDetailView { } } - // MARK: Viewing — 인터랙티브 컨트롤 완전 배제 (Issue B에서 완성) + // MARK: Viewing @ViewBuilder private var viewingBody: some View { @@ -90,7 +89,7 @@ extension SecretDetailView { } } - // MARK: Editing — CreateSecret SectionView 재사용 (Issue C에서 완성) + // MARK: Editing @ViewBuilder private var editingBody: some View { diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index a4f86b78..cd7363ca 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -1,6 +1,7 @@ // Copyright © 2026 Devault. All rights reserved import ComposableArchitecture +import DVDomain import Foundation import Testing @@ -195,6 +196,87 @@ struct MainFeatureTests { } } + // MARK: - SecretDetail Routing + + @Test("secretSelected(id)는 해당 Secret으로 secretDetail을 세팅한다") + func secretSelectedSetsSecretDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.secretsState = .loaded([secret]) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretList(.didSelectSecret(id: secret.id))) { + $0.secretList.selectedSecretID = secret.id + } + await store.receive(.secretList(.delegate(.secretSelected(secret.id)))) { + $0.secretDetail = SecretDetailFeature.State(secret: secret) + } + } + + @Test("secretSelected(nil)은 secretDetail을 닫는다") + func secretSelectedNilClearsSecretDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.secretsState = .loaded([secret]) + initial.secretList.selectedSecretID = secret.id + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretList(.didSelectSecret(id: nil))) { + $0.secretList.selectedSecretID = nil + } + await store.receive(.secretList(.delegate(.secretSelected(nil)))) { + $0.secretDetail = nil + } + } + + @Test("secretDetail closed delegate는 secretDetail과 selectedSecretID를 초기화한다") + func secretDetailClosedClearsDetail() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.secretList.selectedSecretID = secret.id + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.secretDetail(.delegate(.closed))) { + $0.secretDetail = nil + $0.secretList.selectedSecretID = nil + } + } + // MARK: - Rename Delegate @Test("projectRenamed는 현재 선택된 프로젝트의 이름이면 secretList 타이틀을 갱신한다") From ff4796e0f1db0ea01a84886530c35502fa844dd3 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:38:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[#74]=20fix:=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20secretDetail=20?= =?UTF-8?q?=EB=AF=B8=EC=A0=95=EB=A6=AC=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사이드바 selection 변경 시 state.secretDetail을 nil로 초기화 (이전 Secret의 detail이 잔류하는 버그) - dispatchRevealPayload의 default에 assertionFailure 추가로 무효 타입 조합 조기 감지 - task_success 테스트 주석: mock 동기 반환으로 receive 순서가 결정적임을 명시 --- Projects/DVPresentation/Sources/Features/Main/MainFeature.swift | 1 + .../Tests/SecretDetail/SecretDetailFeatureTests.swift | 2 +- .../Sources/Composition/Dependencies/SecretClient+Live.swift | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 6b5a2d30..bb5ec443 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -163,6 +163,7 @@ extension MainFeature { case .selectionChanged(let selection): state.selectSecretType = nil state.createSecret = nil + state.secretDetail = nil state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) return .send(.sidebar(.setCreatingSecret(false))) diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index acd6c77c..7695358a 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -61,7 +61,7 @@ struct SecretDetailFeatureTests { $0.isLoadingProjects = true $0.payloadState = .loading } - // projects, payload 응답 순서는 비결정적이므로 둘 다 receive + // mock이 동기 반환이므로 .merge 선언 순서대로 수신됨 (projects → payload) await store.receive(.projectsResponse(.success(projects))) { $0.isLoadingProjects = false $0.availableProjects = projects diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index dd6a98f7..4e4ab913 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -104,6 +104,7 @@ private func dispatchRevealPayload( let p = try await useCase.revealPayload(id: secret.id, as: CustomPayload.self) return .custom(p) default: + assertionFailure("Unexpected (secretType, subType) combination: \(secret.secretType), \(String(describing: secret.subType))") throw SecretUseCaseError.unexpected } } From 386250f8339a306a22d54bd6002e1cc0635e945d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:29:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[#74]=20fix:=20SecretDetail=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecretDetailView에 alert modifier를 연결해 payload 복호화/인증 실패가 사용자에게 노출되도록 수정 - detail 컬럼 안내 문구를 영어 source key로 교체하고 module 번들 조회 적용 - 상태 전이가 없어 동작하지 않던 Edit 버튼 숨김 - SecretDetail 신규 파일 import 순서를 프로젝트 컨벤션에 맞춰 정리 - dummyClient subType 분기와 payload 렌더링 미구현 지점에 후속 작업 주석 추가 --- .../Resources/Localizable.xcstrings | 8 ++++---- .../Sources/Dependencies/SecretClient.swift | 5 +++++ .../Sources/Features/Main/MainView.swift | 2 +- .../SecretDetail/Model/SecretDetailError.swift | 3 ++- .../SecretDetail/SecretDetailFeature.swift | 5 ++++- .../SecretDetail/SecretDetailView.swift | 18 ++++++++++-------- .../SecretDetailFeatureTests.swift | 5 +++-- 7 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 0dab7300..62dc6fda 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -23,6 +23,10 @@ }, "LOGO" : { + }, + "No secret selected" : { + "comment" : "A message displayed when a secret is not selected.", + "isCommentAutoGenerated" : true }, "No secrets" : { "comment" : "A message displayed when there are no secrets.", @@ -42,10 +46,6 @@ }, "Vault" : { - }, - "선택한 Secret이 없습니다." : { - "comment" : "A message displayed when a secret is not selected.", - "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 2a5146db..ef110fe5 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -56,6 +56,11 @@ private extension SecretClient { softDelete: { _ in .preview }, restore: { _ in .preview }, permanentlyDelete: { _ in }, + // secretType만 분기하므로 live의 dispatchRevealPayload와 달리 subType별 case를 + // 구분하지 않는다(.serviceAccount / .sslTlsCertificate / .custom / .accessToken / + // .webhookSecret). Preview·Test에서 payload 필드 구성이 실제 동작과 어긋나므로, + // SecretDetailView가 payload를 실제로 렌더링하는 후속 이슈에서 + // (secretType, subType) 쌍 기준으로 맞춘다. revealPayload: { secret in switch secret.secretType { case .apiKeyToken: diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 82946505..40e3597e 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -81,7 +81,7 @@ extension MainView { if let detailStore = store.scope(state: \.secretDetail, action: \.secretDetail) { SecretDetailView(store: detailStore) } else { - Text("선택한 Secret이 없습니다.") + Text(.module("No secret selected")) .dvFont(.captionLG) .foregroundStyle(Color.dv(.gray700)) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift index db03441a..55fec325 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift @@ -1,8 +1,9 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation + import ComposableArchitecture import DVDomain -import Foundation // MARK: - SecretDetailError diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 5d7022af..82dcf44f 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -1,8 +1,9 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation + import ComposableArchitecture import DVDomain -import Foundation // MARK: - SecretDetailFeature @@ -131,6 +132,8 @@ public struct SecretDetailFeature { state.alert = .payloadRevealFailed(SecretDetailError.map(error)) return .none + // 편집 모드는 후속 이슈 범위다. 상태 전이가 없으므로 SecretDetailView는 + // Edit / Cancel / Save 컨트롤을 노출하지 않으며, 여기서는 액션 case만 예약해 둔다. case .didTapEdit, .didTapCancelEdit, .didTapSave: return .none diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index 00e84cb3..b1eb2b05 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -1,9 +1,10 @@ // Copyright © 2026 Devault. All rights reserved +import SwiftUI + import ComposableArchitecture import DVDesign import DVDomain -import SwiftUI // MARK: - SecretDetailView @@ -44,6 +45,7 @@ public struct SecretDetailView: View { } } .task { store.send(.task) } + .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -60,13 +62,8 @@ extension SecretDetailView { } .buttonStyle(.plain) Spacer() - if store.mode == .viewing { - Button { store.send(.didTapEdit) } label: { - Image(systemName: "pencil") - .imageScale(.medium) - } - .buttonStyle(.plain) - } + // 편집 모드는 후속 이슈에서 구현한다. 상태 전이가 없는 동안 + // 눌러도 반응하지 않는 컨트롤을 노출하지 않기 위해 Edit 버튼을 숨긴다. } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -76,6 +73,9 @@ extension SecretDetailView { // MARK: Viewing + /// 현재는 name만 노출하는 뼈대다. 후속 이슈에서 `store.payloadState`의 + /// loading / loaded / failed를 분기하고, loaded에서 `CreateSecretPayload` + /// 유형별 필드와 `store.secret` 메타 정보를 렌더링한다. @ViewBuilder private var viewingBody: some View { ScrollView { @@ -91,6 +91,8 @@ extension SecretDetailView { // MARK: Editing + /// 편집 모드 진입 경로가 아직 없어 도달하지 않는다. 후속 이슈에서 + /// `editFields` 바인딩과 SectionView 재사용으로 채운다. @ViewBuilder private var editingBody: some View { ScrollView { diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index 7695358a..6ccf9a1d 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -1,10 +1,11 @@ // Copyright © 2026 Devault. All rights reserved -import ComposableArchitecture -import DVDomain import Foundation import Testing +import ComposableArchitecture +import DVDomain + @testable import DVPresentation @Suite("SecretDetailFeature")