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
7 changes: 4 additions & 3 deletions Projects/DVPresentation/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
},
"De" : {

},
"Detail" : {

},
"Failed to load" : {
"comment" : "A message displayed when loading projects fails.",
Expand All @@ -26,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.",
Expand Down
35 changes: 34 additions & 1 deletion Projects/DVPresentation/Sources/Dependencies/SecretClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -53,9 +56,39 @@ 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:
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)
}
Comment thread
doyeonk429 marked this conversation as resolved.
},
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 }
)
Expand Down
26 changes: 26 additions & 0 deletions Projects/DVPresentation/Sources/Features/Main/MainFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}
Expand All @@ -40,6 +42,7 @@ public struct MainFeature {
case selectSecretType(SelectSecretTypeFeature.Action)
case createProject(PresentationAction<CreateProjectFeature.Action>)
case createSecret(CreateSecretFeature.Action)
case secretDetail(SecretDetailFeature.Action)

// MARK: - Delegate

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +145,9 @@ public struct MainFeature {
.ifLet(\.createSecret, action: \.createSecret) {
CreateSecretFeature()
}
.ifLet(\.secretDetail, action: \.secretDetail) {
SecretDetailFeature()
}
}
}

Expand All @@ -138,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)))

Expand Down
14 changes: 12 additions & 2 deletions Projects/DVPresentation/Sources/Features/Main/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(.module("No secret selected"))
.dvFont(.captionLG)
.foregroundStyle(Color.dv(.gray700))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle("")
.navigationSplitViewColumnWidth(min: 420, ideal: 480)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation

import ComposableArchitecture
import DVDomain

// 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
)
}
}
}
Loading