mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-24 17:20:59 +00:00
Pending accounts
This commit is contained in:
parent
9f80ac6360
commit
1b49ec2515
15 changed files with 149 additions and 84 deletions
|
@ -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?
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -56,6 +56,10 @@ public extension IdentityService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func confirmIdentity() -> AnyPublisher<Never, Error> {
|
||||
identityDatabase.confirmIdentity(id: identityID)
|
||||
}
|
||||
|
||||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
identityDatabase.identitiesObservation()
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue