mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 08:10:59 +00:00
Registrations wip
This commit is contained in:
parent
757e8dba35
commit
2745f2470d
13 changed files with 433 additions and 80 deletions
|
@ -5,7 +5,19 @@
|
||||||
"add-identity.instance-url" = "Instance URL";
|
"add-identity.instance-url" = "Instance URL";
|
||||||
"add-identity.log-in" = "Log in";
|
"add-identity.log-in" = "Log in";
|
||||||
"add-identity.browse" = "Browse";
|
"add-identity.browse" = "Browse";
|
||||||
|
"add-identity.join" = "Join";
|
||||||
|
"add-identity.request-invite" = "Request an invite";
|
||||||
"add-identity.unable-to-connect-to-instance" = "Unable to connect to instance";
|
"add-identity.unable-to-connect-to-instance" = "Unable to connect to instance";
|
||||||
|
"registration.review-terms-of-use-and-privacy-policy-%@" = "Please review %@'s Terms of Use and Privacy Policy to continue";
|
||||||
|
"registration.username" = "Username";
|
||||||
|
"registration.email" = "Email";
|
||||||
|
"registration.password" = "Password";
|
||||||
|
"registration.password-confirmation" = "Confirm password";
|
||||||
|
"registration.reason-%@" = "Why do you want to join %@?";
|
||||||
|
"registration.server-rules" = "Server rules";
|
||||||
|
"registration.terms-of-service" = "Terms of service";
|
||||||
|
"registration.agree-to-server-rules-and-terms-of-service" = "I agree to the server rules and terms of service";
|
||||||
|
"registration.password-confirmation-mismatch" = "Password and password confirmation do not match";
|
||||||
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
||||||
"secondary-navigation.lists" = "Lists";
|
"secondary-navigation.lists" = "Lists";
|
||||||
"secondary-navigation.preferences" = "Preferences";
|
"secondary-navigation.preferences" = "Preferences";
|
||||||
|
|
|
@ -8,39 +8,65 @@ public enum AccessTokenEndpoint {
|
||||||
case oauthToken(
|
case oauthToken(
|
||||||
clientID: String,
|
clientID: String,
|
||||||
clientSecret: String,
|
clientSecret: String,
|
||||||
code: String,
|
|
||||||
grantType: String,
|
grantType: String,
|
||||||
scopes: String,
|
scopes: String,
|
||||||
redirectURI: String
|
code: String?,
|
||||||
|
redirectURI: String?
|
||||||
)
|
)
|
||||||
|
case accounts(username: String, email: String, password: String, reason: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccessTokenEndpoint: Endpoint {
|
extension AccessTokenEndpoint: Endpoint {
|
||||||
public typealias ResultType = AccessToken
|
public typealias ResultType = AccessToken
|
||||||
|
|
||||||
public var context: [String] { [] }
|
public var context: [String] {
|
||||||
|
switch self {
|
||||||
|
case .oauthToken:
|
||||||
|
return []
|
||||||
|
case .accounts:
|
||||||
|
return defaultContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var pathComponentsInContext: [String] {
|
public var pathComponentsInContext: [String] {
|
||||||
["oauth", "token"]
|
switch self {
|
||||||
|
case .oauthToken:
|
||||||
|
return ["oauth", "token"]
|
||||||
|
case .accounts:
|
||||||
|
return ["accounts"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var method: HTTPMethod {
|
public var method: HTTPMethod {
|
||||||
switch self {
|
switch self {
|
||||||
case .oauthToken: return .post
|
case .oauthToken, .accounts: return .post
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var parameters: [String: Any]? {
|
public var parameters: [String: Any]? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .oauthToken(clientID, clientSecret, code, grantType, scopes, redirectURI):
|
case let .oauthToken(clientID, clientSecret, grantType, scopes, code, redirectURI):
|
||||||
return [
|
var params = [
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
"client_secret": clientSecret,
|
"client_secret": clientSecret,
|
||||||
"code": code,
|
|
||||||
"grant_type": grantType,
|
"grant_type": grantType,
|
||||||
"scope": scopes,
|
"scope": scopes]
|
||||||
"redirect_uri": redirectURI
|
|
||||||
]
|
params["code"] = code
|
||||||
|
params["redirect_uri"] = redirectURI
|
||||||
|
|
||||||
|
return params
|
||||||
|
case let .accounts(username, email, password, reason):
|
||||||
|
var params: [String: Any] = [
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"locale": Locale.autoupdatingCurrent.languageCode ?? "en", // TODO: probably need to map
|
||||||
|
"agreement": true]
|
||||||
|
|
||||||
|
params["reason"] = reason
|
||||||
|
|
||||||
|
return params
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,13 @@ import Stubbing
|
||||||
|
|
||||||
extension AccessTokenEndpoint: Stubbing {
|
extension AccessTokenEndpoint: Stubbing {
|
||||||
public func dataString(url: URL) -> String? {
|
public func dataString(url: URL) -> String? {
|
||||||
switch self {
|
"""
|
||||||
case let .oauthToken(_, _, _, _, scopes, _):
|
|
||||||
return """
|
|
||||||
{
|
{
|
||||||
"access_token": "ACCESS_TOKEN_STUB_VALUE",
|
"access_token": "ACCESS_TOKEN_STUB_VALUE",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"scope": "\(scopes)",
|
"scope": "ACCESS_TOKEN_STUB_VALUE_SCOPES",
|
||||||
"created_at": "\(Int(Date().timeIntervalSince1970))"
|
"created_at": "\(Int(Date().timeIntervalSince1970))"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -11,7 +11,9 @@
|
||||||
D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; };
|
D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; };
|
||||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
|
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
|
||||||
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
|
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
|
||||||
|
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; };
|
||||||
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
|
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
|
||||||
|
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
|
||||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
|
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
|
||||||
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
|
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
|
||||||
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
|
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
|
||||||
|
@ -82,11 +84,13 @@
|
||||||
D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
|
D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
|
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
|
||||||
D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; };
|
D01F41E224F8889700D55A2D /* AttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentsView.swift; sourceTree = "<group>"; };
|
||||||
|
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
|
||||||
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
|
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
|
||||||
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
|
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
|
||||||
|
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
|
||||||
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
|
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ServiceLayer; sourceTree = "<group>"; };
|
||||||
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
|
||||||
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
|
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -253,7 +257,9 @@
|
||||||
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
|
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
|
||||||
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
|
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
|
||||||
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
|
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
|
||||||
|
D0B32F4F250B373600311912 /* RegistrationView.swift */,
|
||||||
D0C7D42724F76169001EBDBB /* RootView.swift */,
|
D0C7D42724F76169001EBDBB /* RootView.swift */,
|
||||||
|
D02E1F94250B13210071AD56 /* SafariView.swift */,
|
||||||
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
|
D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */,
|
||||||
D01F41D324F8807E00D55A2D /* Status Cell */,
|
D01F41D324F8807E00D55A2D /* Status Cell */,
|
||||||
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
|
D0C7D42524F76169001EBDBB /* StatusListView.swift */,
|
||||||
|
@ -479,7 +485,9 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
|
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
|
||||||
|
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
|
||||||
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
|
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
|
||||||
|
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
|
||||||
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
|
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
|
||||||
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
|
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
|
||||||
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
|
||||||
|
|
|
@ -29,33 +29,26 @@ public extension AllIdentitiesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
|
||||||
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
createIdentity(
|
||||||
|
|
||||||
do {
|
|
||||||
try secrets.setInstanceURL(url)
|
|
||||||
} catch {
|
|
||||||
return Fail(error: error).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
let createIdentityPublisher = database.createIdentity(
|
|
||||||
id: id,
|
id: id,
|
||||||
url: url,
|
url: url,
|
||||||
authenticated: authenticated)
|
authenticationPublisher: authenticated
|
||||||
.ignoreOutput()
|
? AuthenticationService(url: url, environment: environment).authenticate()
|
||||||
.eraseToAnyPublisher()
|
: nil)
|
||||||
|
}
|
||||||
|
|
||||||
if authenticated {
|
func createIdentity(
|
||||||
return AuthenticationService(url: url, environment: environment).authenticate()
|
id: UUID,
|
||||||
.tryMap {
|
url: URL,
|
||||||
try secrets.setClientID($0.clientId)
|
username: String,
|
||||||
try secrets.setClientSecret($0.clientSecret)
|
email: String,
|
||||||
try secrets.setAccessToken($1.accessToken)
|
password: String,
|
||||||
}
|
reason: String?) -> AnyPublisher<Never, Error> {
|
||||||
.flatMap { createIdentityPublisher }
|
createIdentity(
|
||||||
.eraseToAnyPublisher()
|
id: id,
|
||||||
} else {
|
url: url,
|
||||||
return createIdentityPublisher
|
authenticationPublisher: AuthenticationService(url: url, environment: environment)
|
||||||
}
|
.register(username: username, email: email, password: password, reason: reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
|
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
|
||||||
|
@ -101,3 +94,38 @@ public extension AllIdentitiesService {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension AllIdentitiesService {
|
||||||
|
func createIdentity(
|
||||||
|
id: UUID,
|
||||||
|
url: URL,
|
||||||
|
authenticationPublisher: AnyPublisher<(AppAuthorization, AccessToken), Error>?) -> AnyPublisher<Never, Error> {
|
||||||
|
let secrets = Secrets(identityID: id, keychain: environment.keychain)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try secrets.setInstanceURL(url)
|
||||||
|
} catch {
|
||||||
|
return Fail(error: error).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
let createIdentityPublisher = database.createIdentity(
|
||||||
|
id: id,
|
||||||
|
url: url,
|
||||||
|
authenticated: authenticationPublisher != nil)
|
||||||
|
.ignoreOutput()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
|
if let authenticationPublisher = authenticationPublisher {
|
||||||
|
return authenticationPublisher
|
||||||
|
.tryMap {
|
||||||
|
try secrets.setClientID($0.clientId)
|
||||||
|
try secrets.setClientSecret($0.clientSecret)
|
||||||
|
try secrets.setAccessToken($1.accessToken)
|
||||||
|
}
|
||||||
|
.flatMap { createIdentityPublisher }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
} else {
|
||||||
|
return createIdentityPublisher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,16 +22,42 @@ struct AuthenticationService {
|
||||||
|
|
||||||
extension AuthenticationService {
|
extension AuthenticationService {
|
||||||
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
||||||
let appAuthorization = mastodonAPIClient.request(
|
let authorization = appAuthorization().share()
|
||||||
AppAuthorizationEndpoint.apps(
|
|
||||||
clientName: OAuth.clientName,
|
return authorization
|
||||||
redirectURI: OAuth.callbackURL.absoluteString,
|
.zip(authorization.flatMap(authenticate(appAuthorization:)))
|
||||||
scopes: OAuth.scopes,
|
.eraseToAnyPublisher()
|
||||||
website: OAuth.website))
|
}
|
||||||
|
|
||||||
|
func register(username: String,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
reason: String?) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
|
||||||
|
let authorization = appAuthorization()
|
||||||
.share()
|
.share()
|
||||||
|
|
||||||
return appAuthorization
|
return authorization.zip(
|
||||||
.zip(appAuthorization.flatMap(authenticate(appAuthorization:)))
|
authorization.flatMap { appAuthorization -> AnyPublisher<AccessToken, Error> in
|
||||||
|
mastodonAPIClient.request(
|
||||||
|
AccessTokenEndpoint.oauthToken(
|
||||||
|
clientID: appAuthorization.clientId,
|
||||||
|
clientSecret: appAuthorization.clientSecret,
|
||||||
|
grantType: OAuth.registrationGrantType,
|
||||||
|
scopes: OAuth.scopes,
|
||||||
|
code: nil,
|
||||||
|
redirectURI: OAuth.callbackURL.absoluteString))
|
||||||
|
.flatMap { accessToken -> AnyPublisher<AccessToken, Error> in
|
||||||
|
mastodonAPIClient.accessToken = accessToken.accessToken
|
||||||
|
|
||||||
|
return mastodonAPIClient.request(
|
||||||
|
AccessTokenEndpoint.accounts(
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
reason: reason))
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
})
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,7 +67,8 @@ private extension AuthenticationService {
|
||||||
static let clientName = "Metatext"
|
static let clientName = "Metatext"
|
||||||
static let scopes = "read write follow push"
|
static let scopes = "read write follow push"
|
||||||
static let codeCallbackQueryItemName = "code"
|
static let codeCallbackQueryItemName = "code"
|
||||||
static let grantType = "authorization_code"
|
static let authorizationCodeGrantType = "authorization_code"
|
||||||
|
static let registrationGrantType = "client_credentials"
|
||||||
static let callbackURLScheme = "metatext"
|
static let callbackURLScheme = "metatext"
|
||||||
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
|
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
|
||||||
static let website = URL(string: "https://metabolist.com/metatext")!
|
static let website = URL(string: "https://metabolist.com/metatext")!
|
||||||
|
@ -63,6 +90,15 @@ private extension AuthenticationService {
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appAuthorization() -> AnyPublisher<AppAuthorization, Error> {
|
||||||
|
mastodonAPIClient.request(
|
||||||
|
AppAuthorizationEndpoint.apps(
|
||||||
|
clientName: OAuth.clientName,
|
||||||
|
redirectURI: OAuth.callbackURL.absoluteString,
|
||||||
|
scopes: OAuth.scopes,
|
||||||
|
website: OAuth.website))
|
||||||
|
}
|
||||||
|
|
||||||
func authorizationURL(appAuthorization: AppAuthorization) throws -> URL {
|
func authorizationURL(appAuthorization: AppAuthorization) throws -> URL {
|
||||||
guard var authorizationURLComponents = URLComponents(
|
guard var authorizationURLComponents = URLComponents(
|
||||||
url: mastodonAPIClient.instanceURL,
|
url: mastodonAPIClient.instanceURL,
|
||||||
|
@ -106,9 +142,9 @@ private extension AuthenticationService {
|
||||||
AccessTokenEndpoint.oauthToken(
|
AccessTokenEndpoint.oauthToken(
|
||||||
clientID: appAuthorization.clientId,
|
clientID: appAuthorization.clientId,
|
||||||
clientSecret: appAuthorization.clientSecret,
|
clientSecret: appAuthorization.clientSecret,
|
||||||
code: $0,
|
grantType: OAuth.authorizationCodeGrantType,
|
||||||
grantType: OAuth.grantType,
|
|
||||||
scopes: OAuth.scopes,
|
scopes: OAuth.scopes,
|
||||||
|
code: $0,
|
||||||
redirectURI: OAuth.callbackURL.absoluteString))
|
redirectURI: OAuth.callbackURL.absoluteString))
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
|
|
@ -15,25 +15,21 @@ import ViewModels
|
||||||
|
|
||||||
let db: IdentityDatabase = {
|
let db: IdentityDatabase = {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let url = URL(string: "https://mastodon.social")!
|
|
||||||
let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self)
|
let db = try! IdentityDatabase(inMemory: true, keychain: MockKeychain.self)
|
||||||
let decoder = MastodonDecoder()
|
|
||||||
let instance = try! decoder.decode(Instance.self, from: StubData.instance)
|
|
||||||
let account = try! decoder.decode(Account.self, from: StubData.account)
|
|
||||||
let secrets = Secrets(identityID: id, keychain: MockKeychain.self)
|
let secrets = Secrets(identityID: id, keychain: MockKeychain.self)
|
||||||
|
|
||||||
try! secrets.setInstanceURL(url)
|
try! secrets.setInstanceURL(.previewInstanceURL)
|
||||||
try! secrets.setAccessToken(UUID().uuidString)
|
try! secrets.setAccessToken(UUID().uuidString)
|
||||||
|
|
||||||
_ = db.createIdentity(id: id, url: url, authenticated: true)
|
_ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true)
|
||||||
.receive(on: ImmediateScheduler.shared)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink { _ in } receiveValue: { _ in }
|
.sink { _ in } receiveValue: { _ in }
|
||||||
|
|
||||||
_ = db.updateInstance(instance, forIdentityID: id)
|
_ = db.updateInstance(.preview, forIdentityID: id)
|
||||||
.receive(on: ImmediateScheduler.shared)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink { _ in } receiveValue: { _ in }
|
.sink { _ in } receiveValue: { _ in }
|
||||||
|
|
||||||
_ = db.updateAccount(account, forIdentityID: id)
|
_ = db.updateAccount(.preview, forIdentityID: id)
|
||||||
.receive(on: ImmediateScheduler.shared)
|
.receive(on: ImmediateScheduler.shared)
|
||||||
.sink { _ in } receiveValue: { _ in }
|
.sink { _ in } receiveValue: { _ in }
|
||||||
|
|
||||||
|
@ -41,6 +37,19 @@ let db: IdentityDatabase = {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let environment = AppEnvironment.mock(fixtureDatabase: db)
|
let environment = AppEnvironment.mock(fixtureDatabase: db)
|
||||||
|
let decoder = MastodonDecoder()
|
||||||
|
|
||||||
|
public extension URL {
|
||||||
|
static let previewInstanceURL = URL(string: "https://mastodon.social")!
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Account {
|
||||||
|
static let preview = try! decoder.decode(Account.self, from: StubData.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Instance {
|
||||||
|
static let preview = try! decoder.decode(Instance.self, from: StubData.instance)
|
||||||
|
}
|
||||||
|
|
||||||
public extension RootViewModel {
|
public extension RootViewModel {
|
||||||
static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() }
|
static let preview = try! RootViewModel(environment: environment) { Empty().eraseToAnyPublisher() }
|
||||||
|
|
|
@ -13,7 +13,7 @@ public final class AddIdentityViewModel: ObservableObject {
|
||||||
@Published public var urlFieldText = ""
|
@Published public var urlFieldText = ""
|
||||||
@Published public var alertItem: AlertItem?
|
@Published public var alertItem: AlertItem?
|
||||||
@Published public private(set) var loading = false
|
@Published public private(set) var loading = false
|
||||||
@Published public private(set) var instance: Instance?
|
@Published public private(set) var instanceAndURL: (Instance, URL)?
|
||||||
@Published public private(set) var isPublicTimelineAvailable = false
|
@Published public private(set) var isPublicTimelineAvailable = false
|
||||||
public let addedIdentityID: AnyPublisher<UUID, Never>
|
public let addedIdentityID: AnyPublisher<UUID, Never>
|
||||||
|
|
||||||
|
@ -32,18 +32,23 @@ public final class AddIdentityViewModel: ObservableObject {
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.map(instanceURLService.url(text:))
|
.map(instanceURLService.url(text:))
|
||||||
.share()
|
.share()
|
||||||
let urlPresent = url.map { $0 != nil }.share()
|
|
||||||
|
|
||||||
url.compactMap { $0 }
|
url.compactMap { $0 }
|
||||||
.flatMap(instanceURLService.instance(url:))
|
.flatMap(instanceURLService.instance(url:))
|
||||||
.combineLatest(urlPresent)
|
.combineLatest(url)
|
||||||
.map { $1 ? $0 : nil }
|
.map {
|
||||||
|
if let instance = $0, let url = $1 {
|
||||||
|
return (instance, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$instance)
|
.assign(to: &$instanceAndURL)
|
||||||
|
|
||||||
url.compactMap { $0 }
|
url.compactMap { $0 }
|
||||||
.flatMap(instanceURLService.isPublicTimelineAvailable(url:))
|
.flatMap(instanceURLService.isPublicTimelineAvailable(url:))
|
||||||
.combineLatest(urlPresent)
|
.combineLatest(url.map { $0 != nil })
|
||||||
.map { $0 && $1 }
|
.map { $0 && $1 }
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isPublicTimelineAvailable)
|
.assign(to: &$isPublicTimelineAvailable)
|
||||||
|
@ -64,6 +69,10 @@ public extension AddIdentityViewModel {
|
||||||
.sink { _ in }
|
.sink { _ in }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registrationViewModel(instance: Instance, url: URL) -> RegistrationViewModel {
|
||||||
|
RegistrationViewModel(instance: instance, url: url, allIdentitiesService: allIdentitiesService)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AddIdentityViewModel {
|
private extension AddIdentityViewModel {
|
||||||
|
|
82
ViewModels/Sources/ViewModels/RegistrationViewModel.swift
Normal file
82
ViewModels/Sources/ViewModels/RegistrationViewModel.swift
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Mastodon
|
||||||
|
import MastodonAPI
|
||||||
|
import ServiceLayer
|
||||||
|
|
||||||
|
public enum RegistrationError: Error {
|
||||||
|
case passwordConfirmationMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class RegistrationViewModel: ObservableObject {
|
||||||
|
public let instance: Instance
|
||||||
|
public let serverRulesURL: URL
|
||||||
|
public let termsOfServiceURL: URL
|
||||||
|
@Published public var alertItem: AlertItem?
|
||||||
|
@Published public var username = ""
|
||||||
|
@Published public var email = ""
|
||||||
|
@Published public var password = ""
|
||||||
|
@Published public var passwordConfirmation = ""
|
||||||
|
@Published public var reason = ""
|
||||||
|
@Published public var passwordsMatch = false
|
||||||
|
@Published public var agreement = false
|
||||||
|
@Published public private(set) var registerButtonEnabled = false
|
||||||
|
@Published public private(set) var registering = false
|
||||||
|
|
||||||
|
private let url: URL
|
||||||
|
private let allIdentitiesService: AllIdentitiesService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(instance: Instance, url: URL, allIdentitiesService: AllIdentitiesService) {
|
||||||
|
self.instance = instance
|
||||||
|
self.url = url
|
||||||
|
self.serverRulesURL = url.appendingPathComponent("about").appendingPathComponent("more")
|
||||||
|
self.termsOfServiceURL = url.appendingPathComponent("terms")
|
||||||
|
self.allIdentitiesService = allIdentitiesService
|
||||||
|
|
||||||
|
Publishers.CombineLatest4($username, $email, $password, $reason)
|
||||||
|
.map { username, email, password, reason in
|
||||||
|
!username.isEmpty
|
||||||
|
&& !email.isEmpty
|
||||||
|
&& !password.isEmpty
|
||||||
|
&& (!instance.approvalRequired || !reason.isEmpty)
|
||||||
|
}
|
||||||
|
.combineLatest($agreement)
|
||||||
|
.map { $0 && $1 }
|
||||||
|
.assign(to: &$registerButtonEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension RegistrationViewModel {
|
||||||
|
func registerTapped() {
|
||||||
|
guard password == passwordConfirmation else {
|
||||||
|
alertItem = AlertItem(error: RegistrationError.passwordConfirmationMismatch)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allIdentitiesService.createIdentity(
|
||||||
|
id: UUID(),
|
||||||
|
url: url,
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
reason: reason)
|
||||||
|
.handleEvents(receiveSubscription: { [weak self] _ in self?.registering = true })
|
||||||
|
.mapError { error -> Error in
|
||||||
|
if error is URLError {
|
||||||
|
return AddIdentityError.unableToConnectToInstance
|
||||||
|
} else {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.registering = false
|
||||||
|
} receiveValue: { _ in }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import ViewModels
|
||||||
struct AddIdentityView: View {
|
struct AddIdentityView: View {
|
||||||
@StateObject var viewModel: AddIdentityViewModel
|
@StateObject var viewModel: AddIdentityViewModel
|
||||||
@EnvironmentObject var rootViewModel: RootViewModel
|
@EnvironmentObject var rootViewModel: RootViewModel
|
||||||
|
@State private var navigateToRegister = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
@ -15,7 +16,7 @@ struct AddIdentityView: View {
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
if let instance = viewModel.instance {
|
if let (instance, _) = viewModel.instanceAndURL {
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
KFImage(instance.thumbnail)
|
KFImage(instance.thumbnail)
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
@ -41,6 +42,25 @@ struct AddIdentityView: View {
|
||||||
} else {
|
} else {
|
||||||
Button("add-identity.log-in",
|
Button("add-identity.log-in",
|
||||||
action: viewModel.logInTapped)
|
action: viewModel.logInTapped)
|
||||||
|
if let (instance, url) = viewModel.instanceAndURL,
|
||||||
|
instance.registrations {
|
||||||
|
ZStack {
|
||||||
|
NavigationLink(
|
||||||
|
destination: RegistrationView(
|
||||||
|
viewModel: viewModel.registrationViewModel(
|
||||||
|
instance: instance,
|
||||||
|
url: url)),
|
||||||
|
isActive: $navigateToRegister) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.hidden()
|
||||||
|
Button(instance.approvalRequired
|
||||||
|
? "add-identity.request-invite"
|
||||||
|
: "add-identity.join") {
|
||||||
|
navigateToRegister.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if viewModel.isPublicTimelineAvailable {
|
if viewModel.isPublicTimelineAvailable {
|
||||||
Button("add-identity.browse", action: viewModel.browseTapped)
|
Button("add-identity.browse", action: viewModel.browseTapped)
|
||||||
}
|
}
|
||||||
|
@ -71,7 +91,10 @@ import PreviewViewModels
|
||||||
|
|
||||||
struct AddAccountView_Previews: PreviewProvider {
|
struct AddAccountView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
|
NavigationView {
|
||||||
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
|
AddIdentityView(viewModel: RootViewModel.preview.addIdentityViewModel())
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
103
Views/RegistrationView.swift
Normal file
103
Views/RegistrationView.swift
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import ViewModels
|
||||||
|
|
||||||
|
struct RegistrationView: View {
|
||||||
|
@StateObject var viewModel: RegistrationViewModel
|
||||||
|
|
||||||
|
@State private var presentWebView = false
|
||||||
|
@State private var toReview = ToReview.serverRules
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
TextField("registration.username", text: $viewModel.username)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
Text("@" + viewModel.instance.uri)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
TextField("registration.email", text: $viewModel.email)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
SecureField("registration.password", text: $viewModel.password)
|
||||||
|
SecureField("registration.password-confirmation", text: $viewModel.passwordConfirmation)
|
||||||
|
if viewModel.instance.approvalRequired {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("registration.reason-\(viewModel.instance.uri)")
|
||||||
|
TextEditor(text: $viewModel.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("registration.server-rules") {
|
||||||
|
toReview = .serverRules
|
||||||
|
presentWebView = true
|
||||||
|
}
|
||||||
|
Button("registration.terms-of-service") {
|
||||||
|
toReview = .termsOfService
|
||||||
|
presentWebView = true
|
||||||
|
}
|
||||||
|
Toggle("registration.agree-to-server-rules-and-terms-of-service",
|
||||||
|
isOn: $viewModel.agreement)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Group {
|
||||||
|
if viewModel.registering {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button(viewModel.instance.approvalRequired
|
||||||
|
? "add-identity.request-invite"
|
||||||
|
: "add-identity.join",
|
||||||
|
action: viewModel.registerTapped)
|
||||||
|
.disabled(!viewModel.registerButtonEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alertItem($viewModel.alertItem)
|
||||||
|
.sheet(isPresented: $presentWebView) { () -> SafariView in
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
switch toReview {
|
||||||
|
case .serverRules: url = viewModel.serverRulesURL
|
||||||
|
case .termsOfService: url = viewModel.termsOfServiceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafariView(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension RegistrationView {
|
||||||
|
enum ToReview {
|
||||||
|
case serverRules
|
||||||
|
case termsOfService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RegistrationError: LocalizedError {
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .passwordConfirmationMismatch:
|
||||||
|
return NSLocalizedString(
|
||||||
|
"registration.password-confirmation-mismatch",
|
||||||
|
comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import PreviewViewModels
|
||||||
|
|
||||||
|
struct RegistrationView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
RegistrationView(viewModel: RootViewModel.preview
|
||||||
|
.addIdentityViewModel()
|
||||||
|
.registrationViewModel(instance: .preview,
|
||||||
|
url: .previewInstanceURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
|
@ -13,7 +13,11 @@ struct RootView: View {
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
|
NavigationView {
|
||||||
AddIdentityView(viewModel: viewModel.addIdentityViewModel())
|
AddIdentityView(viewModel: viewModel.addIdentityViewModel())
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
16
Views/SafariView.swift
Normal file
16
Views/SafariView.swift
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import SafariServices
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SafariView: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||||
|
SFSafariViewController(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue