2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Add age verification to new account registration on servers that require it

Also improve the form fill experience by only marking a field invalid after it has been edited at least once, or was valid and then became invalid. (Or, for date of birth, is the last thing standing in the way of everything being valid, since the birth date is pre-filled with a likely-invalid value.)

Contributes to IOS-378
This commit is contained in:
shannon 2025-03-26 08:25:25 -04:00
parent 9f1291ca36
commit 597d704d4e
5 changed files with 344 additions and 56 deletions

View File

@ -14,7 +14,7 @@ final class PrivacyViewModel {
let domain: String
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let rows: [PrivacyRow]
let instance: Mastodon.Entity.Instance
let instance: RegistrationInstance
let applicationToken: Mastodon.Entity.Token
let didAccept: ()->()
@ -22,7 +22,7 @@ final class PrivacyViewModel {
domain: String,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo,
rows: [PrivacyRow],
instance: Mastodon.Entity.Instance,
instance: RegistrationInstance,
applicationToken: Mastodon.Entity.Token,
didAccept: @escaping ()->()
) {

View File

@ -13,19 +13,27 @@ import MastodonAsset
struct MastodonRegisterView: View {
@FocusState var focusedField: MastodonRegisterViewModel.RegistrationField?
@ObservedObject var viewModel: MastodonRegisterViewModel
@State var usernameRightViewWidth: CGFloat = 300
@State var dateOfBirthLabel = L10n.Scene.Register.Input.BirthDate.label.localizedCapitalized
var body: some View {
ScrollView(.vertical) {
let margin: CGFloat = 16
VStack(alignment: .leading, spacing: 16) {
Spacer()
if let minAge = viewModel.minAge {
dateOfBirthEntry(minAge: minAge)
}
TextField(L10n.Scene.Register.Input.DisplayName.placeholder.localizedCapitalized, text: $viewModel.name)
.textContentType(.name)
.disableAutocorrection(true)
.modifier(FormTextFieldModifier(validateState: viewModel.displayNameValidateState))
.focused($focusedField, equals: .displayName)
HStack {
Text("@")
.accessibilityHidden(true)
@ -35,6 +43,7 @@ struct MastodonRegisterView: View {
.disableAutocorrection(true)
.keyboardType(.asciiCapable)
.accessibilityLabel(viewModel.accessibilityLabelUsernameField)
.focused($focusedField, equals: .handle)
Text("@\(viewModel.domain)")
.lineLimit(1)
.truncationMode(.middle)
@ -69,6 +78,7 @@ struct MastodonRegisterView: View {
.disableAutocorrection(true)
.keyboardType(.emailAddress)
.modifier(FormTextFieldModifier(validateState: viewModel.emailValidateState))
.focused($focusedField, equals: .email)
if let errorPrompt = viewModel.emailErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
@ -82,10 +92,12 @@ struct MastodonRegisterView: View {
VStack(alignment: .leading, spacing: margin) {
SecureField(L10n.Scene.Register.Input.Password.placeholder.localizedCapitalized, text: $viewModel.password)
.textContentType(.newPassword)
.modifier(FormTextFieldModifier(validateState: viewModel.passwordValidateState))
.modifier(FormTextFieldModifier(validateState: viewModel.passwordBaseValidateState))
.focused($focusedField, equals: .password)
SecureField(L10n.Scene.Register.Input.Password.confirmationPlaceholder.localizedCapitalized, text: $viewModel.passwordConfirmation)
.textContentType(.newPassword)
.modifier(FormTextFieldModifier(validateState: viewModel.passwordValidateState))
.modifier(FormTextFieldModifier(validateState: viewModel.passwordConfirmationValidateState))
.focused($focusedField, equals: .confirmPassword)
Text(L10n.Scene.Register.Input.Password.hint)
.modifier(FormFootnoteModifier(foregroundColor: .secondary))
if let errorPrompt = viewModel.passwordErrorPrompt {
@ -101,6 +113,7 @@ struct MastodonRegisterView: View {
VStack(alignment: .leading, spacing: 11) {
TextField(L10n.Scene.Register.Input.Invite.registrationUserInviteRequest.localizedCapitalized, text: $viewModel.reason)
.modifier(FormTextFieldModifier(validateState: viewModel.reasonValidateState))
.focused($focusedField, equals: .proposedApprovalReason)
if let errorPrompt = viewModel.reasonErrorPrompt {
Text(errorPrompt)
.modifier(FormFootnoteModifier())
@ -119,6 +132,9 @@ struct MastodonRegisterView: View {
}
)
.scrollDismissesKeyboard(.interactively)
.onChange(of: focusedField) { _, newValue in
viewModel.editingField = newValue
}
}
struct FormTextFieldModifier: ViewModifier {
@ -128,7 +144,7 @@ struct MastodonRegisterView: View {
ZStack {
let borderColor: Color = {
switch validateState {
case .empty: return Color(Asset.Scene.Onboarding.textFieldBackground.color)
case .empty, .filling: return Color(Asset.Scene.Onboarding.textFieldBackground.color)
case .invalid: return Color(Asset.Colors.TextField.invalid.color.withAlphaComponent(0.25))
case .valid: return Color(Asset.Scene.Onboarding.textFieldBackground.color)
}
@ -153,7 +169,22 @@ struct MastodonRegisterView: View {
.foregroundColor(foregroundColor)
}
}
@ViewBuilder func dateOfBirthEntry(minAge: Int) -> some View {
VStack {
ZStack {
TextField(L10n.Scene.Register.Input.BirthDate.label.localizedCapitalized, text: $dateOfBirthLabel)
.disabled(true)
.modifier(FormTextFieldModifier(validateState: viewModel.dateOfBirthValidateState))
HStack {
Spacer().frame(maxWidth: .infinity)
DatePicker(selection: $viewModel.dateOfBirth, in: ...Date.now, displayedComponents: .date) { }
Spacer()
}
}
Text(L10n.Scene.Register.Input.BirthDate.explanationMessage(minAge, viewModel.domain)).font(.callout)
}
}
}
struct WidthKey: PreferenceKey {

View File

@ -12,20 +12,34 @@ import UIKit
import MastodonAsset
import MastodonCore
import MastodonLocalization
import SwiftUI
@MainActor
final class MastodonRegisterViewModel: ObservableObject {
enum RegistrationField: Hashable {
case displayName
case handle
case email
case password
case confirmPassword
case dateOfBirth
case proposedApprovalReason
}
var disposeBag = Set<AnyCancellable>()
// input
let domain: String
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let instance: Mastodon.Entity.Instance
let instance: RegistrationInstance
let applicationToken: Mastodon.Entity.Token
let viewDidAppear = CurrentValueSubject<Void, Never>(Void())
let submitValidatedUserRegistration: (MastodonRegisterViewModel, Bool) async -> ()
@Published var backgroundColor: UIColor = Asset.Scene.Onboarding.background.color
@Published var dateOfBirth = Date.now
@Published var name = ""
@Published var username = ""
@Published var email = ""
@ -43,13 +57,24 @@ final class MastodonRegisterViewModel: ObservableObject {
// output
var diffableDataSource: UITableViewDiffableDataSource<RegisterSection, RegisterItem>?
let approvalRequired: Bool
let minAge: Int?
let applicationAuthorization: Mastodon.API.OAuth.Authorization
@Published var dateOfBirthValidateState: ValidateState = .empty
@Published var usernameValidateState: ValidateState = .empty
@Published var displayNameValidateState: ValidateState = .empty
@Published var emailValidateState: ValidateState = .empty
@Published var passwordValidateState: ValidateState = .empty
@Published var passwordBaseValidateState: ValidateState = .empty
@Published var passwordConfirmationValidateState: ValidateState = .empty
@Published var reasonValidateState: ValidateState = .empty
public var editingField: RegistrationField? {
didSet {
if let oldValue {
validate(oldValue)
}
}
}
@Published var isRegistering = false
@Published var isAllValid = false
@ -60,7 +85,7 @@ final class MastodonRegisterViewModel: ObservableObject {
init(
domain: String,
authenticateInfo: AuthenticationViewModel.AuthenticateInfo,
instance: Mastodon.Entity.Instance,
instance: RegistrationInstance,
applicationToken: Mastodon.Entity.Token,
submitValidatedUserRegistration: @escaping (MastodonRegisterViewModel, Bool) async ->()
) {
@ -69,36 +94,66 @@ final class MastodonRegisterViewModel: ObservableObject {
self.instance = instance
self.applicationToken = applicationToken
self.approvalRequired = instance.approvalRequired ?? false
self.minAge = instance.minAge
self.applicationAuthorization = Mastodon.API.OAuth.Authorization(accessToken: applicationToken.accessToken)
self.submitValidatedUserRegistration = submitValidatedUserRegistration
$dateOfBirth
.map { [weak self] dob in
guard let self else { return .invalid }
switch dateOfBirthValidateState {
case .empty:
return .filling
case .filling:
if self.validate(dateOfBirth: dob) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(dateOfBirth: dob)
}
}
.assign(to: \.dateOfBirthValidateState, on: self)
.store(in: &disposeBag)
$name
.map { name in
.map { [weak self] name in
guard !name.isEmpty else { return .empty }
return .valid
guard let self else { return .invalid }
switch self.displayNameValidateState {
case .empty:
return .filling
case .filling:
if self.validate(displayName: name) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(displayName: name)
}
}
.assign(to: \.displayNameValidateState, on: self)
.store(in: &disposeBag)
$username
.removeDuplicates()
.map { username in
.map { [weak self] username in
guard !username.isEmpty else { return .empty }
var isValid = true
// regex opt-out way to check validation
// allowed:
// a-z (isASCII && isLetter)
// A-Z (isASCII && isLetter)
// 0-9 (isASCII && isNumber)
// _ ("_")
for char in username {
guard char.isASCII, char.isLetter || char.isNumber || char == "_" else {
isValid = false
break
guard let self else { return .invalid }
switch self.usernameValidateState {
case .empty:
return .filling
case .filling:
if self.validate(handle: username) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(handle: username)
}
return isValid ? .valid : .invalid
}
.assign(to: \.usernameValidateState, on: self)
.store(in: &disposeBag)
@ -145,31 +200,79 @@ final class MastodonRegisterViewModel: ObservableObject {
.store(in: &disposeBag)
$email
.map { email in
.map { [weak self] email in
guard !email.isEmpty else { return .empty }
return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid
guard let self else { return .invalid }
switch self.emailValidateState {
case .empty:
return .filling
case .filling:
if self.validate(email: email) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(email: email)
}
}
.assign(to: \.emailValidateState, on: self)
.store(in: &disposeBag)
Publishers.CombineLatest($password, $passwordConfirmation)
.map { password, confirmation in
guard !password.isEmpty && !confirmation.isEmpty else { return .empty }
if password.count >= 8 && password == confirmation {
return .valid
} else {
return .invalid
$password
.map { [weak self] password in
guard !password.isEmpty else { return .empty }
guard let self else { return .invalid }
switch self.passwordBaseValidateState {
case .empty:
return .filling
case .filling:
if self.validate(password: password) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(password: password)
}
}
.assign(to: \.passwordValidateState, on: self)
.assign(to: \.passwordBaseValidateState, on: self)
.store(in: &disposeBag)
Publishers.CombineLatest($password, $passwordConfirmation)
.map { [weak self] password, confirmation in
guard !password.isEmpty && !confirmation.isEmpty else { return .empty }
guard let self else { return .invalid }
switch self.passwordConfirmationValidateState {
case .empty, .filling:
if self.validate(password: password, confirmation: confirmation) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(password: password, confirmation: confirmation)
}
}
.assign(to: \.passwordConfirmationValidateState, on: self)
.store(in: &disposeBag)
if approvalRequired {
$reason
.map { invite in
guard !invite.isEmpty else { return .empty }
return .valid
.map { joinReason in
guard !joinReason.isEmpty else { return .empty }
switch self.reasonValidateState {
case .empty:
return .filling
case .filling:
if self.validate(reason: joinReason) == .valid {
return .valid
} else {
return .filling
}
case .invalid, .valid:
return self.validate(reason: joinReason)
}
}
.assign(to: \.reasonValidateState, on: self)
.store(in: &disposeBag)
@ -188,7 +291,7 @@ final class MastodonRegisterViewModel: ObservableObject {
self.emailErrorPrompt = details.emailErrorDescriptions.first
details.emailErrorDescriptions.first.flatMap { _ in self.emailValidateState = .invalid }
self.passwordErrorPrompt = details.passwordErrorDescriptions.first
details.passwordErrorDescriptions.first.flatMap { _ in self.passwordValidateState = .invalid }
details.passwordErrorDescriptions.first.flatMap { _ in self.passwordBaseValidateState = .invalid }
self.reasonErrorPrompt = details.reasonErrorDescriptions.first
details.reasonErrorDescriptions.first.flatMap { _ in self.reasonValidateState = .invalid }
} else {
@ -204,7 +307,7 @@ final class MastodonRegisterViewModel: ObservableObject {
$usernameValidateState,
$displayNameValidateState,
$emailValidateState,
$passwordValidateState
$passwordBaseValidateState
)
.map {
$0.0 == .valid &&
@ -213,9 +316,17 @@ final class MastodonRegisterViewModel: ObservableObject {
$0.3 == .valid
}
let publisherTwo = $reasonValidateState.map { reasonValidateState -> Bool in
guard self.approvalRequired else { return true }
return reasonValidateState == .valid
let publisherTwo = Publishers.CombineLatest3(
$reasonValidateState,
$dateOfBirthValidateState,
$passwordConfirmationValidateState
)
.map { [weak self] reasonValidateState, dobValidateState, passwordConfirmationValidateState -> Bool in
guard let self else { return false }
let reasonOK = !self.approvalRequired || reasonValidateState == .valid
let dobOK = (self.minAge == nil) || dobValidateState == .valid
let passwordConfirmationCorrect = passwordConfirmationValidateState == .valid
return reasonOK && dobOK && passwordConfirmationCorrect
}
Publishers.CombineLatest(
@ -225,25 +336,123 @@ final class MastodonRegisterViewModel: ObservableObject {
.map { $0 && $1 }
.assign(to: \.isAllValid, on: self)
.store(in: &disposeBag)
Publishers.CombineLatest4(
publisherOne,
$reasonValidateState,
$passwordConfirmationValidateState,
$dateOfBirthValidateState
)
.sink { [weak self] publisherOne, reasonValidState, passwordConfirmValidState, dobValidState in
if publisherOne == false { return }
if reasonValidState == .valid && passwordConfirmValidState == .valid && dobValidState != .valid {
self?.dateOfBirthValidateState = .invalid // this will highlight the DOB field if everything else has been filled in
}
}
.store(in: &disposeBag)
}
}
extension MastodonRegisterViewModel {
enum ValidateState: Hashable {
case empty
case filling
case invalid
case valid
}
static func isValidEmail(_ email: String) -> Bool {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx)
return emailPred.evaluate(with: email)
}
func validate(_ field: RegistrationField) {
let state = validationState(forCurrentContentsOf: field)
switch field {
case .displayName:
displayNameValidateState = state
case .handle:
usernameValidateState = state
case .email:
emailValidateState = state
case .password:
passwordBaseValidateState = state
case .confirmPassword:
passwordConfirmationValidateState = state
case .dateOfBirth:
dateOfBirthValidateState = state
case .proposedApprovalReason:
reasonValidateState = state
}
}
private func validate(dateOfBirth: Date) -> ValidateState {
guard let minAge else { return .valid }
let years = Calendar.current.dateComponents([.year], from: dateOfBirth, to: Date.now).year ?? 0
print("looks to be \(years) old")
return years < minAge ? .invalid : .valid
}
private func validate(displayName: String) -> ValidateState {
return displayName.isEmpty ? .empty : .valid
}
private func validate(handle: String) -> ValidateState {
var isValid = true
// regex opt-out way to check validation
// allowed:
// a-z (isASCII && isLetter)
// A-Z (isASCII && isLetter)
// 0-9 (isASCII && isNumber)
// _ ("_")
for char in handle {
guard char.isASCII, char.isLetter || char.isNumber || char == "_" else {
isValid = false
break
}
}
return isValid ? .valid : .invalid
}
private func validate(email: String) -> ValidateState {
return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid
}
private func validate(password: String) -> ValidateState {
return password.count >= 8 ? .valid : .invalid
}
private func validate(password: String, confirmation: String) -> ValidateState {
return password == passwordConfirmation ? .valid : .invalid
}
private func validate(reason: String) -> ValidateState {
return reason.isEmpty ? .invalid : .valid
}
private func validationState(forCurrentContentsOf field: RegistrationField) -> ValidateState {
switch field {
case .displayName:
return validate(displayName: name)
case .handle:
return validate(handle: username)
case .email:
return validate(email: email)
case .password:
return validate(password: password)
case .confirmPassword:
return validate(password: password, confirmation: passwordConfirmation)
case .dateOfBirth:
return validate(dateOfBirth: dateOfBirth)
case .proposedApprovalReason:
return validate(reason: reason)
}
}
}
extension MastodonRegisterViewModel {
static func isValidEmail(_ email: String) -> Bool {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx)
return emailPred.evaluate(with: email)
}
static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage {
let configuration = UIImage.SymbolConfiguration(font: font)
return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)!
@ -294,3 +503,25 @@ extension MastodonRegisterViewModel {
return "@\(username)@\(domain)"
}
}
protocol RegistrationInstance {
var approvalRequired: Bool? { get }
var minAge: Int? { get }
var isBeyondVersion1: Bool { get }
var isOpenToNewRegistrations: Bool? { get }
var rules: [Mastodon.Entity.Instance.Rule]? { get }
}
extension Mastodon.Entity.Instance: RegistrationInstance {
var minAge: Int? { return nil }
var isBeyondVersion1: Bool {
return version?.majorServerVersion(greaterThanOrEquals: 4) ?? false
}
var isOpenToNewRegistrations: Bool? { return registrations }
}
extension Mastodon.Entity.V2.Instance: RegistrationInstance {
var minAge: Int? { return registrations?.minAge }
var isBeyondVersion1: Bool { return true }
var isOpenToNewRegistrations: Bool? { return registrations?.enabled }
}

View File

@ -103,9 +103,17 @@ extension AuthenticationViewModel {
stateStreamContinuation.yield(.joiningServer(server))
let instance = try await APIService.shared.instance(domain: server.domain, authenticationBox: nil)
let instance: RegistrationInstance
do {
instance = try await APIService.shared.instanceV2(domain: server.domain, authenticationBox: nil)
} catch {
instance = try await APIService.shared.instance(domain: server.domain, authenticationBox: nil)
if instance.isBeyondVersion1 {
throw APIService.APIError.explicit(.badResponse)
}
}
guard instance.registrations != false else {
guard instance.isOpenToNewRegistrations ?? true else {
throw AuthenticationViewModel.AuthenticationError.registrationClosed
}
let application = try await APIService.shared.createApplication(domain: server.domain)
@ -157,10 +165,11 @@ extension AuthenticationViewModel {
assert(hasAgreedToRules == true)
let query = Mastodon.API.Account.RegisterQuery(
reason: info.reason,
dateOfBirth: info.minAge == nil ? nil : info.dateOfBirth,
username: info.username,
email: info.email,
password: info.password,
agreement: hasAgreedToRules,
agreement: hasAgreedToRules,
locale: locale ?? self.locale
)

View File

@ -49,22 +49,39 @@ extension Mastodon.API.Account {
public struct RegisterQuery: Codable, PostQuery {
public let reason: String?
public let dateOfBirth: String?
public let username: String
public let email: String
public let password: String
public let agreement: Bool
public let locale: String
public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) {
public init(reason: String? = nil, dateOfBirth: Date?, username: String, email: String, password: String, agreement: Bool, locale: String) {
self.reason = reason
if let dateOfBirth {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = .withFullDate // YYYY-MM-DD
self.dateOfBirth = dateFormatter.string(from: dateOfBirth)
} else {
self.dateOfBirth = nil
}
self.username = username
self.email = email
self.password = password
self.agreement = agreement
self.locale = locale
}
public enum CodingKeys: String, CodingKey {
case reason
case dateOfBirth = "date_of_birth"
case username
case email
case password
case agreement
case locale
}
}
}
extension Mastodon.API.Account {