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 url: URL
public let authenticated: Bool
public let pending: Bool
public let lastUsedAt: Date
public let preferences: Identity.Preferences
public let instance: Identity.Instance?

View file

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

View file

@ -33,12 +33,13 @@ public struct 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(
updates: IdentityRecord(
id: id,
url: url,
authenticated: authenticated,
pending: pending,
lastUsedAt: Date(),
preferences: Identity.Preferences(),
instanceURI: nil,
@ -99,6 +100,16 @@ public extension IdentityDatabase {
.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,
forIdentityID identityID: UUID) -> AnyPublisher<Never, Error> {
databaseQueue.writePublisher {
@ -230,6 +241,7 @@ private extension IdentityDatabase {
t.column("id", .text).notNull().primaryKey(onConflict: .replace)
t.column("url", .text).notNull()
t.column("authenticated", .boolean).notNull()
t.column("pending", .boolean).notNull()
t.column("lastUsedAt", .datetime).notNull()
t.column("instanceURI", .text)
.indexed()

View file

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

View file

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

View file

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

View file

@ -22,15 +22,16 @@ struct AuthenticationService {
extension AuthenticationService {
func authenticate() -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let authorization = appAuthorization().share()
let authorization = appAuthorization(redirectURI: OAuth.authorizationCallbackURL).share()
return authorization
.zip(authorization.flatMap(authenticate(appAuthorization:)))
.eraseToAnyPublisher()
}
func register(_ registration: Registration) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let authorization = appAuthorization()
func register(_ registration: Registration, id: UUID) -> AnyPublisher<(AppAuthorization, AccessToken), Error> {
let redirectURI = OAuth.registrationCallbackURL.appendingPathComponent(id.uuidString)
let authorization = appAuthorization(redirectURI: redirectURI)
.share()
return authorization.zip(
@ -42,7 +43,7 @@ extension AuthenticationService {
grantType: OAuth.registrationGrantType,
scopes: OAuth.scopes,
code: nil,
redirectURI: OAuth.callbackURL.absoluteString))
redirectURI: redirectURI.absoluteString))
.flatMap { accessToken -> AnyPublisher<AccessToken, Error> in
mastodonAPIClient.accessToken = accessToken.accessToken
@ -62,7 +63,8 @@ private extension AuthenticationService {
static let authorizationCodeGrantType = "authorization_code"
static let registrationGrantType = "client_credentials"
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")!
}
@ -82,11 +84,11 @@ private extension AuthenticationService {
return code
}
func appAuthorization() -> AnyPublisher<AppAuthorization, Error> {
func appAuthorization(redirectURI: URL) -> AnyPublisher<AppAuthorization, Error> {
mastodonAPIClient.request(
AppAuthorizationEndpoint.apps(
clientName: OAuth.clientName,
redirectURI: OAuth.callbackURL.absoluteString,
redirectURI: redirectURI.absoluteString,
scopes: OAuth.scopes,
website: OAuth.website))
}
@ -102,7 +104,7 @@ private extension AuthenticationService {
.init(name: "client_id", value: appAuthorization.clientId),
.init(name: "scope", value: OAuth.scopes),
.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 {
@ -137,7 +139,7 @@ private extension AuthenticationService {
grantType: OAuth.authorizationCodeGrantType,
scopes: OAuth.scopes,
code: $0,
redirectURI: OAuth.callbackURL.absoluteString))
redirectURI: OAuth.authorizationCallbackURL.absoluteString))
}
.eraseToAnyPublisher()
}

View file

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

View file

@ -21,7 +21,7 @@ let db: IdentityDatabase = {
try! secrets.setInstanceURL(.previewInstanceURL)
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)
.sink { _ in } receiveValue: { _ in }

View file

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

View file

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

View file

@ -59,31 +59,34 @@ public extension NavigationViewModel {
}
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()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.store(in: &cancellables)
identification.service.refreshLists()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
identification.service.refreshFilters()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
if identification.identity.preferences.useServerPostingReadingPreferences {
identification.service.refreshServerPreferences()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
}
}
identification.service.refreshInstance()
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { _ in }
.sink { _ in } receiveValue: { _ in }
.store(in: &cancellables)
}

View file

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

View file

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

View file

@ -11,17 +11,23 @@ struct TabNavigationView: View {
@Environment(\.displayScale) var displayScale: CGFloat
var body: some View {
TabView(selection: $viewModel.selectedTab) {
ForEach(viewModel.tabs) { tab in
NavigationView {
view(tab: tab)
Group {
if viewModel.identification.identity.pending {
pendingView
} 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)
@ -40,6 +46,17 @@ struct TabNavigationView: View {
}
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
func view(tab: NavigationViewModel.Tab) -> some View {
switch tab {
@ -47,7 +64,8 @@ private extension TabNavigationView {
StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline))
.id(viewModel.timeline.id)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle(viewModel.timeline.title, displayMode: .inline)
.navigationTitle(viewModel.timeline.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
VStack {