-
Notifications
You must be signed in to change notification settings - Fork 123
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[feature/password-policy] Password Policy support (#1325)
* - SDK update - ShareViewController: add "generate" button to the password field that uses the password policy's generator to generate a password - BottomButtonBar: add new alternative button to allow for an alternative main action; fix layout bug * - SDK update: latest password policy progress and bug fixes - PasswordComposerViewController: - new view controller to compose passwords - interactive feedback based on OCPasswordPolicy - integration with password generator based on OCPasswordPolicy - ability to copy passwords to clipboard - ability to show/hide entered passwords - support for editing and creation of password strings - ShareViewController: - replace UIAlert with PasswordComposerViewController for entering passwords - add Generate button to generate a password based on the currently applicable password policy - add new "Share" button for links (in addition to "Create") that invoke the share sheet to directly share a link (including password) to the clipboard or directly to other apps like Mail or Messages - add missing localizations - BottomButtonBar: include alternativeButton in .modalActionRunning auto-enable/disable - SegmentViewItem / SegmentViewItemView: - add extension to easily create button segments - add support for UIImage rendering modes for .icon - ThemeCollection: add CSS entry for proper PasswordComposerViewController cell background coloring in dark mode * - add line to copy the password to the clipboard (accidentally left out from previous commit) * - update SDK to address findings - PasswordComposerViewController: add notification upon copying password to clipboard * Address findings (1) (2) (3) in #1325: - ios-sdk: - add basic support for OC10 password policies - fix password generator error due to "empty" password policies derived from capabilities (finding 2) - ShareViewController: - support for the requirement to set a password by disabling buttons and adding a warning triangle if no password is set (finding 1) - support for expiration date constraints: - support for the requirement by disabling buttons and adding a warning triangle if no date is set - add support for maximum date - add support for pre-setting an expiration date - add a "Copy" button next to the password if the password is known (finding 3) - clean up button creation code, avoiding duplication * - ShareViewController: enforce password requirement even if files_sharing.public.password.enforced in capabilities is false, but the respectively matching files_sharing.public.password.enforced_for is true --------- Co-authored-by: felix-schwarz <[email protected]>
- Loading branch information
1 parent
f387e8b
commit f0399b0
Showing
10 changed files
with
632 additions
and
55 deletions.
There are no files selected for viewing
Submodule ios-sdk
updated
8 files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
286 changes: 286 additions & 0 deletions
286
ownCloudAppShared/Client/Password Composer/PasswordComposerViewController.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,286 @@ | ||
// | ||
// PasswordComposerViewController.swift | ||
// ownCloudAppShared | ||
// | ||
// Created by Felix Schwarz on 23.02.24. | ||
// Copyright © 2024 ownCloud GmbH. All rights reserved. | ||
// | ||
|
||
/* | ||
* Copyright (C) 2024, ownCloud GmbH. | ||
* | ||
* This code is covered by the GNU Public License Version 3. | ||
* | ||
* For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ | ||
* You should have received a copy of this license along with this program. If not, see <http://www.gnu.org/licenses/gpl-3.0.en.html>. | ||
* | ||
*/ | ||
|
||
import UIKit | ||
import ownCloudSDK | ||
|
||
class PasswordComposerViewController: UIViewController { | ||
typealias ResultHandler = (_ password: String?, _ cancelled: Bool) -> Void | ||
|
||
var resultHandler: ResultHandler? | ||
|
||
let passwordLabel = ThemeCSSLabel(withSelectors: [ .label, .secondary ]) | ||
let passwordFieldContainer = ThemeCSSView(withSelectors: [ .cell ]) | ||
let passwordField = ThemeCSSTextField() | ||
|
||
let componentToolbar = SegmentView(with: [], truncationMode: .none, scrollable: false) | ||
|
||
let validationReportContainerView = ThemeCSSView(withSelectors: [ .cell ]) | ||
|
||
lazy var showPasswordSegment: SegmentViewItem = { | ||
return SegmentViewItem.button(title: "Show".localized, customizeButton: { _, config in | ||
var buttonConfig = config | ||
buttonConfig.image = OCSymbol.icon(forSymbolName: "eye")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small)) | ||
buttonConfig.imagePadding = 5 | ||
return buttonConfig | ||
}, action: UIAction(handler: { [weak self] _ in | ||
self?.showPassword = true | ||
})) | ||
}() | ||
lazy var hidePasswordSegment: SegmentViewItem = { | ||
return SegmentViewItem.button(title: "Hide".localized, customizeButton: { _, config in | ||
var buttonConfig = config | ||
buttonConfig.image = OCSymbol.icon(forSymbolName: "eye.slash")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small)) | ||
buttonConfig.imagePadding = 5 | ||
return buttonConfig | ||
}, action: UIAction(handler: { [weak self] _ in | ||
self?.showPassword = false | ||
})) | ||
}() | ||
lazy var generatePasswordSegment: SegmentViewItem = { | ||
return SegmentViewItem.button(title: "Generate".localized, action: UIAction(handler: { [weak self] _ in | ||
self?.generatePassword() | ||
})) | ||
}() | ||
lazy var copyPasswordSegment: SegmentViewItem = { | ||
return SegmentViewItem.button(title: "Copy".localized, action: UIAction(handler: { [weak self] _ in | ||
self?.copyToClipboard() | ||
})) | ||
}() | ||
|
||
var saveButton: UIBarButtonItem? | ||
|
||
var passwordPolicy: OCPasswordPolicy | ||
|
||
init(password: String, policy: OCPasswordPolicy, saveButtonTitle: String, resultHandler: @escaping ResultHandler) { | ||
self.passwordPolicy = policy | ||
|
||
super.init(nibName: nil, bundle: nil) | ||
|
||
defer { | ||
// Placing this in a defer block makes sure that didSet is called for the respective properties | ||
self.password = password | ||
self.showPassword = false | ||
} | ||
|
||
self.resultHandler = resultHandler | ||
|
||
saveButton = UIBarButtonItem(title: saveButtonTitle, style: .done, target: self, action: #selector(save)) | ||
|
||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Cancel".localized, style: .plain, target: self, action: #selector(cancel)) | ||
navigationItem.rightBarButtonItem = saveButton | ||
navigationItem.title = "Password".localized | ||
} | ||
|
||
required init?(coder: NSCoder) { | ||
fatalError("init(coder:) has not been implemented") | ||
} | ||
|
||
override func loadView() { | ||
let rootView = ThemeCSSView(withSelectors: [ .grouped, .collection ]) | ||
let padding = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) | ||
let labelFieldSpacing: CGFloat = 10 | ||
let fieldToolbarSpacing: CGFloat = 15 | ||
let toolbarValidationReportSpacing: CGFloat = 15 | ||
|
||
passwordLabel.translatesAutoresizingMaskIntoConstraints = false | ||
passwordFieldContainer.translatesAutoresizingMaskIntoConstraints = false | ||
passwordField.translatesAutoresizingMaskIntoConstraints = false | ||
componentToolbar.translatesAutoresizingMaskIntoConstraints = false | ||
componentToolbar.setContentHuggingPriority(.defaultHigh, for: .horizontal) | ||
validationReportContainerView.translatesAutoresizingMaskIntoConstraints = false | ||
|
||
passwordFieldContainer.layer.cornerRadius = 5 | ||
validationReportContainerView.layer.cornerRadius = 10 | ||
|
||
passwordField.cssSelectors = [ .cell ] | ||
passwordFieldContainer.embed(toFillWith: passwordField, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) | ||
|
||
componentToolbar.insets = .zero | ||
componentToolbar.itemSpacing = 0 | ||
|
||
rootView.addSubview(passwordLabel) | ||
rootView.addSubview(passwordFieldContainer) | ||
rootView.addSubview(componentToolbar) | ||
rootView.addSubview(validationReportContainerView) | ||
|
||
passwordLabel.text = "Password".localized | ||
passwordLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize) | ||
|
||
passwordField.placeholder = "Password".localized | ||
passwordField.clearButtonMode = .always | ||
passwordField.addAction(UIAction(handler: { [weak self] _ in | ||
self?.passwordChanged() | ||
}), for: .editingChanged) | ||
|
||
rootView.addConstraints([ | ||
passwordLabel.topAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.topAnchor, constant: padding.top), | ||
passwordLabel.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), | ||
passwordLabel.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), | ||
|
||
passwordFieldContainer.topAnchor.constraint(equalTo: passwordLabel.bottomAnchor, constant: labelFieldSpacing), | ||
passwordFieldContainer.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), | ||
passwordFieldContainer.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), | ||
|
||
componentToolbar.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: fieldToolbarSpacing), | ||
componentToolbar.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left - 5), | ||
componentToolbar.trailingAnchor.constraint(lessThanOrEqualTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), | ||
|
||
validationReportContainerView.topAnchor.constraint(equalTo: componentToolbar.bottomAnchor, constant: toolbarValidationReportSpacing), | ||
validationReportContainerView.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), | ||
validationReportContainerView.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right) | ||
]) | ||
|
||
view = rootView | ||
} | ||
|
||
override func viewWillAppear(_ animated: Bool) { | ||
super.viewWillAppear(animated) | ||
validatePasssword() | ||
} | ||
|
||
override func viewDidAppear(_ animated: Bool) { | ||
super.viewDidAppear(animated) | ||
|
||
passwordField.becomeFirstResponder() | ||
} | ||
|
||
func passwordChanged() { | ||
password = passwordField.text ?? "" | ||
} | ||
|
||
func validatePasssword() { | ||
let report = passwordPolicy.validate(password) | ||
var lines : [UIView] = [] | ||
var failures: Int = 0 | ||
|
||
for rule in report.rules { | ||
var ruleDescription: String? = rule.localizedDescription | ||
|
||
if !(rule is OCPasswordPolicyRuleCharacters), let result = report.result(for: rule) { | ||
ruleDescription = result | ||
} | ||
|
||
if let ruleDescription { | ||
let passedValidation = report.passedValidation(for: rule) | ||
let symbolConfiguration = UIImage.SymbolConfiguration(hierarchicalColor: passedValidation ? .systemGreen : .systemRed) | ||
let line = SegmentView(with: [ | ||
SegmentViewItem(with: UIImage(systemName: passedValidation ? "checkmark.circle.fill" : "xmark.circle.fill")?.withConfiguration(symbolConfiguration), iconRenderingMode: .automatic, title: ruleDescription) | ||
], truncationMode: .truncateTail) | ||
line.translatesAutoresizingMaskIntoConstraints = false | ||
line.insets = .zero | ||
|
||
if passedValidation { | ||
lines.append(line) | ||
} else { | ||
lines.insert(line, at: failures) | ||
failures += 1 | ||
} | ||
} | ||
} | ||
|
||
for subview in validationReportContainerView.subviews { | ||
subview.removeFromSuperview() | ||
} | ||
|
||
validationReportContainerView.embedVertically(views: lines, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), enclosingAnchors: validationReportContainerView.safeAreaAnchorSet, centered: false) | ||
|
||
saveButton?.isEnabled = report.passedValidation | ||
} | ||
|
||
func updateSegments() { | ||
var items: [SegmentViewItem] = [] | ||
|
||
// Show/Hide password | ||
if showPassword { | ||
items.append(hidePasswordSegment) | ||
} else { | ||
items.append(showPasswordSegment) | ||
} | ||
|
||
// Generate password | ||
items.append(SegmentViewItem(title: "|", style: .label)) | ||
items.append(generatePasswordSegment) | ||
|
||
// Copy password | ||
if password.count > 0 { | ||
items.append(SegmentViewItem(title: "|", style: .label)) | ||
items.append(copyPasswordSegment) | ||
} | ||
|
||
if componentToolbar.items != items { | ||
componentToolbar.items = items | ||
} | ||
} | ||
|
||
var password: String { | ||
get { | ||
return passwordField.text ?? "" | ||
} | ||
|
||
set { | ||
passwordField.text = newValue | ||
|
||
updateSegments() | ||
validatePasssword() | ||
} | ||
} | ||
var showPassword: Bool = false { | ||
didSet { | ||
passwordField.isSecureTextEntry = !showPassword | ||
updateSegments() | ||
} | ||
} | ||
|
||
func generatePassword() { | ||
var generatedPassword: String? | ||
do { | ||
try generatedPassword = passwordPolicy.generatePassword(withMinLength: nil, maxLength: nil) | ||
} catch let error as NSError { | ||
Log.error("Error generating password: \(error)") | ||
} | ||
if let generatedPassword { | ||
password = generatedPassword | ||
} | ||
} | ||
|
||
func copyToClipboard() { | ||
UIPasteboard.general.string = password | ||
|
||
_ = NotificationHUDViewController(on: self, title: "Password".localized, subtitle: "The password was copied to the clipboard".localized, completion: nil) | ||
} | ||
|
||
func viewControllerForPresentation() -> ThemeNavigationController { | ||
let navigationViewController = ThemeNavigationController(rootViewController: self) | ||
navigationViewController.cssSelectors = [ .modal ] | ||
|
||
return navigationViewController | ||
} | ||
|
||
@objc func save() { | ||
presentingViewController?.dismiss(animated: true, completion: { | ||
self.resultHandler?(self.password, false) | ||
}) | ||
} | ||
|
||
@objc func cancel() { | ||
presentingViewController?.dismiss(animated: true, completion: { | ||
self.resultHandler?(nil, true) | ||
}) | ||
} | ||
} |
Oops, something went wrong.