Pending accounts

This commit is contained in:
Justin Mazzocchi 2020-09-13 01:03:08 -07:00
parent 9f80ac6360
commit 1b49ec2515
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
15 changed files with 149 additions and 84 deletions

View file

@ -7,6 +7,7 @@ public struct Identity: Codable, Hashable, Identifiable {
public let id: UUID public let id: UUID
public let url: URL public let url: URL
public let authenticated: Bool public let authenticated: Bool
public let pending: Bool
public let lastUsedAt: Date public let lastUsedAt: Date
public let preferences: Identity.Preferences public let preferences: Identity.Preferences
public let instance: Identity.Instance? public let instance: Identity.Instance?

View file

@ -9,6 +9,7 @@ extension Identity {
id: result.identity.id, id: result.identity.id,
url: result.identity.url, url: result.identity.url,
authenticated: result.identity.authenticated, authenticated: result.identity.authenticated,
pending: result.identity.pending,
lastUsedAt: result.identity.lastUsedAt, lastUsedAt: result.identity.lastUsedAt,
preferences: result.identity.preferences, preferences: result.identity.preferences,
instance: result.instance, instance: result.instance,

View file

@ -33,12 +33,13 @@ public struct IdentityDatabase {
} }
public extension IdentityDatabase { public extension IdentityDatabase {
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> { func createIdentity(id: UUID, url: URL, authenticated: Bool, pending: Bool) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher( databaseQueue.writePublisher(
updates: IdentityRecord( updates: IdentityRecord(
id: id, id: id,
url: url, url: url,
authenticated: authenticated, authenticated: authenticated,
pending: pending,
lastUsedAt: Date(), lastUsedAt: Date(),
preferences: Identity.Preferences(), preferences: Identity.Preferences(),
instanceURI: nil, instanceURI: nil,
@ -99,6 +100,16 @@ public extension IdentityDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func confirmIdentity(id: UUID) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
try IdentityRecord
.filter(Column("id") == id)
.updateAll($0, Column("pending").set(to: false))
}
.ignoreOutput()
.eraseToAnyPublisher()
}
func updatePreferences(_ preferences: Mastodon.Preferences, func updatePreferences(_ preferences: Mastodon.Preferences,
forIdentityID identityID: UUID) -> AnyPublisher<Never, Error> { forIdentityID identityID: UUID) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher { databaseQueue.writePublisher {
@ -230,6 +241,7 @@ private extension IdentityDatabase {
t.column("id", .text).notNull().primaryKey(onConflict: .replace) t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("url", .text).notNull() t.column("url", .text).notNull()
t.column("authenticated", .boolean).notNull() t.column("authenticated", .boolean).notNull()
t.column("pending", .boolean).notNull()
t.column("lastUsedAt", .datetime).notNull() t.column("lastUsedAt", .datetime).notNull()
t.column("instanceURI", .text) t.column("instanceURI", .text)
.indexed() .indexed()

View file

@ -8,6 +8,7 @@ struct IdentityRecord: Codable, Hashable, FetchableRecord, PersistableRecord {
let id: UUID let id: UUID
let url: URL let url: URL
let authenticated: Bool let authenticated: Bool
let pending: Bool
let lastUsedAt: Date let lastUsedAt: Date
let preferences: Identity.Preferences let preferences: Identity.Preferences
let instanceURI: String? let instanceURI: String?

View file

@ -23,7 +23,9 @@
"secondary-navigation.preferences" = "Preferences"; "secondary-navigation.preferences" = "Preferences";
"identities.accounts" = "Accounts"; "identities.accounts" = "Accounts";
"identities.browsing" = "Browsing"; "identities.browsing" = "Browsing";
"identities.pending" = "Pending";
"lists.new-list-title" = "New List Title"; "lists.new-list-title" = "New List Title";
"pending.pending-confirmation" = "Your account is pending confirmation";
"preferences" = "Preferences"; "preferences" = "Preferences";
"preferences.posting-reading" = "Posting and Reading"; "preferences.posting-reading" = "Posting and Reading";
"preferences.posting" = "Posting"; "preferences.posting" = "Posting";

View file

@ -24,6 +24,12 @@ public struct AllIdentitiesService {
} }
public extension AllIdentitiesService { public extension AllIdentitiesService {
enum IdentityCreation {
case authentication
case registration(Registration)
case browsing
}
func identityService(id: UUID) throws -> IdentityService { func identityService(id: UUID) throws -> IdentityService {
try IdentityService(id: id, database: database, environment: environment) try IdentityService(id: id, database: database, environment: environment)
} }
@ -32,19 +38,50 @@ public extension AllIdentitiesService {
database.immediateMostRecentlyUsedIdentityIDObservation() database.immediateMostRecentlyUsedIdentityIDObservation()
} }
func createIdentity(url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> { func createIdentity(url: URL, kind: IdentityCreation) -> AnyPublisher<Never, Error> {
createIdentity( let id = environment.uuid()
url: url, let secrets = Secrets(identityID: id, keychain: environment.keychain)
authenticationPublisher: authenticated
? AuthenticationService(url: url, environment: environment).authenticate()
: nil)
}
func createIdentity(url: URL, registration: Registration) -> AnyPublisher<Never, Error> { do {
createIdentity( try secrets.setInstanceURL(url)
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
let createIdentityPublisher = database.createIdentity(
id: id,
url: url, url: url,
authenticationPublisher: AuthenticationService(url: url, environment: environment) authenticated: kind.authenticated,
.register(registration)) pending: kind.pending)
.ignoreOutput()
.handleEvents(receiveCompletion: {
if case .finished = $0 {
identitiesCreatedSubject.send(id)
}
})
.eraseToAnyPublisher()
let authenticationPublisher: AnyPublisher<(AppAuthorization, AccessToken), Error>
switch kind {
case .authentication:
authenticationPublisher = AuthenticationService(url: url, environment: environment)
.authenticate()
case let .registration(registration):
authenticationPublisher = AuthenticationService(url: url, environment: environment)
.register(registration, id: id)
case .browsing:
return createIdentityPublisher
}
return authenticationPublisher
.tryMap {
try secrets.setClientID($0.clientId)
try secrets.setClientSecret($0.clientSecret)
try secrets.setAccessToken($1.accessToken)
}
.flatMap { createIdentityPublisher }
.eraseToAnyPublisher()
} }
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> { func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
@ -91,42 +128,22 @@ public extension AllIdentitiesService {
} }
} }
private extension AllIdentitiesService { private extension AllIdentitiesService.IdentityCreation {
func createIdentity( var authenticated: Bool {
url: URL, switch self {
authenticationPublisher: AnyPublisher<(AppAuthorization, AccessToken), Error>?) -> AnyPublisher<Never, Error> { case .authentication, .registration:
let id = environment.uuid() return true
let secrets = Secrets(identityID: id, keychain: environment.keychain) case .browsing:
return false
do {
try secrets.setInstanceURL(url)
} catch {
return Fail(error: error).eraseToAnyPublisher()
} }
}
let createIdentityPublisher = database.createIdentity( var pending: Bool {
id: id, switch self {
url: url, case .registration:
authenticated: authenticationPublisher != nil) return true
.ignoreOutput() case .authentication, .browsing:
.handleEvents(receiveCompletion: { return false
if case .finished = $0 {
identitiesCreatedSubject.send(id)
}
})
.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
} }
} }
} }

View file

@ -22,15 +22,16 @@ struct AuthenticationService {
extension AuthenticationService { extension AuthenticationService {
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> { func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let authorization = appAuthorization().share() let authorization = appAuthorization(redirectURI: OAuth.authorizationCallbackURL).share()
return authorization return authorization
.zip(authorization.flatMap(authenticate(appAuthorization:))) .zip(authorization.flatMap(authenticate(appAuthorization:)))
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func register(_ registration: Registration) -> AnyPublisher<(AppAuthorization, AccessToken), Error> { func register(_ registration: Registration, id: UUID) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let authorization = appAuthorization() let redirectURI = OAuth.registrationCallbackURL.appendingPathComponent(id.uuidString)
let authorization = appAuthorization(redirectURI: redirectURI)
.share() .share()
return authorization.zip( return authorization.zip(
@ -42,7 +43,7 @@ extension AuthenticationService {
grantType: OAuth.registrationGrantType, grantType: OAuth.registrationGrantType,
scopes: OAuth.scopes, scopes: OAuth.scopes,
code: nil, code: nil,
redirectURI: OAuth.callbackURL.absoluteString)) redirectURI: redirectURI.absoluteString))
.flatMap { accessToken -> AnyPublisher<AccessToken, Error> in .flatMap { accessToken -> AnyPublisher<AccessToken, Error> in
mastodonAPIClient.accessToken = accessToken.accessToken mastodonAPIClient.accessToken = accessToken.accessToken
@ -62,7 +63,8 @@ private extension AuthenticationService {
static let authorizationCodeGrantType = "authorization_code" static let authorizationCodeGrantType = "authorization_code"
static let registrationGrantType = "client_credentials" static let registrationGrantType = "client_credentials"
static let callbackURLScheme = "metatext" static let callbackURLScheme = "metatext"
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")! static let authorizationCallbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
static let registrationCallbackURL = URL(string: "https://metatext.link/confirmation")!
static let website = URL(string: "https://metabolist.com/metatext")! static let website = URL(string: "https://metabolist.com/metatext")!
} }
@ -82,11 +84,11 @@ private extension AuthenticationService {
return code return code
} }
func appAuthorization() -> AnyPublisher<AppAuthorization, Error> { func appAuthorization(redirectURI: URL) -> AnyPublisher<AppAuthorization, Error> {
mastodonAPIClient.request( mastodonAPIClient.request(
AppAuthorizationEndpoint.apps( AppAuthorizationEndpoint.apps(
clientName: OAuth.clientName, clientName: OAuth.clientName,
redirectURI: OAuth.callbackURL.absoluteString, redirectURI: redirectURI.absoluteString,
scopes: OAuth.scopes, scopes: OAuth.scopes,
website: OAuth.website)) website: OAuth.website))
} }
@ -102,7 +104,7 @@ private extension AuthenticationService {
.init(name: "client_id", value: appAuthorization.clientId), .init(name: "client_id", value: appAuthorization.clientId),
.init(name: "scope", value: OAuth.scopes), .init(name: "scope", value: OAuth.scopes),
.init(name: "response_type", value: "code"), .init(name: "response_type", value: "code"),
.init(name: "redirect_uri", value: OAuth.callbackURL.absoluteString) .init(name: "redirect_uri", value: OAuth.authorizationCallbackURL.absoluteString)
] ]
guard let authorizationURL = authorizationURLComponents.url else { guard let authorizationURL = authorizationURLComponents.url else {
@ -137,7 +139,7 @@ private extension AuthenticationService {
grantType: OAuth.authorizationCodeGrantType, grantType: OAuth.authorizationCodeGrantType,
scopes: OAuth.scopes, scopes: OAuth.scopes,
code: $0, code: $0,
redirectURI: OAuth.callbackURL.absoluteString)) redirectURI: OAuth.authorizationCallbackURL.absoluteString))
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -56,6 +56,10 @@ public extension IdentityService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func confirmIdentity() -> AnyPublisher<Never, Error> {
identityDatabase.confirmIdentity(id: identityID)
}
func identitiesObservation() -> AnyPublisher<[Identity], Error> { func identitiesObservation() -> AnyPublisher<[Identity], Error> {
identityDatabase.identitiesObservation() identityDatabase.identitiesObservation()
} }

View file

@ -21,7 +21,7 @@ let db: IdentityDatabase = {
try! secrets.setInstanceURL(.previewInstanceURL) try! secrets.setInstanceURL(.previewInstanceURL)
try! secrets.setAccessToken(UUID().uuidString) try! secrets.setAccessToken(UUID().uuidString)
_ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true) _ = db.createIdentity(id: id, url: .previewInstanceURL, authenticated: true, pending: false)
.receive(on: ImmediateScheduler.shared) .receive(on: ImmediateScheduler.shared)
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }

View file

@ -30,11 +30,11 @@ public final class AddIdentityViewModel: ObservableObject {
public extension AddIdentityViewModel { public extension AddIdentityViewModel {
func logInTapped() { func logInTapped() {
addIdentity(authenticated: true) addIdentity(kind: .authentication)
} }
func browseTapped() { func browseTapped() {
addIdentity(authenticated: false) addIdentity(kind: .browsing)
} }
func refreshFilter() { func refreshFilter() {
@ -93,10 +93,10 @@ private extension AddIdentityViewModel {
.assign(to: &$isPublicTimelineAvailable) .assign(to: &$isPublicTimelineAvailable)
} }
func addIdentity(authenticated: Bool) { func addIdentity(kind: AllIdentitiesService.IdentityCreation) {
instanceURLService.url(text: urlFieldText).publisher instanceURLService.url(text: urlFieldText).publisher
.map { ($0, authenticated) } .map { ($0, kind) }
.flatMap(allIdentitiesService.createIdentity(url:authenticated:)) .flatMap(allIdentitiesService.createIdentity(url:kind:))
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true }) .handleEvents(receiveSubscription: { [weak self] _ in self?.loading = true })
.sink { [weak self] in .sink { [weak self] in

View file

@ -8,6 +8,7 @@ public final class IdentitiesViewModel: ObservableObject {
public let currentIdentityID: UUID public let currentIdentityID: UUID
@Published public var authenticated = [Identity]() @Published public var authenticated = [Identity]()
@Published public var unauthenticated = [Identity]() @Published public var unauthenticated = [Identity]()
@Published public var pending = [Identity]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
private let identification: Identification private let identification: Identification
@ -21,9 +22,11 @@ public final class IdentitiesViewModel: ObservableObject {
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.share() .share()
observation.map { $0.filter { $0.authenticated } } observation.map { $0.filter { $0.authenticated && !$0.pending } }
.assign(to: &$authenticated) .assign(to: &$authenticated)
observation.map { $0.filter { !$0.authenticated } } observation.map { $0.filter { !$0.authenticated && !$0.pending } }
.assign(to: &$unauthenticated) .assign(to: &$unauthenticated)
observation.map { $0.filter { $0.pending } }
.assign(to: &$pending)
} }
} }

View file

@ -59,31 +59,34 @@ public extension NavigationViewModel {
} }
func refreshIdentity() { func refreshIdentity() {
if identification.identity.authenticated { if identification.identity.pending {
identification.service.verifyCredentials()
.collect()
.map { _ in () }
.flatMap(identification.service.confirmIdentity)
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
} else if identification.identity.authenticated {
identification.service.verifyCredentials() identification.service.verifyCredentials()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in } .sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
identification.service.refreshLists() identification.service.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } receiveValue: { _ in }
.sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
identification.service.refreshFilters() identification.service.refreshFilters()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } receiveValue: { _ in }
.sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
if identification.identity.preferences.useServerPostingReadingPreferences { if identification.identity.preferences.useServerPostingReadingPreferences {
identification.service.refreshServerPreferences() identification.service.refreshServerPreferences()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } receiveValue: { _ in }
.sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
identification.service.refreshInstance() identification.service.refreshInstance()
.assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } receiveValue: { _ in }
.sink { _ in }
.store(in: &cancellables) .store(in: &cancellables)
} }

View file

@ -50,7 +50,7 @@ public extension RegistrationViewModel {
return return
} }
allIdentitiesService.createIdentity(url: url, registration: registration) allIdentitiesService.createIdentity(url: url, kind: .registration(registration))
.handleEvents(receiveSubscription: { [weak self] _ in self?.registering = true }) .handleEvents(receiveSubscription: { [weak self] _ in self?.registering = true })
.mapError { error -> Error in .mapError { error -> Error in
if error is URLError { if error is URLError {

View file

@ -21,6 +21,7 @@ struct IdentitiesView: View {
} }
section(title: "identities.accounts", identities: viewModel.authenticated) section(title: "identities.accounts", identities: viewModel.authenticated)
section(title: "identities.browsing", identities: viewModel.unauthenticated) section(title: "identities.browsing", identities: viewModel.unauthenticated)
section(title: "identities.pending", identities: viewModel.pending)
} }
.toolbar { .toolbar {
ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) { ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) {

View file

@ -11,17 +11,23 @@ struct TabNavigationView: View {
@Environment(\.displayScale) var displayScale: CGFloat @Environment(\.displayScale) var displayScale: CGFloat
var body: some View { var body: some View {
TabView(selection: $viewModel.selectedTab) { Group {
ForEach(viewModel.tabs) { tab in if viewModel.identification.identity.pending {
NavigationView { pendingView
view(tab: tab) } else {
TabView(selection: $viewModel.selectedTab) {
ForEach(viewModel.tabs) { tab in
NavigationView {
view(tab: tab)
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label(tab.title, systemImage: tab.systemImageName)
.accessibility(label: Text(tab.title))
}
.tag(tab)
}
} }
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Label(tab.title, systemImage: tab.systemImageName)
.accessibility(label: Text(tab.title))
}
.tag(tab)
} }
} }
.environmentObject(viewModel.identification) .environmentObject(viewModel.identification)
@ -40,6 +46,17 @@ struct TabNavigationView: View {
} }
private extension TabNavigationView { private extension TabNavigationView {
@ViewBuilder
var pendingView: some View {
NavigationView {
Text("pending.pending-confirmation")
.navigationBarItems(leading: secondaryNavigationButton)
.navigationTitle(viewModel.identification.identity.handle)
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
@ViewBuilder @ViewBuilder
func view(tab: NavigationViewModel.Tab) -> some View { func view(tab: NavigationViewModel.Tab) -> some View {
switch tab { switch tab {
@ -47,7 +64,8 @@ private extension TabNavigationView {
StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline)) StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
.id(viewModel.timeline.id) .id(viewModel.timeline.id)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
.navigationBarTitle(viewModel.timeline.title, displayMode: .inline) .navigationTitle(viewModel.timeline.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
VStack { VStack {