2023-08-07 11:55:59 -07:00
|
|
|
// Copyright 2020 Google LLC
|
|
|
|
|
//
|
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
//
|
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
//
|
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
|
// limitations under the License.
|
|
|
|
|
|
|
|
|
|
import AuthenticationServices
|
|
|
|
|
import CryptoKit
|
2023-11-21 21:25:23 -08:00
|
|
|
import FirebaseAuth
|
|
|
|
|
import UIKit
|
2023-08-07 11:55:59 -07:00
|
|
|
|
|
|
|
|
class UserViewController: UIViewController, DataSourceProviderDelegate {
|
|
|
|
|
var dataSourceProvider: DataSourceProvider<User>!
|
|
|
|
|
|
|
|
|
|
var userImage = UIImageView(systemImageName: "person.circle.fill", tintColor: .secondaryLabel)
|
|
|
|
|
var tableView: UITableView { view as! UITableView }
|
|
|
|
|
|
|
|
|
|
private var _user: User?
|
|
|
|
|
var user: User? {
|
2023-08-24 15:42:51 -07:00
|
|
|
get { _user ?? AppManager.shared.auth().currentUser }
|
2023-08-07 11:55:59 -07:00
|
|
|
set { _user = newValue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Init allows for injecting a `User` instance during UI Testing
|
|
|
|
|
/// - Parameter user: A Firebase User instance
|
|
|
|
|
init(_ user: User? = nil) {
|
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
|
self.user = user
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(*, unavailable)
|
|
|
|
|
required init?(coder: NSCoder) {
|
|
|
|
|
fatalError("init(coder:) has not been implemented")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - UIViewController Life Cycle
|
|
|
|
|
|
|
|
|
|
override func loadView() {
|
|
|
|
|
view = UITableView(frame: .zero, style: .insetGrouped)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func viewDidLoad() {
|
|
|
|
|
super.viewDidLoad()
|
|
|
|
|
configureNavigationBar()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
|
|
|
super.viewWillAppear(animated)
|
|
|
|
|
configureDataSourceProvider()
|
|
|
|
|
updateUserImage()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - DataSourceProviderDelegate
|
|
|
|
|
|
|
|
|
|
func tableViewDidScroll(_ tableView: UITableView) {
|
|
|
|
|
adjustUserImageAlpha(tableView.contentOffset.y)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func didSelectRowAt(_ indexPath: IndexPath, on tableView: UITableView) {
|
|
|
|
|
let item = dataSourceProvider.item(at: indexPath)
|
2023-08-21 16:47:22 -07:00
|
|
|
let actionName = item.isEditable ? item.detailTitle! : item.title!
|
2023-08-07 11:55:59 -07:00
|
|
|
|
2023-08-21 16:47:22 -07:00
|
|
|
guard let action = UserAction(rawValue: actionName) else {
|
2023-08-07 11:55:59 -07:00
|
|
|
// The row tapped has no affiliated action.
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch action {
|
|
|
|
|
case .signOut:
|
|
|
|
|
signCurrentUserOut()
|
|
|
|
|
|
|
|
|
|
case .link:
|
|
|
|
|
linkUserToOtherAuthProviders()
|
|
|
|
|
|
|
|
|
|
case .requestVerifyEmail:
|
|
|
|
|
requestVerifyEmail()
|
|
|
|
|
|
|
|
|
|
case .tokenRefresh:
|
|
|
|
|
refreshCurrentUserIDToken()
|
|
|
|
|
|
|
|
|
|
case .delete:
|
|
|
|
|
deleteCurrentUser()
|
|
|
|
|
|
|
|
|
|
case .updateEmail:
|
2023-08-21 16:47:22 -07:00
|
|
|
presentEditUserInfoController(for: actionName, to: updateUserEmail)
|
2023-08-07 11:55:59 -07:00
|
|
|
|
2024-05-06 16:15:39 -07:00
|
|
|
case .updatePassword:
|
|
|
|
|
presentEditUserInfoController(for: actionName, to: updatePassword)
|
|
|
|
|
|
2023-08-07 11:55:59 -07:00
|
|
|
case .updateDisplayName:
|
2023-08-21 16:47:22 -07:00
|
|
|
presentEditUserInfoController(for: actionName, to: updateUserDisplayName)
|
2023-08-07 11:55:59 -07:00
|
|
|
|
|
|
|
|
case .updatePhotoURL:
|
2023-08-21 16:47:22 -07:00
|
|
|
presentEditUserInfoController(for: actionName, to: updatePhotoURL)
|
2023-08-21 07:28:42 -07:00
|
|
|
|
|
|
|
|
case .updatePhoneNumber:
|
|
|
|
|
presentEditUserInfoController(
|
2023-08-21 16:47:22 -07:00
|
|
|
for: actionName + " formatted like +16509871234",
|
2023-08-21 07:28:42 -07:00
|
|
|
to: updatePhoneNumber
|
|
|
|
|
)
|
2023-08-07 11:55:59 -07:00
|
|
|
|
|
|
|
|
case .refreshUserInfo:
|
|
|
|
|
refreshUserInfo()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Firebase 🔥
|
|
|
|
|
|
|
|
|
|
public func signCurrentUserOut() {
|
2023-08-24 15:42:51 -07:00
|
|
|
try? AppManager.shared.auth().signOut()
|
2023-08-07 11:55:59 -07:00
|
|
|
updateUI()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func linkUserToOtherAuthProviders() {
|
|
|
|
|
guard let user = user else { return }
|
|
|
|
|
let accountLinkingController = AccountLinkingViewController(for: user)
|
|
|
|
|
let navController = UINavigationController(rootViewController: accountLinkingController)
|
|
|
|
|
navigationController?.present(navController, animated: true, completion: nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func requestVerifyEmail() {
|
|
|
|
|
user?.sendEmailVerification { error in
|
|
|
|
|
guard error == nil else { return self.displayError(error) }
|
|
|
|
|
print("Verification email sent!")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func refreshCurrentUserIDToken() {
|
|
|
|
|
let forceRefresh = true
|
|
|
|
|
user?.getIDTokenForcingRefresh(forceRefresh) { token, error in
|
|
|
|
|
guard error == nil else { return self.displayError(error) }
|
|
|
|
|
if let token = token {
|
|
|
|
|
print("New token: \(token)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func refreshUserInfo() {
|
|
|
|
|
user?.reload { error in
|
|
|
|
|
if let error = error {
|
|
|
|
|
print(error)
|
|
|
|
|
}
|
|
|
|
|
self.updateUI()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func updateUserDisplayName(to newDisplayName: String) {
|
|
|
|
|
let changeRequest = user?.createProfileChangeRequest()
|
|
|
|
|
changeRequest?.displayName = newDisplayName
|
|
|
|
|
changeRequest?.commitChanges { error in
|
|
|
|
|
guard error == nil else { return self.displayError(error) }
|
|
|
|
|
self.updateUI()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func updateUserEmail(to newEmail: String) {
|
|
|
|
|
user?.updateEmail(to: newEmail, completion: { error in
|
|
|
|
|
guard error == nil else { return self.displayError(error) }
|
|
|
|
|
self.updateUI()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-06 16:15:39 -07:00
|
|
|
public func updatePassword(to newPassword: String) {
|
|
|
|
|
user?.updatePassword(to: newPassword, completion: {
|
|
|
|
|
error in
|
|
|
|
|
if let error = error {
|
|
|
|
|
print("Update password failed. \(error)", error)
|
|
|
|
|
return
|
|
|
|
|
} else {
|
|
|
|
|
print("Password updated!")
|
|
|
|
|
}
|
|
|
|
|
self.updateUI()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-07 11:55:59 -07:00
|
|
|
public func updatePhotoURL(to newPhotoURL: String) {
|
|
|
|
|
guard let newPhotoURL = URL(string: newPhotoURL) else {
|
|
|
|
|
print("Could not create new photo URL!")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let changeRequest = user?.createProfileChangeRequest()
|
|
|
|
|
changeRequest?.photoURL = newPhotoURL
|
|
|
|
|
changeRequest?.commitChanges { error in
|
|
|
|
|
guard error == nil else { return self.displayError(error) }
|
|
|
|
|
self.updateUI()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-21 07:28:42 -07:00
|
|
|
public func updatePhoneNumber(to newPhoneNumber: String) {
|
|
|
|
|
Task {
|
|
|
|
|
do {
|
|
|
|
|
let phoneAuthProvider = PhoneAuthProvider.provider()
|
|
|
|
|
let verificationID = try await phoneAuthProvider.verifyPhoneNumber(newPhoneNumber)
|
|
|
|
|
let verificationCode = try await getVerificationCode()
|
|
|
|
|
let credential = phoneAuthProvider.credential(withVerificationID: verificationID,
|
|
|
|
|
verificationCode: verificationCode)
|
|
|
|
|
try await user?.updatePhoneNumber(credential)
|
|
|
|
|
self.updateUI()
|
|
|
|
|
} catch {
|
|
|
|
|
self.displayError(error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-07 11:55:59 -07:00
|
|
|
// MARK: - Sign in with Apple Token Revocation Flow
|
|
|
|
|
|
|
|
|
|
// For Sign in with Apple
|
|
|
|
|
private var currentNonce: String?
|
|
|
|
|
|
|
|
|
|
// [START token_revocation_deleteuser]
|
|
|
|
|
private func deleteCurrentUser() {
|
|
|
|
|
do {
|
|
|
|
|
let nonce = try CryptoUtils.randomNonceString()
|
|
|
|
|
currentNonce = nonce
|
|
|
|
|
let appleIDProvider = ASAuthorizationAppleIDProvider()
|
|
|
|
|
let request = appleIDProvider.createRequest()
|
|
|
|
|
request.requestedScopes = [.fullName, .email]
|
|
|
|
|
request.nonce = CryptoUtils.sha256(nonce)
|
|
|
|
|
|
|
|
|
|
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
|
|
|
|
|
authorizationController.delegate = self
|
|
|
|
|
authorizationController.presentationContextProvider = self
|
|
|
|
|
authorizationController.performRequests()
|
|
|
|
|
} catch {
|
|
|
|
|
// In the unlikely case that nonce generation fails, show error view.
|
|
|
|
|
displayError(error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [END token_revocation_deleteuser]
|
|
|
|
|
|
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
|
|
2023-08-21 07:28:42 -07:00
|
|
|
private func getVerificationCode() async throws -> String {
|
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
|
|
|
self.presentEditUserInfoController(for: "Phone Auth Verification Code") { code in
|
|
|
|
|
if code != "" {
|
|
|
|
|
continuation.resume(returning: code)
|
|
|
|
|
} else {
|
|
|
|
|
// Cancelled
|
|
|
|
|
continuation.resume(throwing: NSError())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-07 11:55:59 -07:00
|
|
|
private func configureNavigationBar() {
|
|
|
|
|
navigationItem.title = "User"
|
|
|
|
|
guard let navigationBar = navigationController?.navigationBar else { return }
|
|
|
|
|
navigationBar.prefersLargeTitles = true
|
|
|
|
|
navigationBar.titleTextAttributes = [.foregroundColor: UIColor.systemOrange]
|
|
|
|
|
navigationBar.largeTitleTextAttributes = [.foregroundColor: UIColor.systemOrange]
|
|
|
|
|
navigationBar.addProfilePic(userImage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateUserImage() {
|
|
|
|
|
guard let photoURL = user?.photoURL else {
|
|
|
|
|
let defaultImage = UIImage(systemName: "person.circle.fill")
|
|
|
|
|
userImage.image = defaultImage?.withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userImage.setImage(from: photoURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func configureDataSourceProvider() {
|
|
|
|
|
dataSourceProvider = DataSourceProvider(
|
|
|
|
|
dataSource: user?.sections,
|
|
|
|
|
emptyStateView: SignedOutView(),
|
|
|
|
|
tableView: tableView
|
|
|
|
|
)
|
|
|
|
|
dataSourceProvider.delegate = self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateUI() {
|
|
|
|
|
configureDataSourceProvider()
|
|
|
|
|
animateUpdates(for: tableView)
|
|
|
|
|
updateUserImage()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func animateUpdates(for tableView: UITableView) {
|
|
|
|
|
UIView.transition(with: tableView, duration: 0.2,
|
|
|
|
|
options: .transitionCrossDissolve,
|
|
|
|
|
animations: { tableView.reloadData() })
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-21 07:28:42 -07:00
|
|
|
private func presentEditUserInfoController(for title: String,
|
2023-08-07 11:55:59 -07:00
|
|
|
to saveHandler: @escaping (String) -> Void) {
|
|
|
|
|
let editController = UIAlertController(
|
2023-08-21 07:28:42 -07:00
|
|
|
title: "Update \(title)",
|
2023-08-07 11:55:59 -07:00
|
|
|
message: nil,
|
|
|
|
|
preferredStyle: .alert
|
|
|
|
|
)
|
2023-08-21 07:28:42 -07:00
|
|
|
editController.addTextField { $0.placeholder = "New \(title)" }
|
2023-08-07 11:55:59 -07:00
|
|
|
|
2023-08-21 07:28:42 -07:00
|
|
|
let saveHandler1: (UIAlertAction) -> Void = { _ in
|
2023-08-07 11:55:59 -07:00
|
|
|
let text = editController.textFields!.first!.text!
|
|
|
|
|
saveHandler(text)
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-21 07:28:42 -07:00
|
|
|
let cancel: (UIAlertAction) -> Void = { _ in
|
|
|
|
|
saveHandler("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editController.addAction(UIAlertAction(title: "Save", style: .default, handler: saveHandler1))
|
|
|
|
|
editController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: cancel))
|
2023-08-07 11:55:59 -07:00
|
|
|
present(editController, animated: true, completion: nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var originalOffset: CGFloat?
|
|
|
|
|
|
|
|
|
|
private func adjustUserImageAlpha(_ offset: CGFloat) {
|
|
|
|
|
originalOffset = originalOffset ?? offset
|
|
|
|
|
let verticalOffset = offset - originalOffset!
|
|
|
|
|
userImage.alpha = 1 - (verticalOffset * 0.05)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Implementing Sign in with Apple for the Token Revocation Flow
|
|
|
|
|
|
|
|
|
|
extension UserViewController: ASAuthorizationControllerDelegate,
|
|
|
|
|
ASAuthorizationControllerPresentationContextProviding {
|
|
|
|
|
// MARK: ASAuthorizationControllerDelegate
|
|
|
|
|
|
|
|
|
|
// [START token_revocation]
|
|
|
|
|
func authorizationController(controller: ASAuthorizationController,
|
|
|
|
|
didCompleteWithAuthorization authorization: ASAuthorization) {
|
|
|
|
|
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential
|
|
|
|
|
else {
|
|
|
|
|
print("Unable to retrieve AppleIDCredential")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let _ = currentNonce else {
|
|
|
|
|
fatalError("Invalid state: A login callback was received, but no login request was sent.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let appleAuthCode = appleIDCredential.authorizationCode else {
|
|
|
|
|
print("Unable to fetch authorization code")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let authCodeString = String(data: appleAuthCode, encoding: .utf8) else {
|
|
|
|
|
print("Unable to serialize auth code string from data: \(appleAuthCode.debugDescription)")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
do {
|
2023-08-24 15:42:51 -07:00
|
|
|
try await AppManager.shared.auth().revokeToken(withAuthorizationCode: authCodeString)
|
2023-08-07 11:55:59 -07:00
|
|
|
try await user?.delete()
|
|
|
|
|
self.updateUI()
|
|
|
|
|
} catch {
|
|
|
|
|
self.displayError(error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [END token_revocation]
|
|
|
|
|
|
|
|
|
|
func authorizationController(controller: ASAuthorizationController,
|
|
|
|
|
didCompleteWithError error: Error) {
|
|
|
|
|
// Ensure that you have:
|
|
|
|
|
// - enabled `Sign in with Apple` on the Firebase console
|
|
|
|
|
// - added the `Sign in with Apple` capability for this project
|
|
|
|
|
print("Sign in with Apple failed: \(error)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: ASAuthorizationControllerPresentationContextProviding
|
|
|
|
|
|
|
|
|
|
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
|
|
|
|
return view.window!
|
|
|
|
|
}
|
|
|
|
|
}
|