f1dc2e1a8d
- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리 (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP 개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후 다운로드 ?token= 사본 재동기화(guarded 깔때기) - LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출) - RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화) - DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 / DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지) - LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용 - AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp) 검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브 negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달). 3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰 사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
3.7 KiB
Swift
98 lines
3.7 KiB
Swift
import SwiftUI
|
|
import DSKit
|
|
|
|
/// 본 서버(GPU DS) 정식 로그인 — 기능 셸 (페이지 매력 시안은 FU-E 별도 트랙).
|
|
/// refresh 쿠키 부재/만료 시에만 노출; 성공하면 HttpOnly refresh 쿠키가 다음 실행 복귀를 담당해
|
|
/// 이 화면 없이 곧장 셸로 들어간다. TOTP 는 계정에 설정된 경우에만 필요(선택 입력).
|
|
public struct LoginView: View {
|
|
@Environment(AppModel.self) private var model
|
|
@State private var username = ""
|
|
@State private var password = ""
|
|
@State private var totp = ""
|
|
@State private var submitting = false
|
|
@FocusState private var focus: Field?
|
|
private enum Field { case username, password, totp }
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Document Server")
|
|
.font(.title2.weight(.semibold))
|
|
.foregroundStyle(Sage.ink)
|
|
Text(serverHost)
|
|
.font(.caption)
|
|
.foregroundStyle(Sage.muted)
|
|
}
|
|
VStack(spacing: 10) {
|
|
TextField("아이디", text: $username)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focus, equals: .username)
|
|
.onSubmit { focus = .password }
|
|
SecureField("비밀번호", text: $password)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focus, equals: .password)
|
|
.onSubmit { submit() }
|
|
TextField("2FA 코드 (설정한 경우)", text: $totp)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focus, equals: .totp)
|
|
.onSubmit { submit() }
|
|
}
|
|
if let error = model.loginError {
|
|
Text(error)
|
|
.font(.callout)
|
|
.foregroundStyle(Sage.danger)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Button(action: submit) {
|
|
Group {
|
|
if submitting {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text("로그인")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(Sage.brand)
|
|
.disabled(submitting || username.isEmpty || password.isEmpty)
|
|
}
|
|
.padding(28)
|
|
.frame(width: 360)
|
|
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Sage.surface)
|
|
.onAppear { focus = .username }
|
|
}
|
|
|
|
/// 연결 대상 표시 — base 의 host (예: document.hyungi.net / 100.110.63.63).
|
|
private var serverHost: String {
|
|
model.base.url.host() ?? model.base.url.absoluteString
|
|
}
|
|
|
|
private func submit() {
|
|
guard !submitting, !username.isEmpty, !password.isEmpty else { return }
|
|
submitting = true
|
|
Task {
|
|
await model.login(username: username, password: password, totp: totp)
|
|
submitting = false
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
#Preview("로그인") {
|
|
@Previewable @State var model = AppModel.preview
|
|
LoginView()
|
|
.environment(model)
|
|
.frame(width: 700, height: 500)
|
|
}
|
|
#endif
|