diff --git a/app/network/Authenticate/CreateNetwork/CreateNetworkView.swift b/app/network/Authenticate/CreateNetwork/CreateNetworkView.swift index f3091c10..3ecc5ad4 100644 --- a/app/network/Authenticate/CreateNetwork/CreateNetworkView.swift +++ b/app/network/Authenticate/CreateNetwork/CreateNetworkView.swift @@ -188,16 +188,7 @@ struct CreateNetworkView: View { #endif Task { - let result = deviceManager.device != nil - // device exists - upgrade guest network - ? await viewModel.upgradeGuestNetwork( - userAuth: userAuth, - authJwt: authLoginArgs.authJwt, - authType: authLoginArgs.authJwtType, - walletAuth: authLoginArgs.walletAuth - ) - // no device exists - create a new network - : await viewModel.createNetwork( + let result = await viewModel.createNetwork( userAuth: userAuth, authJwt: authLoginArgs.authJwt, authType: authLoginArgs.authJwtType, diff --git a/app/network/Authenticate/CreateNetwork/CreateNetworkViewModel.swift b/app/network/Authenticate/CreateNetwork/CreateNetworkViewModel.swift index d133425e..a77bd0ea 100644 --- a/app/network/Authenticate/CreateNetwork/CreateNetworkViewModel.swift +++ b/app/network/Authenticate/CreateNetwork/CreateNetworkViewModel.swift @@ -247,58 +247,6 @@ extension CreateNetworkView { } - func upgradeGuestNetwork( - userAuth: String?, - authJwt: String?, - authType: String?, - walletAuth: SdkWalletAuthArgs? - ) async -> LoginNetworkResult { - - if !formIsValid { - return .failure(NSError(domain: domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Create network form is invalid"])) - } - - if isCreatingNetwork { - return .failure(NSError(domain: domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Network creation already in progress"])) - } - - - self.createNetworkErrorMessage = nil - self.isCreatingNetwork = true - - do { - - let args = SdkUpgradeGuestArgs() - args.networkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines) - - if let userAuth = userAuth { - args.userAuth = userAuth - args.password = password - } - - if let authJwt, let authType { - args.authJwt = authJwt - args.authJwtType = authType - } - - if let walletAuth { - args.walletAuth = walletAuth - } - - let result = try await urApiService.upgradeGuest(args) - - self.isCreatingNetwork = false - - return result - - } catch { - self.isCreatingNetwork = false - return .failure(error) - } - - - } - func createNetwork( userAuth: String?, authJwt: String?, diff --git a/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantView.swift b/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantView.swift new file mode 100644 index 00000000..840ceeb2 --- /dev/null +++ b/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantView.swift @@ -0,0 +1,133 @@ +// +// CreateNetworkInstantView.swift +// URnetwork +// + +import SwiftUI +import URnetworkSdk + +private struct InstantAccountResult: Identifiable { + let id = UUID() + let jwt: String + let seedphrase: String +} + +struct CreateNetworkInstantView: View { + + @EnvironmentObject var themeManager: ThemeManager + + @StateObject private var viewModel: ViewModel + + let handleSuccess: (_ jwt: String) async -> Void + let back: () -> Void + + @State private var accountResult: InstantAccountResult? = nil + + init( + urApiService: UrApiServiceProtocol, + handleSuccess: @escaping (_ jwt: String) async -> Void, + back: @escaping () -> Void + ) { + _viewModel = StateObject(wrappedValue: ViewModel(urApiService: urApiService)) + self.handleSuccess = handleSuccess + self.back = back + } + + var body: some View { + ScrollView { + VStack(alignment: .center) { + + Text("Create Instant Account") + .foregroundColor(.urWhite) + .font(themeManager.currentTheme.titleFont) + + Spacer().frame(height: 16) + + Text("No email, phone, or password needed. Your account is secured by a seedphrase.") + .foregroundColor(themeManager.currentTheme.textMutedColor) + .font(themeManager.currentTheme.bodyFont) + .multilineTextAlignment(.center) + + Spacer().frame(height: 48) + + UrSwitchToggle(isOn: $viewModel.termsAgreed, isEnabled: !viewModel.isCreatingAccount) { + Text("I agree to URnetwork's [Terms and Services](https://ur.io/terms) and [Privacy Policy](https://ur.io/privacy)") + .foregroundColor(themeManager.currentTheme.textMutedColor) + .font(themeManager.currentTheme.secondaryBodyFont) + } + + Spacer().frame(height: 32) + + UrButton( + text: "Create Account", + action: { + Task { + let result = await viewModel.createInstantAccount() + if let (jwt, seedphrase) = result { + accountResult = InstantAccountResult(jwt: jwt, seedphrase: seedphrase) + } + } + }, + enabled: viewModel.formIsValid, + isProcessing: viewModel.isCreatingAccount + ) + + Spacer().frame(height: 8) + + UrInlineErrorText(message: viewModel.errorMessage) + + } + .padding() + .frame(maxWidth: .infinity) + .frame(maxWidth: 400) + } + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { back() }) { + Image(systemName: "chevron.left") + .foregroundColor(themeManager.currentTheme.textColor) + } + } + #elseif os(macOS) + ToolbarItem { + Button(action: { back() }) { + Image(systemName: "chevron.left") + .foregroundColor(themeManager.currentTheme.textColor) + } + } + #endif + } + #if os(iOS) + .navigationBarBackButtonHidden(true) + .fullScreenCover(item: $accountResult) { result in + SeedphraseDisplayView( + seedphrase: result.seedphrase, + onConfirmed: { _ in + accountResult = nil + Task { + await handleSuccess(result.jwt) + } + } + ) + .environmentObject(themeManager) + } + #elseif os(macOS) + .sheet(item: $accountResult) { result in + SeedphraseDisplayView( + seedphrase: result.seedphrase, + onConfirmed: { _ in + accountResult = nil + Task { + await handleSuccess(result.jwt) + } + } + ) + .environmentObject(themeManager) + .interactiveDismissDisabled(true) + } + #endif + } + +} diff --git a/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantViewModel.swift b/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantViewModel.swift new file mode 100644 index 00000000..7243fdf6 --- /dev/null +++ b/app/network/Authenticate/CreateNetworkInstant/CreateNetworkInstantViewModel.swift @@ -0,0 +1,73 @@ +// +// CreateNetworkInstantViewModel.swift +// URnetwork +// + +import Foundation +import URnetworkSdk + +extension CreateNetworkInstantView { + + @MainActor + class ViewModel: ObservableObject { + + private let urApiService: UrApiServiceProtocol + + @Published var termsAgreed: Bool = false { + didSet { + errorMessage = nil + validateForm() + } + } + + @Published private(set) var isCreatingAccount: Bool = false + + @Published private(set) var formIsValid: Bool = false + + @Published private(set) var errorMessage: String? + + let domain = "CreateNetworkInstantViewModel" + + init(urApiService: UrApiServiceProtocol) { + self.urApiService = urApiService + } + + func setErrorMessage(_ message: String?) { + errorMessage = message + } + + private func validateForm() { + formIsValid = termsAgreed && !isCreatingAccount + } + + func createInstantAccount() async -> (jwt: String, seedphrase: String)? { + + if isCreatingAccount { + return nil + } + + guard termsAgreed else { + errorMessage = "You must agree to the Terms and Privacy Policy" + return nil + } + + isCreatingAccount = true + errorMessage = nil + + defer { + isCreatingAccount = false + } + + do { + let result = try await urApiService.createInstantAccount() + return result + } catch { + errorMessage = "There was an error creating your account. Please try again." + return nil + } + + } + + } + +} diff --git a/app/network/Authenticate/CreateNetworkInstant/SeedphraseDisplayView.swift b/app/network/Authenticate/CreateNetworkInstant/SeedphraseDisplayView.swift new file mode 100644 index 00000000..90e503d3 --- /dev/null +++ b/app/network/Authenticate/CreateNetworkInstant/SeedphraseDisplayView.swift @@ -0,0 +1,162 @@ +// +// SeedphraseDisplayView.swift +// URnetwork +// + +import SwiftUI + +private struct SeedWord: Identifiable { + let id: Int + let word: String +} + +struct SeedphraseDisplayView: View { + + @EnvironmentObject var themeManager: ThemeManager + + let seedphrase: String + let onConfirmed: (_ jwt: String) -> Void + + @State private var hasCopied = false + + private let words: [SeedWord] + + init(seedphrase: String, onConfirmed: @escaping (String) -> Void) { + // Normalize: trim whitespace and collapse multiple spaces + let normalized = seedphrase + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + self.seedphrase = normalized + self.onConfirmed = onConfirmed + let tokens = normalized.components(separatedBy: " ") + self.words = tokens.enumerated().map { SeedWord(id: $0.offset, word: $0.element) } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .center) { + + titleView + warningView + instructionsView + + Spacer().frame(height: 32) + + wordGridView + + Spacer().frame(height: 24) + + copyButton + confirmationButton + + } + .padding() + .frame(maxWidth: .infinity) + .frame(maxWidth: 400) + } + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + .interactiveDismissDisabled(true) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Secure Your Account") + .font(themeManager.currentTheme.toolbarTitleFont).fontWeight(.bold) + } + } + } + } + + private var titleView: some View { + Text("Your Seedphrase") + .foregroundColor(.urWhite) + .font(themeManager.currentTheme.titleFont) + } + + private var warningView: some View { + Text("⚠️ This is the ONLY time you'll see this.") + .foregroundColor(.urYellow) + .font(themeManager.currentTheme.bodyFont.bold()) + .multilineTextAlignment(.center) + } + + private var instructionsView: some View { + Text("Write it down and store it somewhere safe. If you lose it, you'll lose access to your account.") + .foregroundColor(themeManager.currentTheme.textMutedColor) + .font(themeManager.currentTheme.secondaryBodyFont) + .multilineTextAlignment(.center) + } + + private var wordGridView: some View { + let columns = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)] + return LazyVGrid(columns: columns, spacing: 8) { + ForEach(words) { seedWord in + wordRowView(index: seedWord.id, word: seedWord.word) + } + } + .padding(12) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(themeManager.currentTheme.textMutedColor.opacity(0.3), lineWidth: 1) + ) + } + + private func wordRowView(index: Int, word: String) -> some View { + HStack(spacing: 4) { + Text(String(index + 1) + ".") + .foregroundColor(themeManager.currentTheme.textMutedColor) + .font(.system(.caption, design: .monospaced)) + .frame(width: 24, alignment: .trailing) + Text(word) + .foregroundColor(.urWhite) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(themeManager.currentTheme.tintedBackgroundBase) + .cornerRadius(6) + } + + private var copyButton: some View { + UrButton( + text: hasCopied ? "Copied!" : "Copy to Clipboard", + action: { + #if os(iOS) + UIPasteboard.general.string = seedphrase + #elseif os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(seedphrase, forType: .string) + #endif + hasCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + hasCopied = false + } + }, + enabled: !hasCopied + ) + } + + private var confirmationButton: some View { + UrButton( + text: "I've Saved My Seedphrase", + action: { + onConfirmed(seedphrase) + }, + enabled: true, + isProcessing: false + ) + .tint(.urGreen) + } + +} + +#Preview { + SeedphraseDisplayView( + seedphrase: "abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor active activity", + onConfirmed: { _ in } + ) + .environmentObject(ThemeManager.shared) +} diff --git a/app/network/Authenticate/LoginInitial/GuestModeSheet.swift b/app/network/Authenticate/LoginInitial/GuestModeSheet.swift deleted file mode 100644 index e6179bfe..00000000 --- a/app/network/Authenticate/LoginInitial/GuestModeSheet.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// GuestModeSheet.swift -// URnetwork -// -// Created by Stuart Kuentzel on 2025/01/10. -// - -import SwiftUI - -struct GuestModeSheet: View { - - @Binding var termsAgreed: Bool - let isCreatingGuestNetwork: Bool - let errorMessage: String? - let onCreateGuestNetwork: () -> Void - - - @EnvironmentObject var themeManager: ThemeManager - - var body: some View { - VStack { - - HStack { - - Text("Try guest mode") - .font(themeManager.currentTheme.secondaryTitleFont) - - Spacer() - - } - - Spacer().frame(height: 24) - - UrSwitchToggle(isOn: $termsAgreed, isEnabled: !isCreatingGuestNetwork) { - Text("I agree to URnetwork's [Terms and Services](https://ur.io/terms) and [Privacy Policy](https://ur.io/privacy)") - .foregroundColor(themeManager.currentTheme.textMutedColor) - .font(themeManager.currentTheme.secondaryBodyFont) - } - - Spacer().frame(height: 24) - - UrButton( - text: "Enter URnetwork", - action: { - onCreateGuestNetwork() - }, - enabled: termsAgreed && !isCreatingGuestNetwork, - isProcessing: isCreatingGuestNetwork - ) - - Spacer().frame(height: 8) - - UrInlineErrorText(message: errorMessage) - - } - .padding() - } -} - -#Preview { - GuestModeSheet( - termsAgreed: .constant(false), - isCreatingGuestNetwork: false, - errorMessage: nil, - onCreateGuestNetwork: {} - ) -} diff --git a/app/network/Authenticate/LoginInitial/LoginInitialView.swift b/app/network/Authenticate/LoginInitial/LoginInitialView.swift index f4e12d2f..8dbc08fa 100644 --- a/app/network/Authenticate/LoginInitial/LoginInitialView.swift +++ b/app/network/Authenticate/LoginInitial/LoginInitialView.swift @@ -20,8 +20,6 @@ struct LoginInitialView: View { @StateObject private var viewModel: ViewModel @State private var initialIsLandscape: Bool = false - @ObservedObject var guestUpgradeViewModel: GuestUpgradeViewModel - let navigate: (LoginInitialNavigationPath) -> Void let cancel: (() -> Void)? let handleSuccess: (_ jwt: String) async -> Void @@ -31,14 +29,12 @@ struct LoginInitialView: View { urApiService: UrApiServiceProtocol, navigate: @escaping (LoginInitialNavigationPath) -> Void, cancel: (() -> Void)? = nil, - handleSuccess: @escaping (_ jwt: String) async -> Void, - guestUpgradeViewModel: GuestUpgradeViewModel + handleSuccess: @escaping (_ jwt: String) async -> Void ) { _viewModel = StateObject(wrappedValue: ViewModel(urApiService: urApiService)) self.navigate = navigate self.cancel = cancel self.handleSuccess = handleSuccess - self.guestUpgradeViewModel = guestUpgradeViewModel self.urApiService = urApiService } @@ -70,7 +66,7 @@ struct LoginInitialView: View { handleGoogleSignInButton: handleGoogleSignInButton, isValidUserAuth: viewModel.isValidUserAuth, activeLoginAction: viewModel.activeLoginAction, - isLoginActionInFlight: viewModel.isLoginActionInFlight || guestUpgradeViewModel.isUpgrading, + isLoginActionInFlight: viewModel.isLoginActionInFlight, loginErrorMessage: viewModel.loginErrorMessage, deviceExists: deviceExists, presentSignInWithSolanaSheet: { @@ -87,7 +83,12 @@ struct LoginInitialView: View { presentAuthCodeLoginSheet: { viewModel.setPresentAuthCodeLoginSheet(true) }, - presentGuestNetworkSheet: $viewModel.presentGuestNetworkSheet, + presentSeedphraseLogin: { + navigate(.seedphrase) + }, + presentCreateInstant: { + navigate(.createInstant) + } ) .frame(width: geometry.size.width / 2, alignment: .leading) @@ -109,7 +110,7 @@ struct LoginInitialView: View { handleGoogleSignInButton: handleGoogleSignInButton, isValidUserAuth: viewModel.isValidUserAuth, activeLoginAction: viewModel.activeLoginAction, - isLoginActionInFlight: viewModel.isLoginActionInFlight || guestUpgradeViewModel.isUpgrading, + isLoginActionInFlight: viewModel.isLoginActionInFlight, loginErrorMessage: viewModel.loginErrorMessage, deviceExists: deviceExists, presentSignInWithSolanaSheet: { @@ -126,7 +127,12 @@ struct LoginInitialView: View { presentAuthCodeLoginSheet: { viewModel.setPresentAuthCodeLoginSheet(true) }, - presentGuestNetworkSheet: $viewModel.presentGuestNetworkSheet + presentSeedphraseLogin: { + navigate(.seedphrase) + }, + presentCreateInstant: { + navigate(.createInstant) + } ) } @@ -156,23 +162,6 @@ struct LoginInitialView: View { .presentationDetents([.height(216)]) #endif - } - .sheet(isPresented: $viewModel.presentGuestNetworkSheet) { - - GuestModeSheet( - termsAgreed: $viewModel.termsAgreed, - isCreatingGuestNetwork: viewModel.isCreatingGuestNetwork, - errorMessage: viewModel.guestNetworkErrorMessage, - onCreateGuestNetwork: { - Task { - let result = await viewModel.createGuestNetwork() - await self.handleCreateGuestNetworkResult(result) - } - } - ) - .environmentObject(themeManager) - .presentationDetents([.height(264)]) - } .sheet(isPresented: $viewModel.presentAuthCodeLoginSheet) { @@ -305,23 +294,10 @@ struct LoginInitialView: View { switch createArgsResult { case .success(let args): - if deviceManager.device != nil { - - let upgradeArgs = self.createUpgradeSolanaWalletArgs(args) - - let result = await guestUpgradeViewModel.linkGuestToExistingLogin(args: upgradeArgs) - - await self.handleAuthLoginResult(result) - viewModel.presentSigninWithSolanaSheet = false - viewModel.setIsSigningMessage(false) - - - } else { - let result = await viewModel.authLogin(args: args) - await self.handleAuthLoginResult(result) - viewModel.presentSigninWithSolanaSheet = false - viewModel.setIsSigningMessage(false) - } + let result = await viewModel.authLogin(args: args) + await self.handleAuthLoginResult(result) + viewModel.presentSigninWithSolanaSheet = false + viewModel.setIsSigningMessage(false) case .failure(let error): print("error create args result: \(error.localizedDescription)") @@ -330,25 +306,6 @@ struct LoginInitialView: View { } } - private func handleCreateGuestNetworkResult(_ result: LoginNetworkResult) async { - switch result { - - case .successWithJwt(let jwt): - viewModel.presentGuestNetworkSheet = false - await handleSuccess(jwt) - break - case .failure(let error): - print("CreateNetworkView: handleResult: \(error.localizedDescription)") - viewModel.setGuestNetworkErrorMessage("There was an error creating your guest network. Please try again.") - break - default: - print("neither success with jwt or failure") - viewModel.setGuestNetworkErrorMessage("There was an error creating your guest network. Please try again.") - break - - } - } - private func handleAppleLoginResult(_ result: Result) async { guard viewModel.beginLoginAction(.apple) else { @@ -362,25 +319,8 @@ struct LoginInitialView: View { let createArgsResult = viewModel.createAppleAuthLoginArgs(result) switch createArgsResult { case .success(let args): - - if deviceManager.device != nil { - - // device exists, meaning we're in the guest flow - // link guest account to google account - - let upgradeArgs = self.createUpgradeExistingSocialArgs(args) - - let result = await guestUpgradeViewModel.linkGuestToExistingLogin(args: upgradeArgs) - await self.handleAuthLoginResult(result) - - } else { - - // login with apple - // let result = await viewModel.authLogin(args: args) - let result = await viewModel.authLogin(args: args) - await self.handleAuthLoginResult(result) - - } + let result = await viewModel.authLogin(args: args) + await self.handleAuthLoginResult(result) case .failure(let error): print("error create args result: \(error.localizedDescription)") @@ -414,6 +354,56 @@ struct LoginInitialView: View { } + private func handleGoogleSignInButton() async { + + guard viewModel.beginLoginAction(.google) else { + return + } + + defer { + viewModel.endLoginAction(.google) + } + + do { + #if os(iOS) + + guard let rootViewController = getRootViewController() else { + print("no root view controller found") + viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) + return + } + + let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) + #elseif os(macOS) + + guard let presentingWindow = NSApplication.shared.windows.first else { + print("There is no presenting window!") + viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) + return + } + + let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: presentingWindow) + #endif + + let createArgsResult = viewModel.createGoogleAuthLoginArgs(signInResult) + switch createArgsResult { + case .success(let args): + let result = await viewModel.authLogin(args: args) + await self.handleAuthLoginResult(result) + + case .failure(let error): + print("error create args result: \(error.localizedDescription)") + viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) + } + + } catch { + print("Error signing in: \(error.localizedDescription)") + viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) + } + + + } + private func handleAuthLoginResult(_ authLoginResult: AuthLoginResult) async { switch authLoginResult { @@ -489,18 +479,6 @@ struct LoginInitialView: View { } } - private func createUpgradeSolanaWalletArgs(_ args: SdkAuthLoginArgs) -> SdkUpgradeGuestExistingArgs { - let updateArgs = SdkUpgradeGuestExistingArgs() - updateArgs.walletAuth = args.walletAuth - return updateArgs - } - - /** - * Bittensor sign in: fetches a fresh server-issued challenge (same - * flow as Solana), then opens the ur.io/wallet-connect bridge; the - * wallet returns through onOpenURL with the ss58 address and sr25519 - * signature over that challenge. - */ private func handleBittensorSignIn() { Task { let ok = await viewModel.prepareBittensorChallenge() @@ -541,20 +519,9 @@ struct LoginInitialView: View { switch createArgsResult { case .success(let args): - if deviceManager.device != nil { - - let upgradeArgs = self.createUpgradeSolanaWalletArgs(args) - - let result = await guestUpgradeViewModel.linkGuestToExistingLogin(args: upgradeArgs) - - await self.handleAuthLoginResult(result) - viewModel.setIsSigningMessage(false) - - } else { - let result = await viewModel.authLogin(args: args) - await self.handleAuthLoginResult(result) - viewModel.setIsSigningMessage(false) - } + let result = await viewModel.authLogin(args: args) + await self.handleAuthLoginResult(result) + viewModel.setIsSigningMessage(false) case .failure(let error): print("error create args result: \(error.localizedDescription)") @@ -563,82 +530,6 @@ struct LoginInitialView: View { } } - private func createUpgradeExistingSocialArgs(_ args: SdkAuthLoginArgs) -> SdkUpgradeGuestExistingArgs { - let updateArgs = SdkUpgradeGuestExistingArgs() - updateArgs.authJwt = args.authJwt - updateArgs.authJwtType = args.authJwtType - return updateArgs - } - - private func handleGoogleSignInButton() async { - - guard viewModel.beginLoginAction(.google) else { - return - } - - defer { - viewModel.endLoginAction(.google) - } - - do { - #if os(iOS) - - guard let rootViewController = getRootViewController() else { - print("no root view controller found") - viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) - return - } - - let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) - #elseif os(macOS) - - guard let presentingWindow = NSApplication.shared.windows.first else { - print("There is no presenting window!") - viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) - return - } - - let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: presentingWindow) - #endif - - let createArgsResult = viewModel.createGoogleAuthLoginArgs(signInResult) - switch createArgsResult { - case .success(let args): - - if deviceManager.device != nil { - - // device exists, meaning we're in the guest flow - // link guest account to google account - // let result = await viewModel.linkGuestToExistingSocialLogin(args: args) - - let upgradeArgs = self.createUpgradeExistingSocialArgs(args) - - let result = await guestUpgradeViewModel.linkGuestToExistingLogin(args: upgradeArgs) - - await self.handleAuthLoginResult(result) - - } else { - - // login with google - let result = await viewModel.authLogin(args: args) - await self.handleAuthLoginResult(result) - - } - - case .failure(let error): - print("error create args result: \(error.localizedDescription)") - viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) - } - - } catch { - print("Error signing in: \(error.localizedDescription)") - viewModel.setLoginErrorMessage(String(localized: "There was an error logging in")) - } - - - - } - } private struct LoginInitialFormView: View { @@ -659,7 +550,9 @@ private struct LoginInitialFormView: View { let signInWithBittensor: () -> Void let presentAuthCodeLoginSheet: () -> Void - @Binding var presentGuestNetworkSheet: Bool + let presentSeedphraseLogin: () -> Void + let presentCreateInstant: () -> Void + @State private var presentNetworkServerSheet = false var body: some View { @@ -742,6 +635,8 @@ private struct LoginInitialFormView: View { presentSignInWithSolanaSheet: presentSignInWithSolanaSheet, signInWithBittensor: signInWithBittensor, presentAuthCodeLoginSheet: presentAuthCodeLoginSheet, + presentSeedphraseLogin: presentSeedphraseLogin, + presentCreateInstant: presentCreateInstant, activeLoginAction: activeLoginAction, isLoginActionInFlight: isLoginActionInFlight ) @@ -754,28 +649,54 @@ private struct LoginInitialFormView: View { Spacer() .frame(height: 24) - if !deviceExists { - // if a device exists, it means they are already in guest mode and trying to upgrade their account - // restrict access to create guest network from within authed guest network - + // Seedphrase login button + Button(action: presentSeedphraseLogin) { HStack { - Text("Commitment issues?") - .font(themeManager.currentTheme.bodyFont) - .foregroundColor(themeManager.currentTheme.textMutedColor) - - Button(action: { - presentGuestNetworkSheet = true - }) { - Text("Try Guest Mode") - .font(themeManager.currentTheme.bodyFont) - .foregroundColor(themeManager.currentTheme.textColor) - } - .buttonStyle(.plain) - .disabled(isLoginActionInFlight) - + Image(systemName: "key.fill") + .resizable() + .scaledToFit() + .frame(width: 16) + Spacer().frame(width: 8) + Text("Sign in with Seedphrase") + .foregroundColor(themeManager.currentTheme.inverseTextColor) + .font( + Font.system(size: 19, weight: .medium) + ) } - + .frame(maxWidth: .infinity) + } + .frame(height: 48) + .background(.white) + .clipShape(Capsule()) + .buttonStyle(.plain) + .opacity(isLoginActionInFlight ? 0.3 : 1) + .disabled(isLoginActionInFlight) + + Spacer() + .frame(height: 12) + + // Instant create account button + Button(action: presentCreateInstant) { + HStack { + Image(systemName: "bolt.fill") + .resizable() + .scaledToFit() + .frame(width: 16) + Spacer().frame(width: 8) + Text("Create Instant Account") + .foregroundColor(themeManager.currentTheme.inverseTextColor) + .font( + Font.system(size: 19, weight: .medium) + ) + } + .frame(maxWidth: .infinity) } + .frame(height: 48) + .background(.white) + .clipShape(Capsule()) + .buttonStyle(.plain) + .opacity(isLoginActionInFlight ? 0.3 : 1) + .disabled(isLoginActionInFlight) Spacer() .frame(height: 8) @@ -830,6 +751,8 @@ private struct SSOButtons: View { let presentSignInWithSolanaSheet: () -> Void let signInWithBittensor: () -> Void let presentAuthCodeLoginSheet: () -> Void + let presentSeedphraseLogin: () -> Void + let presentCreateInstant: () -> Void let activeLoginAction: LoginInitialView.LoginAction? let isLoginActionInFlight: Bool @@ -997,6 +920,8 @@ private struct SSOButtons: View { var presentSignInWithSolanaSheet: () -> Void var signInWithBittensor: () -> Void let presentAuthCodeLoginSheet: () -> Void + let presentSeedphraseLogin: () -> Void + let presentCreateInstant: () -> Void let activeLoginAction: LoginInitialView.LoginAction? let isLoginActionInFlight: Bool @@ -1130,10 +1055,57 @@ private struct SSOButtons: View { .disabled(isLoginActionInFlight) } .frame(maxWidth: .infinity) - + Spacer() + .frame(height: 8) + + // Seedphrase login button (macOS) + Button(action: presentSeedphraseLogin) { + HStack { + Image(systemName: "key.fill") + .resizable() + .scaledToFit() + .frame(width: 12) + Spacer().frame(width: 8) + Text("Sign in with Seedphrase") + .foregroundColor(themeManager.currentTheme.inverseTextColor) + .font(Font.system(size: 12, weight: .medium)) + } + .frame(maxWidth: .infinity) + } + .frame(height: 30) + .frame(maxWidth: .infinity) + .background(.white) + .cornerRadius(6) + .buttonStyle(.plain) + .disabled(isLoginActionInFlight) + .opacity(isLoginActionInFlight ? 0.3 : 1) + + Spacer().frame(width: 8) + + // Instant create account button (macOS) + Button(action: presentCreateInstant) { + HStack { + Image(systemName: "bolt.fill") + .resizable() + .scaledToFit() + .frame(width: 12) + Spacer().frame(width: 8) + Text("Create Instant Account") + .foregroundColor(themeManager.currentTheme.inverseTextColor) + .font(Font.system(size: 12, weight: .medium)) + } + .frame(maxWidth: .infinity) + } + .frame(height: 30) + .frame(maxWidth: .infinity) + .background(.white) + .cornerRadius(6) + .buttonStyle(.plain) + .disabled(isLoginActionInFlight) + .opacity(isLoginActionInFlight ? 0.3 : 1) } - + } } #endif diff --git a/app/network/Authenticate/LoginInitial/LoginInitialViewModel.swift b/app/network/Authenticate/LoginInitial/LoginInitialViewModel.swift index c707061d..3e434fd2 100644 --- a/app/network/Authenticate/LoginInitial/LoginInitialViewModel.swift +++ b/app/network/Authenticate/LoginInitial/LoginInitialViewModel.swift @@ -72,18 +72,6 @@ extension LoginInitialView { loginErrorMessage = message } - /** - * Guest mode - */ - @Published private(set) var isCreatingGuestNetwork: Bool = false - @Published var presentGuestNetworkSheet: Bool = false - @Published var termsAgreed: Bool = false - @Published private(set) var guestNetworkErrorMessage: String? - - func setGuestNetworkErrorMessage(_ message: String?) -> Void { - guestNetworkErrorMessage = message - } - /** * Auth code login */ @@ -287,40 +275,6 @@ extension LoginInitialView.ViewModel { } -// MARK: create guest network -extension LoginInitialView.ViewModel { - - func createGuestNetwork() async -> LoginNetworkResult { - - if self.isCreatingGuestNetwork { - return .failure(LoginError.inProgress) - } - - self.isCreatingGuestNetwork = true - self.guestNetworkErrorMessage = nil - - do { - - let args = SdkNetworkCreateArgs() - args.terms = true - args.guestMode = true - - let result = try await urApiService.createNetwork(args) - - self.isCreatingGuestNetwork = false - - - return result - - } catch(let error) { - self.isCreatingGuestNetwork = false - return .failure(error) - } - - } - -} - // MARK: Solana Sign in extension LoginInitialView.ViewModel { func createSolanaAuthLoginArgs(message: String, signature: String, publicKey: String) -> Result { diff --git a/app/network/Authenticate/LoginNavigationView.swift b/app/network/Authenticate/LoginNavigationView.swift index d603197e..2d6fd4a6 100644 --- a/app/network/Authenticate/LoginNavigationView.swift +++ b/app/network/Authenticate/LoginNavigationView.swift @@ -12,8 +12,6 @@ struct LoginNavigationView: View { @StateObject private var viewModel = ViewModel() - @StateObject private var guestUpgradeViewModel: GuestUpgradeViewModel - @EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var deviceManager: DeviceManager @@ -38,7 +36,6 @@ struct LoginNavigationView: View { self.api = api self.cancel = cancel self.handleSuccess = handleSuccess - _guestUpgradeViewModel = StateObject(wrappedValue: GuestUpgradeViewModel(api: api)) } var body: some View { @@ -49,8 +46,7 @@ struct LoginNavigationView: View { urApiService: urApiService, navigate: viewModel.navigate, cancel: cancel, - handleSuccess: handleSuccess, - guestUpgradeViewModel: guestUpgradeViewModel + handleSuccess: handleSuccess ) .id(deviceManager.activeHostName) .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) @@ -61,7 +57,6 @@ struct LoginNavigationView: View { userAuth: userAuth, navigate: viewModel.navigate, handleSuccess: handleSuccess, - guestUpgradeViewModel: guestUpgradeViewModel, api: api ) .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) @@ -89,6 +84,20 @@ struct LoginNavigationView: View { api: api ) .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + case .seedphrase: + LoginSeedphraseView( + urApiService: urApiService, + handleSuccess: handleSuccess, + back: viewModel.back + ) + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + case .createInstant: + CreateNetworkInstantView( + urApiService: urApiService, + handleSuccess: handleSuccess, + back: viewModel.back + ) + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) } } } diff --git a/app/network/Authenticate/LoginNavigationViewModel.swift b/app/network/Authenticate/LoginNavigationViewModel.swift index 29260e74..a1f53261 100644 --- a/app/network/Authenticate/LoginNavigationViewModel.swift +++ b/app/network/Authenticate/LoginNavigationViewModel.swift @@ -14,6 +14,8 @@ enum LoginInitialNavigationPath: Hashable { case createNetwork(_ authLoginArgs: SdkAuthLoginArgs) case verify(_ userAuth: String) case resetPassword(_ userAuth: String) + case seedphrase + case createInstant } extension LoginNavigationView { diff --git a/app/network/Authenticate/LoginPassword/LoginPasswordView.swift b/app/network/Authenticate/LoginPassword/LoginPasswordView.swift index ca47c8fd..1c92fe2e 100644 --- a/app/network/Authenticate/LoginPassword/LoginPasswordView.swift +++ b/app/network/Authenticate/LoginPassword/LoginPasswordView.swift @@ -11,9 +11,7 @@ import URnetworkSdk struct LoginPasswordView: View { @EnvironmentObject var themeManager: ThemeManager - @EnvironmentObject var deviceManager: DeviceManager @StateObject private var viewModel: ViewModel - @ObservedObject var guestUpgradeViewModel: GuestUpgradeViewModel var userAuth: String var navigate: (LoginInitialNavigationPath) -> Void @@ -25,14 +23,12 @@ struct LoginPasswordView: View { userAuth: String, navigate: @escaping (LoginInitialNavigationPath) -> Void, handleSuccess: @escaping (_ jwt: String) async -> Void, - guestUpgradeViewModel: GuestUpgradeViewModel, api: SdkApi? ) { _viewModel = StateObject(wrappedValue: ViewModel(api: api)) self.userAuth = userAuth self.navigate = navigate self.handleSuccess = handleSuccess - self.guestUpgradeViewModel = guestUpgradeViewModel } var body: some View { @@ -88,7 +84,6 @@ struct LoginPasswordView: View { }, enabled: !isLoginInProgress && viewModel.isValid, isProcessing: isLoginInProgress - // todo add icon ) Spacer().frame(height: 8) @@ -123,7 +118,7 @@ struct LoginPasswordView: View { } private var isLoginInProgress: Bool { - viewModel.isLoggingIn || guestUpgradeViewModel.isUpgrading + viewModel.isLoggingIn } private func submitPasswordLogin() async { @@ -133,45 +128,8 @@ struct LoginPasswordView: View { viewModel.setErrorMessage(nil) - if deviceManager.device != nil { - // in guest mode; merge user auth account and login - let args = SdkUpgradeGuestExistingArgs() - args.userAuth = self.userAuth - args.password = self.viewModel.password - - let result = await guestUpgradeViewModel.linkGuestToExistingLogin(args: args) - await handleUpgradeLoginResult(result) - } else { - let result = await viewModel.loginWithPassword(userAuth: self.userAuth) - await handleLoginResult(result) - } - } - - private func handleUpgradeLoginResult(_ result: AuthLoginResult) async { - switch result { - - case .login(let authJwt): - await handleSuccess(authJwt) - break - - case .failure(let error): - print("auth login error: \(error.localizedDescription)") - viewModel.setIsLoggingIn(false) - viewModel.setErrorMessage(snackbarErrorMessage) - break - - case .verificationRequired(let userAuth): - navigate(.verify(userAuth)) - viewModel.setIsLoggingIn(false) - break - - default: - viewModel.setIsLoggingIn(false) - viewModel.setErrorMessage(snackbarErrorMessage) - print("upgrade login result does not match any case") - return - - } + let result = await viewModel.loginWithPassword(userAuth: self.userAuth) + await handleLoginResult(result) } private func handleLoginResult(_ result: LoginNetworkResult) async { @@ -179,7 +137,6 @@ struct LoginPasswordView: View { case .successWithJwt(let jwt): await handleSuccess(jwt) - // viewModel.setIsLoggingIn(false) break case .successWithVerificationRequired: diff --git a/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseView.swift b/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseView.swift new file mode 100644 index 00000000..44923b83 --- /dev/null +++ b/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseView.swift @@ -0,0 +1,125 @@ +// +// LoginSeedphraseView.swift +// URnetwork +// + +import SwiftUI +import URnetworkSdk + +struct LoginSeedphraseView: View { + + @EnvironmentObject var themeManager: ThemeManager + + @StateObject private var viewModel: ViewModel + + let handleSuccess: (_ jwt: String) async -> Void + let back: () -> Void + + init( + urApiService: UrApiServiceProtocol, + handleSuccess: @escaping (_ jwt: String) async -> Void, + back: @escaping () -> Void + ) { + _viewModel = StateObject(wrappedValue: ViewModel(urApiService: urApiService)) + self.handleSuccess = handleSuccess + self.back = back + } + + var body: some View { + ScrollView { + VStack(alignment: .center) { + + Text("Sign in with Seedphrase") + .foregroundColor(.urWhite) + .font(themeManager.currentTheme.titleFont) + + Spacer().frame(height: 48) + + TextEditor(text: $viewModel.seedphrase) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 160) + .padding(12) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(themeManager.currentTheme.textMutedColor.opacity(0.3), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if viewModel.seedphrase.isEmpty { + Text("Paste your 24-word seedphrase here") + .foregroundColor(themeManager.currentTheme.textMutedColor) + .font(themeManager.currentTheme.bodyFont) + .padding(.horizontal, 16) + .padding(.vertical, 20) + .allowsHitTesting(false) + } + } + .cornerRadius(8) + + Spacer().frame(height: 32) + + UrButton( + text: "Sign In", + action: { + Task { + let result = await viewModel.login() + await handleResult(result) + } + }, + enabled: viewModel.isSeedphraseValid && !viewModel.isLoggingIn, + isProcessing: viewModel.isLoggingIn + ) + + Spacer().frame(height: 8) + + if let wordCountWarning = viewModel.wordCountWarning { + Text(wordCountWarning) + .foregroundColor(.urYellow) + .font(themeManager.currentTheme.secondaryBodyFont) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Spacer().frame(height: 8) + } + + UrInlineErrorText(message: viewModel.errorMessage) + + } + .padding() + .frame(maxWidth: .infinity) + .frame(maxWidth: 400) + } + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { back() }) { + Image(systemName: "chevron.left") + .foregroundColor(themeManager.currentTheme.textColor) + } + } + #elseif os(macOS) + ToolbarItem { + Button(action: { back() }) { + Image(systemName: "chevron.left") + .foregroundColor(themeManager.currentTheme.textColor) + } + } + #endif + } + #if os(iOS) + .navigationBarBackButtonHidden(true) + #endif + } + + private func handleResult(_ result: AuthLoginResult) async { + switch result { + case .login(let jwt): + await handleSuccess(jwt) + case .failure(let error): + viewModel.errorMessage = error.localizedDescription + default: + viewModel.errorMessage = "There was an error signing in with your seedphrase. Please try again." + } + } + +} diff --git a/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseViewModel.swift b/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseViewModel.swift new file mode 100644 index 00000000..a5ac278d --- /dev/null +++ b/app/network/Authenticate/LoginSeedphrase/LoginSeedphraseViewModel.swift @@ -0,0 +1,99 @@ +// +// LoginSeedphraseViewModel.swift +// URnetwork +// + +import Foundation +import URnetworkSdk + +extension LoginSeedphraseView { + + @MainActor + class ViewModel: ObservableObject { + + private let urApiService: UrApiServiceProtocol + + @Published var seedphrase: String = "" { + didSet { + errorMessage = nil + } + } + + @Published private(set) var wordCountWarning: String? + + @Published private(set) var isLoggingIn: Bool = false + + @Published var errorMessage: String? + + let domain = "LoginSeedphraseViewModel" + + init(urApiService: UrApiServiceProtocol) { + self.urApiService = urApiService + } + + func setErrorMessage(_ message: String?) { + errorMessage = message + } + + var isSeedphraseValid: Bool { + wordCount == 12 || wordCount == 24 + } + + var normalizedSeedphrase: String { + seedphrase + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + var wordCount: Int { + normalizedSeedphrase.isEmpty ? 0 : normalizedSeedphrase.components(separatedBy: " ").count + } + + func validateWordCount() -> Bool { + let count = wordCount + if count == 12 || count == 24 { + wordCountWarning = nil + return true + } + if count > 0 { + wordCountWarning = "Seedphrase should be 12 or 24 words (you entered \(count))" + } else { + wordCountWarning = nil + } + return false + } + + func login() async -> AuthLoginResult { + + if isLoggingIn { + return .failure(NSError(domain: domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Login already in progress"])) + } + + guard isSeedphraseValid else { + return .failure(NSError(domain: domain, code: 0, userInfo: [NSLocalizedDescriptionKey: "Seedphrase should be 12 or 24 words"])) + } + + validateWordCount() + + isLoggingIn = true + errorMessage = nil + + defer { + isLoggingIn = false + } + + do { + let result = try await urApiService.loginWithSeedphrase(seedphrase: normalizedSeedphrase) + return result + } catch { + return .failure(error) + } + + } + + } + +} diff --git a/app/network/Main/Account/AccountNavStackView.swift b/app/network/Main/Account/AccountNavStackView.swift index 1aa85852..d32c2bce 100644 --- a/app/network/Main/Account/AccountNavStackView.swift +++ b/app/network/Main/Account/AccountNavStackView.swift @@ -105,7 +105,16 @@ struct AccountNavStackView: View { api: api, back: viewModel.back, networkName: networkName, - userAuth: networkUserViewModel.networkUser?.userAuth + userAuth: networkUserViewModel.networkUser?.userAuth, + needsNameClaim: { + guard let networkUser = networkUserViewModel.networkUser else { return false } + let hasIdentityMethod = authTypesContains(networkUser.authTypes, "email") + || authTypesContains(networkUser.authTypes, "phone") + || authTypesContains(networkUser.authTypes, "google") + || authTypesContains(networkUser.authTypes, "apple") + || authTypesContains(networkUser.authTypes, "solana") + return !hasIdentityMethod + }() ) .background(themeManager.currentTheme.backgroundColor) .navigationTitle("Profile") @@ -119,6 +128,7 @@ struct AccountNavStackView: View { accountWalletsViewModel: accountWalletsViewModel, navigate: viewModel.navigate, providerCountries: providerCountries, + networkUserViewModel: networkUserViewModel ) .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) .navigationTitle("Settings") diff --git a/app/network/Main/Account/Profile/ProfileView.swift b/app/network/Main/Account/Profile/ProfileView.swift index 77fdeae4..62bb3685 100644 --- a/app/network/Main/Account/Profile/ProfileView.swift +++ b/app/network/Main/Account/Profile/ProfileView.swift @@ -18,42 +18,107 @@ struct ProfileView: View { var back: () -> Void var networkName: String? var userAuth: String? - - init(api: SdkApi, back: @escaping () -> Void, networkName: String?, userAuth: String?) { + /// True when the account has no verified identity method (email/Google/Apple) + /// bound yet, so it's still on its auto-generated name and needs the + /// claim-name flow (no reclaim cooldown on the old name) rather than + /// change-name (which applies a 24h cooldown to protect the old name). + var needsNameClaim: Bool + + init(api: SdkApi, back: @escaping () -> Void, networkName: String?, userAuth: String?, needsNameClaim: Bool = false) { _viewModel = StateObject.init(wrappedValue: ViewModel( api: api )) self.back = back self.userAuth = userAuth self.networkName = networkName + self.needsNameClaim = needsNameClaim } var body: some View { VStack { - -// HStack { -// Text("Profile") -// .font(themeManager.currentTheme.titleFont) -// .foregroundColor(themeManager.currentTheme.textColor) -// -// Spacer() -// } -// -// Spacer().frame(height: 64) - + HStack { UrLabel(text: "Network name") Spacer() } - HStack { - Text(networkName ?? "") - .font(themeManager.currentTheme.bodyFont) - .foregroundColor(themeManager.currentTheme.textColor) + if viewModel.isEditingNetworkName { + // Editable view + HStack { + TextField("Enter network name", text: $viewModel.editedNetworkName) + .textFieldStyle(.plain) + .font(themeManager.currentTheme.bodyFont) + .foregroundColor(themeManager.currentTheme.textColor) + .autocorrectionDisabled() + #if os(iOS) + .autocapitalization(.none) + #endif + } + .padding() + .background(themeManager.currentTheme.tintedBackgroundBase) + .cornerRadius(8) - Spacer() + if let error = viewModel.networkNameError { + Text(error) + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(.red) + } + + HStack(spacing: 12) { + UrButton( + text: "Save", + action: { + Task { + if needsNameClaim { + let result = await viewModel.claimNetworkName() + handleNetworkNameResult(result) + } else { + let result = await viewModel.saveNetworkName() + handleNetworkNameResult(result) + } + } + }, + enabled: !viewModel.isSavingNetworkName && !viewModel.editedNetworkName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + isProcessing: viewModel.isSavingNetworkName + ) + + Button(action: { + viewModel.cancelEditingNetworkName() + }) { + Text("Cancel") + } + } + } else { + // Read-only view + HStack { + Text(networkName ?? "") + .font(themeManager.currentTheme.bodyFont) + .foregroundColor(themeManager.currentTheme.textColor) + + Spacer() + + Button(action: { + viewModel.startEditingNetworkName(currentName: networkName ?? "") + }) { + Image(systemName: "pencil") + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + } + + HStack { + if needsNameClaim { + Text("Claim a custom network name to replace your auto-generated one") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } else { + Text("Tap the edit icon to change your network name") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + Spacer() + } } Spacer().frame(height: 32) @@ -97,6 +162,25 @@ struct ProfileView: View { snackbarManager.showSnackbar(message: String(localized: "Error sending password reset link")) } } + + private func handleNetworkNameResult(_ result: Result) { + switch result { + case .success(let newName): + snackbarManager.showSnackbar(message: String(localized: "Network name changed to \(newName)")) + // The displayed network name comes from the cached JWT + // (deviceManager.parsedJwt?.networkName), which isn't reissued by + // this call — force a refresh so the new name propagates to this + // screen and anywhere else the JWT-derived name is shown. + do { + try deviceManager.device?.refreshToken(0) + } catch { + print("Error refreshing JWT after network name change: \(error)") + } + case .failure(let error): + print("Error changing network name: \(error)") + snackbarManager.showSnackbar(message: String(localized: "Error changing network name")) + } + } } #Preview { diff --git a/app/network/Main/Account/Profile/ProfileViewModel.swift b/app/network/Main/Account/Profile/ProfileViewModel.swift index 3eb0567c..b1432155 100644 --- a/app/network/Main/Account/Profile/ProfileViewModel.swift +++ b/app/network/Main/Account/Profile/ProfileViewModel.swift @@ -18,6 +18,11 @@ extension ProfileView { @Published private(set) var isSendingPasswordResetLink: Bool = false @Published private(set) var sendPasswordResetLinkError: String? + @Published var isEditingNetworkName: Bool = false + @Published var editedNetworkName: String = "" + @Published private(set) var isSavingNetworkName: Bool = false + @Published private(set) var networkNameError: String? + init(api: SdkApi) { self.api = api } @@ -70,6 +75,116 @@ extension ProfileView { } + func startEditingNetworkName(currentName: String) { + editedNetworkName = currentName + isEditingNetworkName = true + networkNameError = nil + } + + func saveNetworkName() async -> Result { + + let name = editedNetworkName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + return .failure(NetworkNameError.empty) + } + + isSavingNetworkName = true + networkNameError = nil + + do { + let result: SdkChangeNetworkNameResult = try await withCheckedThrowingContinuation { continuation in + + let callback = ChangeNetworkNameCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NetworkNameError.resultNil) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: "ProfileViewModel", code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkChangeNetworkNameArgs() + args.newName = name + api.changeNetworkName(args, callback: callback) + } + + self.isSavingNetworkName = false + self.isEditingNetworkName = false + return .success(result.networkName) + + } catch(let error) { + self.isSavingNetworkName = false + self.networkNameError = error.localizedDescription + return .failure(error) + } + } + + func claimNetworkName() async -> Result { + + let name = editedNetworkName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + return .failure(NetworkNameError.empty) + } + + isSavingNetworkName = true + networkNameError = nil + + do { + let result: SdkClaimNetworkNameResult = try await withCheckedThrowingContinuation { continuation in + + let callback = ClaimNetworkNameCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NetworkNameError.resultNil) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: "ProfileViewModel", code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkClaimNetworkNameArgs() + args.newName = name + api.claimNetworkName(args, callback: callback) + } + + self.isSavingNetworkName = false + self.isEditingNetworkName = false + return .success(result.networkName) + + } catch(let error) { + self.isSavingNetworkName = false + self.networkNameError = error.localizedDescription + return .failure(error) + } + } + + func cancelEditingNetworkName() { + isEditingNetworkName = false + editedNetworkName = "" + networkNameError = nil + } + } } @@ -79,8 +194,25 @@ enum SendPasswordResetLinkError: Error { case resultInvalid } +enum NetworkNameError: Error { + case empty + case resultNil +} + private class AuthPasswordResetCallback: SdkCallback, SdkAuthPasswordResetCallbackProtocol { func result(_ result: SdkAuthPasswordResetResult?, err: Error?) { handleResult(result, err: err) } } + +private class ChangeNetworkNameCallback: SdkCallback, SdkChangeNetworkNameCallbackProtocol { + func result(_ result: SdkChangeNetworkNameResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class ClaimNetworkNameCallback: SdkCallback, SdkClaimNetworkNameCallbackProtocol { + func result(_ result: SdkClaimNetworkNameResult?, err: Error?) { + handleResult(result, err: err) + } +} diff --git a/app/network/Main/Account/Settings/AddAuthSheet.swift b/app/network/Main/Account/Settings/AddAuthSheet.swift new file mode 100644 index 00000000..b7225b40 --- /dev/null +++ b/app/network/Main/Account/Settings/AddAuthSheet.swift @@ -0,0 +1,533 @@ +// +// AddAuthSheet.swift +// URnetwork +// + +import SwiftUI +import URnetworkSdk +import AuthenticationServices +import GoogleSignIn + +struct AddAuthSheet: View { + + @EnvironmentObject var themeManager: ThemeManager + @EnvironmentObject var snackbarManager: UrSnackbarManager + @EnvironmentObject var connectWalletProviderViewModel: ConnectWalletProviderViewModel + + let api: UrApiServiceProtocol + let networkUserViewModel: NetworkUserViewModel? + + @State private var email: String = "" + @State private var password: String = "" + @State private var isAdding: Bool = false + @State private var selectedMethod: String = "email" + @State private var addError: String? + @State private var walletConnectionTask: Task? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + Text("Add a sign-in method") + .font(themeManager.currentTheme.titleFont) + .foregroundColor(themeManager.currentTheme.textColor) + + Text("Link another way to sign in to your account.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + + Spacer().frame(height: 16) + + Picker("Method", selection: $selectedMethod) { + Text("Apple").tag("apple") + Text("Google").tag("google") + Text("Wallet").tag("wallet") + Text("Email").tag("email") + Text("Seedphrase").tag("seedphrase") + } + .pickerStyle(.menu) + + if selectedMethod == "apple" { + appleSignInView + } else if selectedMethod == "google" { + googleSignInView + } else if selectedMethod == "wallet" { + walletSignInView + } else if selectedMethod == "email" { + emailFields + } else if selectedMethod == "seedphrase" { + Text("A new seedphrase will be generated and linked to your account.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + + if let error = addError { + Text(error) + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(.red) + } + + if selectedMethod == "email" || selectedMethod == "seedphrase" { + Spacer().frame(height: 16) + + UrButton( + text: "Add Sign-In Method", + action: { + Task { + await addAuth() + } + }, + enabled: !isAdding && formValid, + isProcessing: isAdding + ) + } + } + .padding() + } + .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + .onDisappear { + walletConnectionTask?.cancel() + walletConnectionTask = nil + connectWalletProviderViewModel.pendingAddAuthSignatureHandler = nil + connectWalletProviderViewModel.pendingWalletAuthMessage = nil + } + } + } + + @Environment(\.dismiss) private var dismiss + + private var formValid: Bool { + switch selectedMethod { + case "email": + return !email.isEmpty && password.count >= 12 + default: + return true + } + } + + // MARK: - Apple Sign-In + + private var appleSignInView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Sign in with your Apple ID to add it as a sign-in method.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + + #if os(iOS) + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.email] + } onCompletion: { result in + Task { + await handleAppleResult(result) + } + } + .signInWithAppleButtonStyle(.white) + .frame(height: 50) + .cornerRadius(8) + #else + Text("Apple Sign-In is available on iOS.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + #endif + } + } + + // MARK: - Google Sign-In + + private var googleSignInView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Sign in with Google to add it as a sign-in method.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + + UrGoogleSignInButton( + action: { + await handleGoogleSignIn() + } + ) + } + } + + // MARK: - Wallet Sign-In + + @State private var walletStep: WalletStep = .disconnected + + enum WalletStep: Equatable { + case disconnected + case connecting + case connected(String) // publicKey + case signing + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + } + + @State private var walletChallengeMessage: String = "" + + private var walletSignInView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Connect a Solana wallet (Phantom or Solflare) to add it as a sign-in method.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + + switch walletStep { + case .disconnected: + HStack(spacing: 12) { + Button(action: { + walletConnectionTask = Task { + await connectWallet(.phantom) + } + }) { + VStack { + Image("phantom.white.logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .padding() + .background(Color(hex: "#ab9ff2")) + .cornerRadius(12) + Text("Phantom") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textColor) + } + } + .buttonStyle(.plain) + .disabled(isAdding || walletStep == .connecting) + + Button(action: { + walletConnectionTask = Task { + await connectWallet(.solflare) + } + }) { + VStack { + Image("solflare.logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .padding() + .background(.urWhite) + .cornerRadius(12) + Text("Solflare") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textColor) + } + } + .buttonStyle(.plain) + .disabled(isAdding || walletStep == .connecting) + } + + case .connecting: + HStack { + ProgressView() + Text("Connecting to wallet...") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + + case .connected(let publicKey): + HStack { + Image(systemName: "wallet.pass.fill") + .foregroundColor(.green) + Text("Connected: \(publicKey.prefix(8))...") + .font(themeManager.currentTheme.secondaryBodyFont) + } + + if !walletChallengeMessage.isEmpty { + UrButton( + text: "Sign with Wallet", + action: { + Task { + await signWalletChallenge() + } + }, + enabled: !isAdding && walletStep.isConnected, + isProcessing: isAdding + ) + } + + case .signing: + HStack { + ProgressView() + Text("Waiting for wallet signature...") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + } + } + } + + private func connectWallet(_ provider: ConnectedWalletProvider) async { + isAdding = true + addError = nil + walletStep = .connecting + + // Set up the pending handler for when wallet signs back + connectWalletProviderViewModel.pendingAddAuthSignatureHandler = { [self] publicKey, signature in + Task { @MainActor in + await completeWalletAuth(publicKey: publicKey, signature: signature) + } + } + + // Fetch the challenge first before connecting + do { + let challengeArgs = SdkAuthWalletChallengeArgs() + challengeArgs.blockchain = "solana" + let challengeResult = try await api.authWalletChallenge(challengeArgs) + self.walletChallengeMessage = challengeResult.messageTemplate + connectWalletProviderViewModel.pendingWalletAuthMessage = challengeResult.messageTemplate + } catch { + addError = "Failed to get wallet challenge: \(error.localizedDescription)" + isAdding = false + walletStep = .disconnected + return + } + + // Check if already connected + if connectWalletProviderViewModel.connectedPublicKey != nil { + walletStep = .connected(connectWalletProviderViewModel.connectedPublicKey!) + isAdding = false + return + } + + // Open wallet connection + let opened: Bool + switch provider { + case .phantom: + opened = connectWalletProviderViewModel.connectPhantomWallet() + case .solflare: + opened = connectWalletProviderViewModel.connectSolflareWallet() + case .bittensor: + opened = false + @unknown default: + opened = false + } + + if !opened { + addError = "Could not open wallet. Please install it and try again." + isAdding = false + walletStep = .disconnected + return + } + + // We'll wait for the connect deep link to fire + // The Sheet's onAppear sets up a polling timer + await pollForWalletConnection() + } + + private func pollForWalletConnection() async { + // Poll for up to 60 seconds for the wallet to connect back + for _ in 0..<60 { + if Task.isCancelled { return } + if let pk = connectWalletProviderViewModel.connectedPublicKey { + walletStep = .connected(pk) + isAdding = false + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + if Task.isCancelled { return } + } + addError = "Wallet connection timed out. Please try again." + isAdding = false + walletStep = .disconnected + } + + private func signWalletChallenge() async { + isAdding = true + addError = nil + walletStep = .signing + + guard let provider = connectWalletProviderViewModel.connectedWalletProvider else { + addError = "Wallet not connected" + isAdding = false + walletStep = .disconnected + return + } + + let message = walletChallengeMessage + let didStartSigning: Bool + switch provider { + case .phantom: + didStartSigning = connectWalletProviderViewModel.signMessagePhantom(message: message) + case .solflare: + didStartSigning = connectWalletProviderViewModel.signMessageSolflare(message: message) + case .bittensor: + didStartSigning = false + @unknown default: + didStartSigning = false + } + + if !didStartSigning { + addError = "Failed to start wallet signing" + isAdding = false + walletStep = .connected(connectWalletProviderViewModel.connectedPublicKey ?? "") + } + // The pendingAddAuthSignatureHandler will complete the flow when the signature comes back + } + + private func completeWalletAuth(publicKey: String, signature: String) async { + do { + let args = SdkAddAuthArgs() + let walletAuth = SdkWalletAuthArgs() + walletAuth.blockchain = SdkSOL + walletAuth.publicKey = publicKey + walletAuth.message = walletChallengeMessage + walletAuth.signature = signature + args.walletAuth = walletAuth + + let _ = try await api.addAuth(args) + isAdding = false + walletStep = .disconnected + connectWalletProviderViewModel.pendingAddAuthSignatureHandler = nil + connectWalletProviderViewModel.pendingWalletAuthMessage = nil + _ = await networkUserViewModel?.refreshNetworkUser() + snackbarManager.showSnackbar(message: String(localized: "Wallet sign-in method added")) + dismiss() + } catch(let error) { + isAdding = false + addError = error.localizedDescription + walletStep = .disconnected + connectWalletProviderViewModel.pendingAddAuthSignatureHandler = nil + connectWalletProviderViewModel.pendingWalletAuthMessage = nil + } + } + + // MARK: - Email Fields + + private var emailFields: some View { + VStack(alignment: .leading, spacing: 12) { + UrTextField( + text: $email, + label: "Email", + placeholder: "your@email.com", + disableCapitalization: true + ) + + UrTextField( + text: $password, + label: "Password", + placeholder: "Enter a password", + isSecure: true + ) + + Text("Password must be at least 12 characters") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + } + + // MARK: - Actions + + private func handleAppleResult(_ result: Result) async { + isAdding = true + addError = nil + + do { + let authResult = try result.get() + guard let credential = authResult.credential as? ASAuthorizationAppleIDCredential, + let idToken = credential.identityToken, + let idTokenString = String(data: idToken, encoding: .utf8) else { + addError = "Could not read Apple ID token" + isAdding = false + return + } + + let args = SdkAddAuthArgs() + args.authJwt = idTokenString + args.authJwtType = "apple" + + let _ = try await api.addAuth(args) + isAdding = false + _ = await networkUserViewModel?.refreshNetworkUser() + snackbarManager.showSnackbar(message: String(localized: "Apple sign-in method added")) + dismiss() + } catch(let error) { + isAdding = false + addError = error.localizedDescription + } + } + + private func handleGoogleSignIn() async { + isAdding = true + addError = nil + + do { + #if os(iOS) + guard let rootViewController = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.keyWindow? + .rootViewController else { + addError = "Could not get root view controller" + isAdding = false + return + } + let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) + #elseif os(macOS) + guard let presentingWindow = NSApplication.shared.windows.first else { + addError = "Could not get presenting window" + isAdding = false + return + } + let signInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: presentingWindow) + #endif + + guard let idTokenString = signInResult.user.idToken?.tokenString else { + addError = "Could not get Google ID token" + isAdding = false + return + } + + let args = SdkAddAuthArgs() + args.authJwt = idTokenString + args.authJwtType = "google" + + let _ = try await api.addAuth(args) + isAdding = false + _ = await networkUserViewModel?.refreshNetworkUser() + snackbarManager.showSnackbar(message: String(localized: "Google sign-in method added")) + dismiss() + } catch(let error) { + isAdding = false + addError = error.localizedDescription + } + } + + private func addAuth() async { + isAdding = true + addError = nil + + do { + let args = SdkAddAuthArgs() + + switch selectedMethod { + case "email": + args.userAuth = email + args.password = password + case "seedphrase": + // Seedphrase is generated server-side when no auth fields are set + // Just call addAuth with empty args to trigger seedphrase linking + break + default: + break + } + + let _ = try await api.addAuth(args) + isAdding = false + _ = await networkUserViewModel?.refreshNetworkUser() + snackbarManager.showSnackbar(message: String(localized: "Sign-in method added successfully")) + dismiss() + } catch(let error) { + isAdding = false + addError = error.localizedDescription + } + } +} diff --git a/app/network/Main/Account/Settings/AuthMethods.swift b/app/network/Main/Account/Settings/AuthMethods.swift new file mode 100644 index 00000000..18a7f0e0 --- /dev/null +++ b/app/network/Main/Account/Settings/AuthMethods.swift @@ -0,0 +1,57 @@ +// +// AuthMethods.swift +// URnetwork +// + +import URnetworkSdk + +// Shared by SettingsForm-iOS.swift and SettingsForm-macOS.swift so both +// platforms read auth-method state identically. + +func authTypesContains(_ authTypes: SdkStringList?, _ method: String) -> Bool { + guard let authTypes else { return false } + for i in 0.. [String] { + var methods: [String] = [] + + // Read from the new auth_types array returned by GET /network/user + if let authTypes = networkUser.authTypes, authTypes.len() > 0 { + for i in 0.. String { + switch method { + case "email": return "Email" + case "google": return "Google" + case "apple": return "Apple" + case "solana": return "Solana Wallet" + case "seedphrase": return "Seedphrase" + default: return method.capitalized + } +} diff --git a/app/network/Main/Account/Settings/SettingsForm-iOS.swift b/app/network/Main/Account/Settings/SettingsForm-iOS.swift index 69f95e3b..f09f1c4e 100644 --- a/app/network/Main/Account/Settings/SettingsForm-iOS.swift +++ b/app/network/Main/Account/Settings/SettingsForm-iOS.swift @@ -35,6 +35,9 @@ struct SettingsForm_iOS: View { @Binding var canReceiveNotifications: Bool @Binding var canReceiveProductUpdates: Bool + let networkUserViewModel: NetworkUserViewModel? + let viewModel: SettingsView.ViewModel + var body: some View { Form { @@ -135,6 +138,98 @@ struct SettingsForm_iOS: View { } } + // MARK: - Seedphrase Management + + Section("Seedphrase") { + VStack(alignment: .leading, spacing: 8) { + if let networkUser = networkUserViewModel?.networkUser { + let hasSeedphrase = authTypesContains(networkUser.authTypes, "seedphrase") + if hasSeedphrase { + Button(action: { + viewModel.confirmRegenerateSeedphrase() + }) { + HStack { + Text("Regenerate Seedphrase") + .font(themeManager.currentTheme.bodyFont) + Spacer() + if viewModel.isRegeneratingSeedphrase { + ProgressView() + } + } + } + .disabled(viewModel.isRegeneratingSeedphrase) + } else { + Button(action: { + viewModel.confirmGenerateSeedphrase() + }) { + HStack { + Text("Generate Seedphrase") + .font(themeManager.currentTheme.bodyFont) + Spacer() + if viewModel.isGeneratingSeedphrase { + ProgressView() + } + } + } + .disabled(viewModel.isGeneratingSeedphrase) + } + } else { + Button(action: { + viewModel.confirmGenerateSeedphrase() + }) { + HStack { + Text("Generate Seedphrase") + .font(themeManager.currentTheme.bodyFont) + Spacer() + if viewModel.isGeneratingSeedphrase { + ProgressView() + } + } + } + .disabled(viewModel.isGeneratingSeedphrase) + } + + Text("A seedphrase lets you recover your account if you lose access.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + } + + // MARK: - Sign-In Methods + + Section("Sign-In Methods") { + if let networkUser = networkUserViewModel?.networkUser { + let authMethods = parseAuthMethods(networkUser) + + ForEach(authMethods, id: \.self) { method in + HStack { + Text(methodDisplayName(method)) + .font(themeManager.currentTheme.bodyFont) + Spacer() + Button(role: .destructive) { + viewModel.presentRemoveAuth(method) + } label: { + Text("Remove") + } + } + } + + Button(action: { + viewModel.presentAddAuthSheet = true + }) { + Text("Add sign-in method") + } + } else { + HStack { + Text("Loading sign-in methods...") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + Spacer() + ProgressView() + } + } + } + Section("Account") { /** * Update referral code @@ -349,6 +444,7 @@ struct SettingsForm_iOS: View { .scrollContentBackground(.hidden) .background(themeManager.currentTheme.backgroundColor) } + } #endif diff --git a/app/network/Main/Account/Settings/SettingsForm-macOS.swift b/app/network/Main/Account/Settings/SettingsForm-macOS.swift index 4cf2b8e3..efd77f85 100644 --- a/app/network/Main/Account/Settings/SettingsForm-macOS.swift +++ b/app/network/Main/Account/Settings/SettingsForm-macOS.swift @@ -36,6 +36,9 @@ struct SettingsForm_macOS: View { @Binding var canReceiveProductUpdates: Bool @Binding var launchAtStartupEnabled: Bool + let networkUserViewModel: NetworkUserViewModel? + let viewModel: SettingsView.ViewModel + var provideIndicatorColor: Color { if !provideEnabled { return .urCoral @@ -170,6 +173,112 @@ struct SettingsForm_macOS: View { Spacer().frame(height: 32) + // MARK: - Seedphrase Management + + HStack { + UrLabel(text: "Seedphrase") + + Spacer() + } + + VStack(alignment: .leading) { + if let networkUser = networkUserViewModel?.networkUser { + let hasSeedphrase = authTypesContains(networkUser.authTypes, "seedphrase") + if hasSeedphrase { + HStack { + Button(action: { + viewModel.confirmRegenerateSeedphrase() + }) { + Text("Regenerate Seedphrase") + } + if viewModel.isRegeneratingSeedphrase { + ProgressView().scaleEffect(0.7) + } + Spacer() + } + .disabled(viewModel.isRegeneratingSeedphrase) + } else { + HStack { + Button(action: { + viewModel.confirmGenerateSeedphrase() + }) { + Text("Generate Seedphrase") + } + if viewModel.isGeneratingSeedphrase { + ProgressView().scaleEffect(0.7) + } + Spacer() + } + .disabled(viewModel.isGeneratingSeedphrase) + } + } else { + Text("Loading...") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + + Text("A seedphrase lets you recover your account if you lose access.") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + } + .padding() + .background(themeManager.currentTheme.tintedBackgroundBase) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + Spacer().frame(height: 32) + + // MARK: - Sign-In Methods + + HStack { + UrLabel(text: "Sign-In Methods") + + Spacer() + } + + VStack(alignment: .leading) { + if let networkUser = networkUserViewModel?.networkUser { + let authMethods = parseAuthMethods(networkUser) + + ForEach(authMethods, id: \.self) { method in + HStack { + Text(methodDisplayName(method)) + .font(themeManager.currentTheme.bodyFont) + Spacer() + Button(role: .destructive) { + viewModel.presentRemoveAuth(method) + } label: { + Text("Remove") + } + } + Spacer().frame(height: 8) + } + + Spacer().frame(height: 4) + + Button(action: { + viewModel.presentAddAuthSheet = true + }) { + Text("Add sign-in method") + } + } else { + HStack { + Text("Loading sign-in methods...") + .font(themeManager.currentTheme.secondaryBodyFont) + .foregroundColor(themeManager.currentTheme.textMutedColor) + Spacer() + ProgressView().scaleEffect(0.7) + } + } + } + .padding() + .background(themeManager.currentTheme.tintedBackgroundBase) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + Spacer().frame(height: 32) + + /** + * System + */ HStack { UrLabel(text: "System") @@ -235,7 +344,6 @@ struct SettingsForm_macOS: View { .contentShape(Rectangle()) .onTapGesture { navigate(.transferBalanceCodes) - // navigate to blocked } @@ -367,7 +475,6 @@ struct SettingsForm_macOS: View { .contentShape(Rectangle()) .onTapGesture { navigate(.blockedLocations) - // navigate to blocked } } @@ -496,7 +603,7 @@ struct SettingsForm_macOS: View { } Spacer().frame(height: 64) - + Button(role: .destructive, action: { presentDeleteAccountConfirmation() }) { @@ -514,6 +621,7 @@ struct SettingsForm_macOS: View { } } } + } #endif diff --git a/app/network/Main/Account/Settings/SettingsView.swift b/app/network/Main/Account/Settings/SettingsView.swift index 0239f7c1..2e5104ae 100644 --- a/app/network/Main/Account/Settings/SettingsView.swift +++ b/app/network/Main/Account/Settings/SettingsView.swift @@ -27,6 +27,7 @@ struct SettingsView: View { let api: UrApiServiceProtocol let navigate: (AccountNavigationPath) -> Void let providerCountries: [SdkConnectLocation] + let networkUserViewModel: NetworkUserViewModel? init( api: UrApiServiceProtocol, @@ -35,9 +36,10 @@ struct SettingsView: View { referralLinkViewModel: ReferralLinkViewModel, accountWalletsViewModel: AccountWalletsViewModel, navigate: @escaping (AccountNavigationPath) -> Void, - providerCountries: [SdkConnectLocation] + providerCountries: [SdkConnectLocation], + networkUserViewModel: NetworkUserViewModel? = nil ) { - _viewModel = StateObject(wrappedValue: ViewModel(api: api)) + _viewModel = StateObject(wrappedValue: ViewModel(api: api, networkUserViewModel: networkUserViewModel)) self.clientId = clientId self.accountPreferencesViewModel = accountPreferencesViewModel self.referralLinkViewModel = referralLinkViewModel @@ -45,10 +47,11 @@ struct SettingsView: View { self.navigate = navigate self.providerCountries = providerCountries self.api = api + self.networkUserViewModel = networkUserViewModel } var body: some View { - + #if os(iOS) SettingsForm_iOS( urApiService: api, @@ -73,6 +76,8 @@ struct SettingsView: View { presentRenameDevice: viewModel.presentRenameDevice, canReceiveNotifications: $viewModel.canReceiveNotifications, canReceiveProductUpdates: $accountPreferencesViewModel.canReceiveProductUpdates, + networkUserViewModel: networkUserViewModel, + viewModel: viewModel, ) .background(themeManager.currentTheme.backgroundColor) .task { @@ -107,6 +112,9 @@ struct SettingsView: View { } } + .onOpenURL { url in + handleWalletDeepLink(url) + } .sheet(isPresented: $viewModel.presentUpdateReferralNetworkSheet) { UpdateReferralNetworkSheet( api: api, @@ -125,6 +133,51 @@ struct SettingsView: View { .presentationDetents([.height(268)]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $viewModel.presentSeedphraseSheet) { + SeedphraseDisplayView( + seedphrase: viewModel.generatedSeedphrase, + onConfirmed: { _ in + viewModel.dismissSeedphraseSheet() + } + ) + .environmentObject(themeManager) + } + .confirmationDialog( + "Generate a recovery seedphrase?", + isPresented: $viewModel.presentSeedphraseConfirmation, + titleVisibility: .visible + ) { + Button("Generate") { + Task { + await viewModel.executePendingSeedphraseAction() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("A seedphrase lets you recover your account if you lose access. Store it safely.") + } + .confirmationDialog( + "Remove this sign-in method?", + isPresented: $viewModel.presentRemoveAuthConfirmation, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + Task { + await viewModel.executeRemoveAuth() + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let authType = viewModel.authTypeToRemove { + Text("Are you sure you want to remove \(authType) as a sign-in method?") + } + } + .sheet(isPresented: $viewModel.presentAddAuthSheet) { + AddAuthSheet(api: api, networkUserViewModel: networkUserViewModel) + .environmentObject(themeManager) + .environmentObject(snackbarManager) + .environmentObject(connectWalletProviderViewModel) + } #elseif os(macOS) SettingsForm_macOS( @@ -150,7 +203,9 @@ struct SettingsView: View { presentRenameDevice: viewModel.presentRenameDevice, canReceiveNotifications: $viewModel.canReceiveNotifications, canReceiveProductUpdates: $accountPreferencesViewModel.canReceiveProductUpdates, - launchAtStartupEnabled: $viewModel.launchAtStartupEnabled + launchAtStartupEnabled: $viewModel.launchAtStartupEnabled, + networkUserViewModel: networkUserViewModel, + viewModel: viewModel ) .task { await viewModel.fetchDeviceInfo(clientId) @@ -200,11 +255,76 @@ struct SettingsView: View { ) .environmentObject(themeManager) } + .sheet(isPresented: $viewModel.presentSeedphraseSheet) { + SeedphraseDisplayView( + seedphrase: viewModel.generatedSeedphrase, + onConfirmed: { _ in + viewModel.dismissSeedphraseSheet() + } + ) + .environmentObject(themeManager) + } + .confirmationDialog( + "Generate a recovery seedphrase?", + isPresented: $viewModel.presentSeedphraseConfirmation, + titleVisibility: .visible + ) { + Button("Generate") { + Task { + await viewModel.executePendingSeedphraseAction() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("A seedphrase lets you recover your account if you lose access. Store it safely.") + } + .confirmationDialog( + "Remove this sign-in method?", + isPresented: $viewModel.presentRemoveAuthConfirmation, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + Task { + await viewModel.executeRemoveAuth() + } + } + Button("Cancel", role: .cancel) {} + } message: { + if let authType = viewModel.authTypeToRemove { + Text("Are you sure you want to remove \(authType) as a sign-in method?") + } + } + .sheet(isPresented: $viewModel.presentAddAuthSheet) { + AddAuthSheet(api: api, networkUserViewModel: networkUserViewModel) + .environmentObject(themeManager) + .environmentObject(snackbarManager) + .environmentObject(connectWalletProviderViewModel) + } #endif } + private func handleWalletDeepLink(_ url: URL) { + let vm = connectWalletProviderViewModel + if vm.pendingAddAuthSignatureHandler != nil, + let pk = vm.connectedPublicKey { + vm.handleDeepLink( + url, + onSignature: { signature in + if let handler = vm.pendingAddAuthSignatureHandler { + Task { + await handler(pk, signature) + } + } + }, + onError: { _ in } + ) + } else { + vm.handleDeepLink(url) + } + } + private func saveDeviceName() async { let result = await viewModel.updateDeviceName() switch result { @@ -237,17 +357,3 @@ struct SettingsView: View { #endif } } - -//#Preview { -// -// let themeManager = ThemeManager.shared -// let accountPreferenceViewModel = AccountPreferencesViewModel(api: SdkApi()) -// -// SettingsView( -// api: SdkApi(), -// clientId: nil, -// accountPreferencesViewModel: accountPreferenceViewModel -// ) -// .environmentObject(themeManager) -// .background(themeManager.currentTheme.backgroundColor.ignoresSafeArea()) -//} diff --git a/app/network/Main/Account/Settings/SettingsViewModel.swift b/app/network/Main/Account/Settings/SettingsViewModel.swift index 74acd8a8..b06c395c 100644 --- a/app/network/Main/Account/Settings/SettingsViewModel.swift +++ b/app/network/Main/Account/Settings/SettingsViewModel.swift @@ -20,11 +20,14 @@ extension SettingsView { let api: UrApiServiceProtocol @Published var presentUpdateReferralNetworkSheet: Bool = false - + @Published var version: String = "" - init(api: UrApiServiceProtocol) { + var networkUserViewModel: NetworkUserViewModel? + + init(api: UrApiServiceProtocol, networkUserViewModel: NetworkUserViewModel? = nil) { self.api = api + self.networkUserViewModel = networkUserViewModel #if os(macOS) self.launchAtStartupEnabled = SMAppService.mainApp.status == .enabled @@ -233,7 +236,6 @@ extension SettingsView { } catch(let error) { print("\(domain) Error fetching transfer stats: \(error)") - // isLoadingTransferStats = false } } @@ -264,6 +266,123 @@ extension SettingsView { } #endif + // MARK: - Seedphrase Management + + @Published var presentSeedphraseSheet: Bool = false + @Published var generatedSeedphrase: String = "" + @Published var isGeneratingSeedphrase: Bool = false + @Published var isRegeneratingSeedphrase: Bool = false + @Published var presentSeedphraseConfirmation: Bool = false + @Published var seedphraseError: String? + + private var pendingSeedphraseAction: SeedphraseAction = .generate + + enum SeedphraseAction { + case generate + case regenerate + } + + func confirmGenerateSeedphrase() { + pendingSeedphraseAction = .generate + presentSeedphraseConfirmation = true + } + + func confirmRegenerateSeedphrase() { + pendingSeedphraseAction = .regenerate + presentSeedphraseConfirmation = true + } + + func executePendingSeedphraseAction() async { + switch pendingSeedphraseAction { + case .generate: + await executeGenerateSeedphrase() + case .regenerate: + await executeRegenerateSeedphrase() + } + } + + func executeGenerateSeedphrase() async { + isGeneratingSeedphrase = true + seedphraseError = nil + + do { + let result = try await api.generateSeedphrase() + self.generatedSeedphrase = result.seedphrase + self.isGeneratingSeedphrase = false + self.presentSeedphraseConfirmation = false + self.presentSeedphraseSheet = true + // Refresh user data to get updated auth_types + Task { [weak self] in + _ = await self?.networkUserViewModel?.refreshNetworkUser() + } + } catch(let error) { + self.isGeneratingSeedphrase = false + self.presentSeedphraseConfirmation = false + self.seedphraseError = error.localizedDescription + } + } + + func executeRegenerateSeedphrase() async { + isRegeneratingSeedphrase = true + seedphraseError = nil + + do { + let result = try await api.regenerateSeedphrase() + self.generatedSeedphrase = result.seedphrase + self.isRegeneratingSeedphrase = false + self.presentSeedphraseConfirmation = false + self.presentSeedphraseSheet = true + // Refresh user data to get updated auth_types + Task { [weak self] in + _ = await self?.networkUserViewModel?.refreshNetworkUser() + } + } catch(let error) { + self.isRegeneratingSeedphrase = false + self.presentSeedphraseConfirmation = false + self.seedphraseError = error.localizedDescription + } + } + + func dismissSeedphraseSheet() { + self.presentSeedphraseSheet = false + self.generatedSeedphrase = "" + } + + // MARK: - Auth Method Management + + @Published var presentAddAuthSheet: Bool = false + @Published var presentRemoveAuthConfirmation: Bool = false + @Published var authTypeToRemove: String? + @Published var isAddingAuth: Bool = false + @Published var isRemovingAuth: Bool = false + @Published var addAuthError: String? + @Published var removeAuthError: String? + + func presentRemoveAuth(_ authType: String) { + self.authTypeToRemove = authType + self.presentRemoveAuthConfirmation = true + } + + func executeRemoveAuth() async { + guard let authType = authTypeToRemove else { return } + isRemovingAuth = true + removeAuthError = nil + + do { + let _ = try await api.removeAuth(authType: authType) + self.isRemovingAuth = false + self.presentRemoveAuthConfirmation = false + self.authTypeToRemove = nil + // Refresh user data to get updated auth_types + Task { [weak self] in + _ = await self?.networkUserViewModel?.refreshNetworkUser() + } + } catch(let error) { + self.isRemovingAuth = false + self.removeAuthError = error.localizedDescription + } + } + } } diff --git a/app/network/NetworkApp.swift b/app/network/NetworkApp.swift index 14ffb520..46a2b661 100644 --- a/app/network/NetworkApp.swift +++ b/app/network/NetworkApp.swift @@ -20,9 +20,10 @@ struct NetworkApp: App { #endif @AppStorage("showMenuBarExtra") private var showMenuBarExtra = true - + @State private var isWindowVisible = true @State private var keyEventMonitor: Any? + @Environment(\.scenePhase) private var scenePhase let themeManager = ThemeManager.shared @@ -90,6 +91,21 @@ struct NetworkApp: App { dnsSettingsStore.reset() networkPeersStore.reset() } + + // Cold launch already gets a fresh JWT (the SDK's token manager refreshes + // once immediately on start), but returning from the background does not + // -- the token manager only refreshes on a schedule (~14 days out from + // expiration) or when explicitly asked. Force one here so anything that + // could have changed out-of-band (e.g. a network name changed from + // another device or the web dashboard) shows up when the user comes + // back to the app, not just after a local rename or a full logout/login. + private func refreshJwtOnForeground() { + do { + try deviceManager.device?.refreshToken(0) + } catch { + print("Error refreshing JWT on foreground: \(error)") + } + } private var connectEnabled: Bool { @@ -160,6 +176,11 @@ struct NetworkApp: App { .onReceive(deviceManager.$device) { device in updateConnectViewModel(device) } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + refreshJwtOnForeground() + } + } #elseif os(macOS) ContentView() .environmentObject(themeManager) @@ -188,6 +209,11 @@ struct NetworkApp: App { .onReceive(deviceManager.$device) { device in updateConnectViewModel(device) } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + refreshJwtOnForeground() + } + } .onAppear { if mainWindow == nil { mainWindow = NSApplication.shared.windows.first { window in diff --git a/app/network/Shared/Resources/Localizable.xcstrings b/app/network/Shared/Resources/Localizable.xcstrings index ff73f85c..65ab2865 100644 --- a/app/network/Shared/Resources/Localizable.xcstrings +++ b/app/network/Shared/Resources/Localizable.xcstrings @@ -14788,173 +14788,6 @@ } } }, - "Confirm Seeker Token" : { - "comment" : "Used in Main/Account/Settings/SettingsView.swift.", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تأكيد رمز Seeker" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potvrďte Seeker Token" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seeker-Token bestätigen" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Επιβεβαίωση Token Seeker" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirm Seeker Token" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token de Seeker" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token de Seeker" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token de Seeker" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmer le jeton Seeker" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אמת אסימון Seeker" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "सीकर टोकन की पुष्टि करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Konfirmasi Token Seeker" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conferma Token Seeker" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "シーカートークンを確認" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seeker 토큰 확인" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bevestig Seeker Token" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potwierdź token Seeker" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token Seeker" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token Seeker" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Confirmar Token Seeker" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Подтвердить токен Seeker" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bekräfta Seeker-token" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ยืนยัน Seeker Token" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підтвердити токен Seeker" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Xác nhận Token Seeker" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "确认 Seeker 令牌" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "確認 Seeker 代幣" - } - } - } - }, "Connect" : { "comment" : "Button text to connect to URnetwork VPN", "localizations" : { diff --git a/app/network/Shared/Services/UrApiService/MockUrApiService.swift b/app/network/Shared/Services/UrApiService/MockUrApiService.swift index f27dd46c..dc338996 100644 --- a/app/network/Shared/Services/UrApiService/MockUrApiService.swift +++ b/app/network/Shared/Services/UrApiService/MockUrApiService.swift @@ -49,10 +49,6 @@ class MockUrApiService: UrApiServiceProtocol { SdkValidateReferralCodeResult() } - func upgradeGuest(_ args: SdkUpgradeGuestArgs) async throws -> LoginNetworkResult { - return LoginNetworkResult.failure(LoginError.appleLoginFailed) - } - func fetchSubscriptionBalance() async throws -> SdkSubscriptionBalanceResult { return SdkSubscriptionBalanceResult() } @@ -97,6 +93,38 @@ class MockUrApiService: UrApiServiceProtocol { return SdkUnlinkReferralNetworkResult() } + func loginWithSeedphrase(seedphrase: String) async throws -> AuthLoginResult { + return AuthLoginResult.failure(LoginError.inProgress) + } + + func createInstantAccount() async throws -> (jwt: String, seedphrase: String) { + return ("mock-jwt", "mock seedphrase words here for testing purposes only") + } + + func generateSeedphrase() async throws -> SdkGenerateSeedphraseResult { + return SdkGenerateSeedphraseResult() + } + + func regenerateSeedphrase() async throws -> SdkRegenerateSeedphraseResult { + return SdkRegenerateSeedphraseResult() + } + + func addAuth(_ args: SdkAddAuthArgs) async throws -> SdkAddAuthResult { + return SdkAddAuthResult() + } + + func removeAuth(authType: String) async throws -> SdkRemoveAuthResult { + return SdkRemoveAuthResult() + } + + func changeNetworkName(_ newName: String) async throws -> SdkChangeNetworkNameResult { + return SdkChangeNetworkNameResult() + } + + func claimNetworkName(_ newName: String) async throws -> SdkClaimNetworkNameResult { + return SdkClaimNetworkNameResult() + } + func redeemBalanceCode(_ code: String) async throws -> SdkRedeemBalanceCodeResult { return SdkRedeemBalanceCodeResult() } diff --git a/app/network/Shared/Services/UrApiService/UrApiService.swift b/app/network/Shared/Services/UrApiService/UrApiService.swift index 7eceb04b..0eac82fa 100644 --- a/app/network/Shared/Services/UrApiService/UrApiService.swift +++ b/app/network/Shared/Services/UrApiService/UrApiService.swift @@ -395,7 +395,56 @@ extension UrApiService { } } - func createNetwork(_ args: SdkNetworkCreateArgs) async throws -> LoginNetworkResult { + func loginWithSeedphrase(seedphrase: String) async throws -> AuthLoginResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = AuthLoginCallback { result, error in + + if let error { + continuation.resume(throwing: error) + return + } + + guard let result else { + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No result found"])) + return + } + + if let resultError = result.error { + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "result.error exists \(resultError.message)"])) + return + } + + if let network = result.network { + switch self.nonEmptyJwt(network.byJwt, context: "loginWithSeedphrase") { + case .success(let jwt): + continuation.resume(returning: .login(jwt)) + case .failure(let error): + continuation.resume(throwing: error) + } + return + } + + continuation.resume(returning: .failure(NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Seedphrase login returned no network"]))) + + } + + let args = SdkAuthLoginArgs() + // Normalize: lowercase, trim, collapse multiple spaces + let normalized = seedphrase + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + args.seedphrase = normalized + api.authLogin(args, callback: callback) + + } + } + + func createInstantAccount() async throws -> (jwt: String, seedphrase: String) { let api = try requireApi() return try await withCheckedThrowingContinuation { continuation in @@ -407,7 +456,7 @@ extension UrApiService { } guard let result = result else { - continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "createNetwork returned nil result"])) + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "createInstantAccount returned nil result"])) return } @@ -417,14 +466,21 @@ extension UrApiService { } if result.verificationRequired != nil { - continuation.resume(returning: .successWithVerificationRequired) + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Verification required for instant account creation"])) return } + guard !result.seedphrase.isEmpty else { + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No seedphrase in result"])) + return + } + + let seedphrase = result.seedphrase + if let network = result.network { - switch self.nonEmptyJwt(network.byJwt, context: "createNetwork") { + switch self.nonEmptyJwt(network.byJwt, context: "createInstantAccount") { case .success(let jwt): - continuation.resume(returning: .successWithJwt(jwt)) + continuation.resume(returning: (jwt, seedphrase)) case .failure(let error): continuation.resume(throwing: error) } @@ -436,16 +492,19 @@ extension UrApiService { } + let args = SdkNetworkCreateArgs() + args.terms = true + // No userAuth, password, authJwt, walletAuth — triggers seedphrase path api.networkCreate(args, callback: callback) } } - func upgradeGuest(_ args: SdkUpgradeGuestArgs) async throws -> LoginNetworkResult { + func createNetwork(_ args: SdkNetworkCreateArgs) async throws -> LoginNetworkResult { let api = try requireApi() return try await withCheckedThrowingContinuation { continuation in - let callback = UpgradeGuestCallback { result, err in + let callback = NetworkCreateCallback { result, err in if let err = err { continuation.resume(throwing: err) @@ -453,12 +512,12 @@ extension UrApiService { } guard let result = result else { - continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "upgradeGuest returned nil result"])) + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: "createNetwork returned nil result"])) return } if let resultError = result.error { - continuation.resume(throwing: UrApiError.resultError(message: resultError.message)) + continuation.resume(throwing: NSError(domain: "UrApiService", code: -1, userInfo: [NSLocalizedDescriptionKey: resultError.message])) return } @@ -468,7 +527,7 @@ extension UrApiService { } if let network = result.network { - switch self.nonEmptyJwt(network.byJwt, context: "upgradeGuest") { + switch self.nonEmptyJwt(network.byJwt, context: "createNetwork") { case .success(let jwt): continuation.resume(returning: .successWithJwt(jwt)) case .failure(let error): @@ -476,19 +535,219 @@ extension UrApiService { } return } else { - continuation.resume(throwing: NSError(domain: "UrApiService", code: 0, userInfo: [NSLocalizedDescriptionKey: "No network found in result"])) + continuation.resume(throwing: NSError(domain: "UrApiService", code: 0, userInfo: [NSLocalizedDescriptionKey: "No network object found in result"])) return } } - api.upgradeGuest(args, callback: callback) + api.networkCreate(args, callback: callback) } } +} + +// MARK: — Account Management: Seedphrase + +extension UrApiService { + + func generateSeedphrase() async throws -> SdkGenerateSeedphraseResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = GenerateSeedphraseCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "generateSeedphrase returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkGenerateSeedphraseArgs() + api.generateSeedphrase(args, callback: callback) + } + } + + func regenerateSeedphrase() async throws -> SdkRegenerateSeedphraseResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = RegenerateSeedphraseCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "regenerateSeedphrase returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkRegenerateSeedphraseArgs() + api.regenerateSeedphrase(args, callback: callback) + } + } + +} + +// MARK: — Account Management: Auth Methods + +extension UrApiService { + + func addAuth(_ args: SdkAddAuthArgs) async throws -> SdkAddAuthResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = AddAuthCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "addAuth returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + api.addAuth(args, callback: callback) + } + } + + func removeAuth(authType: String) async throws -> SdkRemoveAuthResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = RemoveAuthCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "removeAuth returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkRemoveAuthArgs() + args.authType = authType + api.removeAuth(args, callback: callback) + } + } + +} + +// MARK: — Account Management: Network Name + +extension UrApiService { + + func changeNetworkName(_ newName: String) async throws -> SdkChangeNetworkNameResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = ChangeNetworkNameCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "changeNetworkName returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkChangeNetworkNameArgs() + args.newName = newName + api.changeNetworkName(args, callback: callback) + } + } + + func claimNetworkName(_ newName: String) async throws -> SdkClaimNetworkNameResult { + let api = try requireApi() + return try await withCheckedThrowingContinuation { continuation in + + let callback = ClaimNetworkNameCallback { result, err in + + if let err = err { + continuation.resume(throwing: err) + return + } + + guard let result = result else { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: "claimNetworkName returned nil result"])) + return + } + + if let errMsg = result.error?.message { + continuation.resume(throwing: NSError(domain: self.domain, code: -1, userInfo: [NSLocalizedDescriptionKey: errMsg])) + return + } + + continuation.resume(returning: result) + } + + let args = SdkClaimNetworkNameArgs() + args.newName = newName + api.claimNetworkName(args, callback: callback) + } + } + +} + +// MARK: wallet + +extension UrApiService { + func createAuthCode() async throws -> SdkAuthCodeCreateResult { - + let api = try requireApi() return try await withCheckedThrowingContinuation { continuation in @@ -1049,12 +1308,6 @@ private class ValidateReferralCallback: SdkCallback, SdkUpgradeGuestCallbackProtocol { - func result(_ result: SdkUpgradeGuestResult?, err: Error?) { - handleResult(result, err: err) - } -} - private class GetSubscriptionBalanceCallback: SdkCallback, SdkSubscriptionBalanceCallbackProtocol { func result(_ result: SdkSubscriptionBalanceResult?, err: Error?) { @@ -1304,6 +1557,44 @@ private class DeviceSetNameCallback: SdkCallback< } } +// MARK: — Account Management Callbacks + +private class GenerateSeedphraseCallback: SdkCallback, SdkGenerateSeedphraseCallbackProtocol { + func result(_ result: SdkGenerateSeedphraseResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class RegenerateSeedphraseCallback: SdkCallback, SdkRegenerateSeedphraseCallbackProtocol { + func result(_ result: SdkRegenerateSeedphraseResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class AddAuthCallback: SdkCallback, SdkAddAuthCallbackProtocol { + func result(_ result: SdkAddAuthResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class RemoveAuthCallback: SdkCallback, SdkRemoveAuthCallbackProtocol { + func result(_ result: SdkRemoveAuthResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class ChangeNetworkNameCallback: SdkCallback, SdkChangeNetworkNameCallbackProtocol { + func result(_ result: SdkChangeNetworkNameResult?, err: Error?) { + handleResult(result, err: err) + } +} + +private class ClaimNetworkNameCallback: SdkCallback, SdkClaimNetworkNameCallbackProtocol { + func result(_ result: SdkClaimNetworkNameResult?, err: Error?) { + handleResult(result, err: err) + } +} + enum DeviceInfoError: Error { case resultEmpty case resultError(message: String) diff --git a/app/network/Shared/Services/UrApiService/UrApiServiceProtocol.swift b/app/network/Shared/Services/UrApiService/UrApiServiceProtocol.swift index c1e481e9..1cd7c201 100644 --- a/app/network/Shared/Services/UrApiService/UrApiServiceProtocol.swift +++ b/app/network/Shared/Services/UrApiService/UrApiServiceProtocol.swift @@ -34,7 +34,6 @@ protocol UrApiServiceProtocol { func authLogin(_ args: SdkAuthLoginArgs) async throws -> AuthLoginResult func createNetwork(_ args: SdkNetworkCreateArgs) async throws -> LoginNetworkResult func validateReferralCode(_ code: String) async throws -> SdkValidateReferralCodeResult - func upgradeGuest(_ args: SdkUpgradeGuestArgs) async throws -> LoginNetworkResult func createAuthCode() async throws -> SdkAuthCodeCreateResult func authCodeLogin(_ args: SdkAuthCodeLoginArgs) async throws -> SdkAuthCodeLoginResult func authWalletChallenge(_ args: SdkAuthWalletChallengeArgs) async throws -> SdkAuthWalletChallengeResult @@ -66,11 +65,31 @@ protocol UrApiServiceProtocol { /** * Settings */ + func loginWithSeedphrase(seedphrase: String) async throws -> AuthLoginResult + func createInstantAccount() async throws -> (jwt: String, seedphrase: String) func deleteAccount() async throws -> SdkNetworkDeleteResult func getReferralNetwork() async throws -> SdkGetReferralNetworkResult func setNetworkReferral(_ referralCode: String) async throws -> SdkSetNetworkReferralResult func unlinkReferralNetwork() async throws -> SdkUnlinkReferralNetworkResult + /** + * Account Management — Seedphrase + */ + func generateSeedphrase() async throws -> SdkGenerateSeedphraseResult + func regenerateSeedphrase() async throws -> SdkRegenerateSeedphraseResult + + /** + * Account Management — Auth Methods + */ + func addAuth(_ args: SdkAddAuthArgs) async throws -> SdkAddAuthResult + func removeAuth(authType: String) async throws -> SdkRemoveAuthResult + + /** + * Account Management — Network Name + */ + func changeNetworkName(_ newName: String) async throws -> SdkChangeNetworkNameResult + func claimNetworkName(_ newName: String) async throws -> SdkClaimNetworkNameResult + /** * Device */ diff --git a/app/network/Shared/ViewModels/ConnectWalletProviderViewModel.swift b/app/network/Shared/ViewModels/ConnectWalletProviderViewModel.swift index 82f7b79d..af751c7d 100644 --- a/app/network/Shared/ViewModels/ConnectWalletProviderViewModel.swift +++ b/app/network/Shared/ViewModels/ConnectWalletProviderViewModel.swift @@ -53,6 +53,12 @@ class ConnectWalletProviderViewModel: ObservableObject { private let bittensorSignMessageRedirectLink = "urnetwork://bittensor-sign-message" private let bittensorConnectRedirectLink = "urnetwork://bittensor-connect" + // When set, the wallet deep link onSignature routes here instead of the default multiplier claim flow + var pendingAddAuthSignatureHandler: ((String, String) async -> Void)? + + // Challenge message to sign during wallet auth addition + var pendingWalletAuthMessage: String? + init() { self.createKeyPair() } diff --git a/app/network/Shared/ViewModels/GuestUpgradeViewModel.swift b/app/network/Shared/ViewModels/GuestUpgradeViewModel.swift deleted file mode 100644 index 99a04080..00000000 --- a/app/network/Shared/ViewModels/GuestUpgradeViewModel.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// GuestUpgradeViewModel.swift -// URnetwork -// -// Created by Stuart Kuentzel on 2025/01/19. -// - -import Foundation -import URnetworkSdk - -@MainActor -class GuestUpgradeViewModel: ObservableObject { - - @Published private(set) var isUpgrading: Bool = false - - private var api: SdkApi - - let domain = "[GuestUpgradeViewModel]" - - init(api: SdkApi) { - self.api = api - } - - func linkGuestToExistingLogin(args: SdkUpgradeGuestExistingArgs) async -> AuthLoginResult { - - if isUpgrading { - return .failure(LoginError.inProgress) - } - - isUpgrading = true - defer { - isUpgrading = false - } - - do { - - let result: AuthLoginResult = try await withCheckedThrowingContinuation { continuation in - - let callback = UpgradeGuestExistingCallback { result, error in - - guard let result else { - continuation.resume(throwing: error ?? NSError(domain: "[GuestUpgradeViewModel]", code: -1, userInfo: [NSLocalizedDescriptionKey: "No result found"])) - - return - } - - if let resultError = result.error { - - continuation.resume(throwing: NSError(domain: "[GuestUpgradeViewModel]", code: -1, userInfo: [NSLocalizedDescriptionKey: "result.error exists \(resultError.message)"])) - - return - } - - /** - * In the case a guest user is upgrading to an existing account - * but the existing account has not yet been verified - */ - if let verificationRequired = result.verificationRequired { - continuation.resume(returning: .verificationRequired(verificationRequired.userAuth)) - return - } - - /** - * JWT exists, proceed to authenticate network - */ - if let network = result.network { - guard !network.byJwt.isEmpty else { - continuation.resume(throwing: NSError(domain: "[GuestUpgradeViewModel]", code: -1, userInfo: [NSLocalizedDescriptionKey: "byJWT is empty"])) - return - } - - continuation.resume(returning: .login(network.byJwt)) - return - } - - /** - * No network exists - * Navigate to create view - */ - let authLoginArgs = SdkAuthLoginArgs() - - authLoginArgs.authJwt = args.authJwt - authLoginArgs.authJwtType = args.authJwtType - authLoginArgs.userAuth = args.userAuth - authLoginArgs.walletAuth = args.walletAuth - - - continuation.resume(returning: .create(authLoginArgs)) - - - } - - api.upgradeGuestExisting(args, callback: callback) - - } - - return result - - } catch(let error) { - return .failure(error) - } - - } - -} - -private class UpgradeGuestExistingCallback: SdkCallback, SdkUpgradeGuestExistingCallbackProtocol { - func result(_ result: SdkUpgradeGuestExistingResult?, err: Error?) { - handleResult(result, err: err) - } -}