ios_client
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AuthorizeDeviceView: View {
|
||||
var appState: AppState
|
||||
@State private var code = ""
|
||||
@State private var isAuthorizing = false
|
||||
@State private var statusMessage: String?
|
||||
@State private var isError = false
|
||||
@State private var isDone = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.badge.checkmark")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Authorize New Device")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter the 8-digit pairing code shown on the new device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Pairing Code", text: $code)
|
||||
.font(.system(size: 24, weight: .bold, design: .monospaced))
|
||||
.multilineTextAlignment(.center)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Authorize") {
|
||||
Task { await authorize() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(code.count < 8 || isAuthorizing || isDone)
|
||||
|
||||
if isAuthorizing {
|
||||
ProgressView("Preparing history & sending keys...")
|
||||
}
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if isDone {
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.navigationTitle("Authorize Device")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authorize() async {
|
||||
isAuthorizing = true
|
||||
isError = false
|
||||
statusMessage = nil
|
||||
|
||||
let (success, msg) = await appState.chatClient.authorizeDevice(code: code)
|
||||
isAuthorizing = false
|
||||
|
||||
statusMessage = msg
|
||||
isError = !success
|
||||
if success {
|
||||
isDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
@State private var showPairing = false
|
||||
@State private var didAttemptBiometric = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top, 40)
|
||||
|
||||
Text("Encrypted Chat")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("End-to-end encrypted messaging")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Server config
|
||||
DisclosureGroup("Server") {
|
||||
TextField("Host", text: $viewModel.serverHost)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
TextField("Port", text: $viewModel.serverPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
TextField("Username", text: $viewModel.username)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
TextField("Email", text: $viewModel.email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
.textContentType(viewModel.mode == .login ? .password : .oneTimeCode)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
SecureField("Confirm Password", text: $viewModel.confirmPassword)
|
||||
.textContentType(.oneTimeCode)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
if viewModel.mode == .login {
|
||||
await viewModel.login(appState: appState)
|
||||
} else {
|
||||
await viewModel.register(appState: appState)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(viewModel.mode == .login ? "Login" : "Register")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
|
||||
viewModel.mode = viewModel.mode == .login ? .register : .login
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
if viewModel.hasSavedCredentials && viewModel.mode == .login {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.biometricLogin(appState: appState) }
|
||||
} label: {
|
||||
if viewModel.isBiometricLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Sign in with Face ID", systemImage: "faceid")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.isLoading || viewModel.isBiometricLoading)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button("Pair from existing device") {
|
||||
showPairing = true
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.checkSavedCredentials()
|
||||
if viewModel.hasSavedCredentials && !didAttemptBiometric {
|
||||
didAttemptBiometric = true
|
||||
await viewModel.biometricLogin(appState: appState)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showConfirmation) {
|
||||
ConfirmationSheet(viewModel: viewModel, appState: appState)
|
||||
}
|
||||
.sheet(isPresented: $showPairing) {
|
||||
PairingView(appState: appState, authViewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Confirm Registration")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let msg = viewModel.registrationMessage {
|
||||
Text(msg)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
TextField("Confirmation Code", text: $viewModel.confirmationCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Button("Confirm") {
|
||||
Task {
|
||||
await viewModel.confirmRegistration(appState: appState)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PairingView: View {
|
||||
var appState: AppState
|
||||
@Bindable var authViewModel: AuthViewModel
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var pairingCode: String?
|
||||
@State private var isStarting = false
|
||||
@State private var isWaiting = false
|
||||
@State private var statusMessage: String?
|
||||
@State private var isError = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Device Pairing")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Transfer your keys from an existing device to this one.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if pairingCode == nil {
|
||||
// Phase 1: Enter email and start pairing
|
||||
VStack(spacing: 16) {
|
||||
// Server config
|
||||
DisclosureGroup("Server") {
|
||||
TextField("Host", text: $authViewModel.serverHost)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
TextField("Port", text: $authViewModel.serverPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password (for key encryption)", text: $password)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Start Pairing") {
|
||||
Task { await startPairing() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(email.isEmpty || password.isEmpty || isStarting)
|
||||
|
||||
if isStarting {
|
||||
ProgressView("Connecting...")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Phase 2: Show code and wait for authorization
|
||||
VStack(spacing: 16) {
|
||||
Text("Pairing Code")
|
||||
.font(.headline)
|
||||
|
||||
Text(pairingCode!)
|
||||
.font(.system(size: 36, weight: .bold, design: .monospaced))
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text("Enter this code on your already logged-in device\nto authorize this device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isWaiting {
|
||||
ProgressView("Waiting for authorization...")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.navigationTitle("Pair Device")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPairing() async {
|
||||
isStarting = true
|
||||
isError = false
|
||||
statusMessage = nil
|
||||
|
||||
// Connect to server
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(
|
||||
host: authViewModel.serverHost, port: port
|
||||
)
|
||||
} catch {
|
||||
isStarting = false
|
||||
statusMessage = "Connection failed: \(error.localizedDescription)"
|
||||
isError = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email)
|
||||
isStarting = false
|
||||
|
||||
if success {
|
||||
pairingCode = codeOrMsg
|
||||
// Start waiting for authorization
|
||||
isWaiting = true
|
||||
Task { await waitForAuthorization() }
|
||||
} else {
|
||||
statusMessage = codeOrMsg
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForAuthorization() async {
|
||||
let (success, msg) = await appState.chatClient.pairingWait(
|
||||
code: pairingCode!, email: email, password: password
|
||||
)
|
||||
isWaiting = false
|
||||
|
||||
if success {
|
||||
statusMessage = msg
|
||||
isError = false
|
||||
// Auto-login
|
||||
let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password)
|
||||
if loginOk {
|
||||
appState.email = email
|
||||
appState.isLoggedIn = true
|
||||
appState.connectionStatus = .connected
|
||||
appState.startConnectionMonitor()
|
||||
if let userId = await appState.chatClient.userId {
|
||||
appState.currentUser = User(
|
||||
id: userId,
|
||||
username: await appState.chatClient.username,
|
||||
email: email
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} else {
|
||||
statusMessage = "Keys imported but login failed: \(loginMsg)"
|
||||
isError = true
|
||||
}
|
||||
} else {
|
||||
statusMessage = msg
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
Reference in New Issue
Block a user