mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #849 from omnivore-app/feature/delete-account
Delete account API
This commit is contained in:
commit
4fe81eaf92
23 changed files with 686 additions and 46 deletions
|
|
@ -26,7 +26,7 @@ struct DebugMenuView: View {
|
|||
|
||||
Button(
|
||||
action: {
|
||||
authenticator.logout()
|
||||
authenticator.logout(dataService: dataService)
|
||||
dataService.switchAppEnvironment(appEnvironment: selectedEnvironment)
|
||||
},
|
||||
label: { Text("Apply Changes") }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct ManageAccountView: View {
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@State private var showDeleteAccountConfirmation = false
|
||||
@StateObject private var viewModel = ProfileContainerViewModel()
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
ProfileCard(data: viewModel.profileCardData)
|
||||
.task {
|
||||
await viewModel.loadProfileData(dataService: dataService)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(
|
||||
action: {
|
||||
showDeleteAccountConfirmation = true
|
||||
},
|
||||
label: { Text("Delete Account") }
|
||||
)
|
||||
.alert(isPresented: $showDeleteAccountConfirmation) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete your account? This action can't be undone."),
|
||||
primaryButton: .destructive(Text("Delete Account")) {
|
||||
Task {
|
||||
await viewModel.deleteAccount(dataService: dataService, authenticator: authenticator)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.deleteAccountErrorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.appBody)
|
||||
.foregroundColor(.red)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import Views
|
|||
@MainActor final class ProfileContainerViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var profileCardData = ProfileCardData()
|
||||
@Published var deleteAccountErrorMessage: String?
|
||||
|
||||
var appVersionString: String {
|
||||
if let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
|
||||
|
|
@ -31,6 +32,20 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func deleteAccount(dataService: DataService, authenticator: Authenticator) async {
|
||||
guard let currentViewer = dataService.currentViewer else {
|
||||
deleteAccountErrorMessage = "Unable to load account information."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await dataService.deleteAccount(userID: currentViewer.unwrappedUserID)
|
||||
authenticator.logout(dataService: dataService)
|
||||
} catch {
|
||||
deleteAccountErrorMessage = "We were unable to delete your account."
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfileCardData(viewer: Viewer) {
|
||||
profileCardData = ProfileCardData(
|
||||
name: viewer.unwrappedName,
|
||||
|
|
@ -106,14 +121,10 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
Section(footer: Text(viewModel.appVersionString)) {
|
||||
if FeatureFlag.showAccountDeletion {
|
||||
NavigationLink(
|
||||
destination: ManageAccountView(handleAccountDeletion: {
|
||||
print("delete account")
|
||||
})
|
||||
) {
|
||||
Text("Manage Account")
|
||||
}
|
||||
NavigationLink(
|
||||
destination: ManageAccountView()
|
||||
) {
|
||||
Text("Manage Account")
|
||||
}
|
||||
|
||||
Text("Logout")
|
||||
|
|
@ -124,7 +135,7 @@ struct ProfileView: View {
|
|||
Alert(
|
||||
title: Text("Are you sure you want to logout?"),
|
||||
primaryButton: .destructive(Text("Confirm")) {
|
||||
authenticator.logout()
|
||||
authenticator.logout(dataService: dataService)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public final class RootViewModel: ObservableObject {
|
|||
|
||||
#if DEBUG
|
||||
if CommandLine.arguments.contains("--uitesting") {
|
||||
services.authenticator.logout()
|
||||
services.authenticator.logout(dataService: services.dataService)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ public final class Authenticator: ObservableObject {
|
|||
ValetKey.authToken.value()
|
||||
}
|
||||
|
||||
public func logout() {
|
||||
public func logout(dataService: DataService) {
|
||||
dataService.resetCoreData()
|
||||
clearCreds()
|
||||
Authenticator.unregisterIntercomUser?()
|
||||
isLoggedIn = false
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func resetCoreData() {
|
||||
func resetCoreData() {
|
||||
clearCoreData()
|
||||
|
||||
persistentContainer = PersistentContainer.make()
|
||||
|
|
|
|||
|
|
@ -3382,6 +3382,136 @@ extension Selection where TypeLock == Never, Type == Never {
|
|||
typealias CreateReminderSuccess<T> = Selection<T, Objects.CreateReminderSuccess>
|
||||
}
|
||||
|
||||
extension Objects {
|
||||
struct DeleteAccountError {
|
||||
let __typename: TypeName = .deleteAccountError
|
||||
let errorCodes: [String: [Enums.DeleteAccountErrorCode]]
|
||||
|
||||
enum TypeName: String, Codable {
|
||||
case deleteAccountError = "DeleteAccountError"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Objects.DeleteAccountError: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
|
||||
|
||||
var map = HashMap()
|
||||
for codingKey in container.allKeys {
|
||||
if codingKey.isTypenameKey { continue }
|
||||
|
||||
let alias = codingKey.stringValue
|
||||
let field = GraphQLField.getFieldNameFromAlias(alias)
|
||||
|
||||
switch field {
|
||||
case "errorCodes":
|
||||
if let value = try container.decode([Enums.DeleteAccountErrorCode]?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
default:
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Unknown key \(field)."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
errorCodes = map["errorCodes"]
|
||||
}
|
||||
}
|
||||
|
||||
extension Fields where TypeLock == Objects.DeleteAccountError {
|
||||
func errorCodes() throws -> [Enums.DeleteAccountErrorCode] {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "errorCodes",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.errorCodes[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Selection where TypeLock == Never, Type == Never {
|
||||
typealias DeleteAccountError<T> = Selection<T, Objects.DeleteAccountError>
|
||||
}
|
||||
|
||||
extension Objects {
|
||||
struct DeleteAccountSuccess {
|
||||
let __typename: TypeName = .deleteAccountSuccess
|
||||
let userId: [String: String]
|
||||
|
||||
enum TypeName: String, Codable {
|
||||
case deleteAccountSuccess = "DeleteAccountSuccess"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Objects.DeleteAccountSuccess: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
|
||||
|
||||
var map = HashMap()
|
||||
for codingKey in container.allKeys {
|
||||
if codingKey.isTypenameKey { continue }
|
||||
|
||||
let alias = codingKey.stringValue
|
||||
let field = GraphQLField.getFieldNameFromAlias(alias)
|
||||
|
||||
switch field {
|
||||
case "userId":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
default:
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Unknown key \(field)."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
userId = map["userId"]
|
||||
}
|
||||
}
|
||||
|
||||
extension Fields where TypeLock == Objects.DeleteAccountSuccess {
|
||||
func userId() throws -> String {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "userID",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.userId[field.alias!] {
|
||||
return data
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return String.mockValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Selection where TypeLock == Never, Type == Never {
|
||||
typealias DeleteAccountSuccess<T> = Selection<T, Objects.DeleteAccountSuccess>
|
||||
}
|
||||
|
||||
extension Objects {
|
||||
struct DeleteHighlightError {
|
||||
let __typename: TypeName = .deleteHighlightError
|
||||
|
|
@ -7383,6 +7513,7 @@ extension Objects {
|
|||
let createNewsletterEmail: [String: Unions.CreateNewsletterEmailResult]
|
||||
let createReaction: [String: Unions.CreateReactionResult]
|
||||
let createReminder: [String: Unions.CreateReminderResult]
|
||||
let deleteAccount: [String: Unions.DeleteAccountResult]
|
||||
let deleteHighlight: [String: Unions.DeleteHighlightResult]
|
||||
let deleteHighlightReply: [String: Unions.DeleteHighlightReplyResult]
|
||||
let deleteLabel: [String: Unions.DeleteLabelResult]
|
||||
|
|
@ -7480,6 +7611,10 @@ extension Objects.Mutation: Decodable {
|
|||
if let value = try container.decode(Unions.CreateReminderResult?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "deleteAccount":
|
||||
if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "deleteHighlight":
|
||||
if let value = try container.decode(Unions.DeleteHighlightResult?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
|
|
@ -7667,6 +7802,7 @@ extension Objects.Mutation: Decodable {
|
|||
createNewsletterEmail = map["createNewsletterEmail"]
|
||||
createReaction = map["createReaction"]
|
||||
createReminder = map["createReminder"]
|
||||
deleteAccount = map["deleteAccount"]
|
||||
deleteHighlight = map["deleteHighlight"]
|
||||
deleteHighlightReply = map["deleteHighlightReply"]
|
||||
deleteLabel = map["deleteLabel"]
|
||||
|
|
@ -7884,6 +8020,25 @@ extension Fields where TypeLock == Objects.Mutation {
|
|||
}
|
||||
}
|
||||
|
||||
func deleteAccount<Type>(userId: String, selection: Selection<Type, Unions.DeleteAccountResult>) throws -> Type {
|
||||
let field = GraphQLField.composite(
|
||||
name: "deleteAccount",
|
||||
arguments: [Argument(name: "userID", type: "ID!", value: userId)],
|
||||
selection: selection.selection
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
if let data = data.deleteAccount[field.alias!] {
|
||||
return try selection.decode(data: data)
|
||||
}
|
||||
throw HttpError.badpayload
|
||||
case .mocking:
|
||||
return selection.mock()
|
||||
}
|
||||
}
|
||||
|
||||
func deleteHighlight<Type>(highlightId: String, selection: Selection<Type, Unions.DeleteHighlightResult>) throws -> Type {
|
||||
let field = GraphQLField.composite(
|
||||
name: "deleteHighlight",
|
||||
|
|
@ -18211,6 +18366,80 @@ extension Selection where TypeLock == Never, Type == Never {
|
|||
typealias CreateReminderResult<T> = Selection<T, Unions.CreateReminderResult>
|
||||
}
|
||||
|
||||
extension Unions {
|
||||
struct DeleteAccountResult {
|
||||
let __typename: TypeName
|
||||
let errorCodes: [String: [Enums.DeleteAccountErrorCode]]
|
||||
let userId: [String: String]
|
||||
|
||||
enum TypeName: String, Codable {
|
||||
case deleteAccountError = "DeleteAccountError"
|
||||
case deleteAccountSuccess = "DeleteAccountSuccess"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Unions.DeleteAccountResult: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
|
||||
|
||||
var map = HashMap()
|
||||
for codingKey in container.allKeys {
|
||||
if codingKey.isTypenameKey { continue }
|
||||
|
||||
let alias = codingKey.stringValue
|
||||
let field = GraphQLField.getFieldNameFromAlias(alias)
|
||||
|
||||
switch field {
|
||||
case "errorCodes":
|
||||
if let value = try container.decode([Enums.DeleteAccountErrorCode]?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "userId":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
default:
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Unknown key \(field)."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
__typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!)
|
||||
|
||||
errorCodes = map["errorCodes"]
|
||||
userId = map["userId"]
|
||||
}
|
||||
}
|
||||
|
||||
extension Fields where TypeLock == Unions.DeleteAccountResult {
|
||||
func on<Type>(deleteAccountError: Selection<Type, Objects.DeleteAccountError>, deleteAccountSuccess: Selection<Type, Objects.DeleteAccountSuccess>) throws -> Type {
|
||||
select([GraphQLField.fragment(type: "DeleteAccountError", selection: deleteAccountError.selection), GraphQLField.fragment(type: "DeleteAccountSuccess", selection: deleteAccountSuccess.selection)])
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
switch data.__typename {
|
||||
case .deleteAccountError:
|
||||
let data = Objects.DeleteAccountError(errorCodes: data.errorCodes)
|
||||
return try deleteAccountError.decode(data: data)
|
||||
case .deleteAccountSuccess:
|
||||
let data = Objects.DeleteAccountSuccess(userId: data.userId)
|
||||
return try deleteAccountSuccess.decode(data: data)
|
||||
}
|
||||
case .mocking:
|
||||
return deleteAccountError.mock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Selection where TypeLock == Never, Type == Never {
|
||||
typealias DeleteAccountResult<T> = Selection<T, Unions.DeleteAccountResult>
|
||||
}
|
||||
|
||||
extension Unions {
|
||||
struct DeleteHighlightReplyResult {
|
||||
let __typename: TypeName
|
||||
|
|
@ -22234,6 +22463,17 @@ extension Enums {
|
|||
}
|
||||
}
|
||||
|
||||
extension Enums {
|
||||
/// DeleteAccountErrorCode
|
||||
enum DeleteAccountErrorCode: String, CaseIterable, Codable {
|
||||
case forbidden = "FORBIDDEN"
|
||||
|
||||
case unauthorized = "UNAUTHORIZED"
|
||||
|
||||
case userNotFound = "USER_NOT_FOUND"
|
||||
}
|
||||
}
|
||||
|
||||
extension Enums {
|
||||
/// DeleteHighlightErrorCode
|
||||
enum DeleteHighlightErrorCode: String, CaseIterable, Codable {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func deleteAccount(userID: String) async throws {
|
||||
enum MutationResult {
|
||||
case success(id: String)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.DeleteAccountResult> {
|
||||
try $0.on(
|
||||
deleteAccountError: .init {
|
||||
.error(errorMessage: (try $0.errorCodes().first ?? .forbidden).rawValue)
|
||||
},
|
||||
deleteAccountSuccess: .init {
|
||||
.success(id: try $0.userId())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.deleteAccount(userId: userID, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { mutationResult in
|
||||
guard let payload = try? mutationResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "failed to delete user"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "failed to delete user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import Foundation
|
|||
#endif
|
||||
|
||||
public enum FeatureFlag {
|
||||
public static let showAccountDeletion = false
|
||||
public static let enableSnoozeFromShareExtension = false
|
||||
public static let enableRemindersFromShareExtension = false
|
||||
public static let enableReadNow = false
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct ManageAccountView: View {
|
||||
let handleAccountDeletion: () -> Void
|
||||
@State private var showDeleteAccountConfirmation = false
|
||||
|
||||
public init(handleAccountDeletion: @escaping () -> Void) {
|
||||
self.handleAccountDeletion = handleAccountDeletion
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(
|
||||
action: {
|
||||
showDeleteAccountConfirmation = true
|
||||
},
|
||||
label: { Text("Delete my account") }
|
||||
)
|
||||
.alert(isPresented: $showDeleteAccountConfirmation) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to delete your account? This action can't be undone."),
|
||||
primaryButton: .destructive(Text("Delete Account")) {
|
||||
handleAccountDeletion()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ services:
|
|||
- http.cors.allow-credentials=true
|
||||
- http.port=9201
|
||||
volumes:
|
||||
- ./.docker/elastic-test-data:/usr/share/elasticsearch-test/data
|
||||
- ./.docker/elastic-test-data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- "9201:9201"
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
UpdateSet,
|
||||
UserData,
|
||||
} from './model'
|
||||
import DataModel, { MAX_RECORDS_LIMIT } from '../model'
|
||||
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
|
||||
import Knex from 'knex'
|
||||
import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logMethod } from '../helpers'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
|
|
@ -327,6 +327,18 @@ class UserModel extends DataModel<UserData, CreateSet, UpdateSet> {
|
|||
}
|
||||
return this.kx.transaction((tx) => this.updateProfile(userId, set, tx))
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async delete(
|
||||
userId: string,
|
||||
tx?: Knex.Transaction
|
||||
): Promise<UserData | { error: DataModelError }> {
|
||||
if (tx) {
|
||||
return super.delete(userId, tx)
|
||||
}
|
||||
|
||||
return this.kx.transaction((tx) => super.delete(userId, tx))
|
||||
}
|
||||
}
|
||||
|
||||
export default UserModel
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ export const getPageByParam = async <K extends keyof ParamSet>(
|
|||
id: body.hits.hits[0]._id,
|
||||
} as Page
|
||||
} catch (e) {
|
||||
console.error('failed to get pages by param in elastic', e)
|
||||
console.error('failed to get page by param in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -509,3 +509,41 @@ export const countByCreatedAt = async (
|
|||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const deletePagesByParam = async <K extends keyof ParamSet>(
|
||||
param: Record<K, Page[K]>,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const params = {
|
||||
query: {
|
||||
bool: {
|
||||
filter: Object.keys(param).map((key) => {
|
||||
return {
|
||||
term: {
|
||||
[key]: param[key as K],
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { body } = await client.deleteByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (body.deleted > 0) {
|
||||
// * means deleting all pages of the same user
|
||||
await ctx.pubsub.entityDeleted(EntityType.PAGE, '*', ctx.uid)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e) {
|
||||
console.error('failed to delete pages by param in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -424,6 +424,24 @@ export type CreateReminderSuccess = {
|
|||
reminder: Reminder;
|
||||
};
|
||||
|
||||
export type DeleteAccountError = {
|
||||
__typename?: 'DeleteAccountError';
|
||||
errorCodes: Array<DeleteAccountErrorCode>;
|
||||
};
|
||||
|
||||
export enum DeleteAccountErrorCode {
|
||||
Forbidden = 'FORBIDDEN',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
UserNotFound = 'USER_NOT_FOUND'
|
||||
}
|
||||
|
||||
export type DeleteAccountResult = DeleteAccountError | DeleteAccountSuccess;
|
||||
|
||||
export type DeleteAccountSuccess = {
|
||||
__typename?: 'DeleteAccountSuccess';
|
||||
userID: Scalars['ID'];
|
||||
};
|
||||
|
||||
export type DeleteHighlightError = {
|
||||
__typename?: 'DeleteHighlightError';
|
||||
errorCodes: Array<DeleteHighlightErrorCode>;
|
||||
|
|
@ -863,6 +881,7 @@ export type Mutation = {
|
|||
createNewsletterEmail: CreateNewsletterEmailResult;
|
||||
createReaction: CreateReactionResult;
|
||||
createReminder: CreateReminderResult;
|
||||
deleteAccount: DeleteAccountResult;
|
||||
deleteHighlight: DeleteHighlightResult;
|
||||
deleteHighlightReply: DeleteHighlightReplyResult;
|
||||
deleteLabel: DeleteLabelResult;
|
||||
|
|
@ -948,6 +967,11 @@ export type MutationCreateReminderArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationDeleteAccountArgs = {
|
||||
userID: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteHighlightArgs = {
|
||||
highlightId: Scalars['ID'];
|
||||
};
|
||||
|
|
@ -2437,6 +2461,10 @@ export type ResolversTypes = {
|
|||
CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess'];
|
||||
CreateReminderSuccess: ResolverTypeWrapper<CreateReminderSuccess>;
|
||||
Date: ResolverTypeWrapper<Scalars['Date']>;
|
||||
DeleteAccountError: ResolverTypeWrapper<DeleteAccountError>;
|
||||
DeleteAccountErrorCode: DeleteAccountErrorCode;
|
||||
DeleteAccountResult: ResolversTypes['DeleteAccountError'] | ResolversTypes['DeleteAccountSuccess'];
|
||||
DeleteAccountSuccess: ResolverTypeWrapper<DeleteAccountSuccess>;
|
||||
DeleteHighlightError: ResolverTypeWrapper<DeleteHighlightError>;
|
||||
DeleteHighlightErrorCode: DeleteHighlightErrorCode;
|
||||
DeleteHighlightReplyError: ResolverTypeWrapper<DeleteHighlightReplyError>;
|
||||
|
|
@ -2772,6 +2800,9 @@ export type ResolversParentTypes = {
|
|||
CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess'];
|
||||
CreateReminderSuccess: CreateReminderSuccess;
|
||||
Date: Scalars['Date'];
|
||||
DeleteAccountError: DeleteAccountError;
|
||||
DeleteAccountResult: ResolversParentTypes['DeleteAccountError'] | ResolversParentTypes['DeleteAccountSuccess'];
|
||||
DeleteAccountSuccess: DeleteAccountSuccess;
|
||||
DeleteHighlightError: DeleteHighlightError;
|
||||
DeleteHighlightReplyError: DeleteHighlightReplyError;
|
||||
DeleteHighlightReplyResult: ResolversParentTypes['DeleteHighlightReplyError'] | ResolversParentTypes['DeleteHighlightReplySuccess'];
|
||||
|
|
@ -3274,6 +3305,20 @@ export interface DateScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes
|
|||
name: 'Date';
|
||||
}
|
||||
|
||||
export type DeleteAccountErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteAccountError'] = ResolversParentTypes['DeleteAccountError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['DeleteAccountErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type DeleteAccountResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteAccountResult'] = ResolversParentTypes['DeleteAccountResult']> = {
|
||||
__resolveType: TypeResolveFn<'DeleteAccountError' | 'DeleteAccountSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type DeleteAccountSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteAccountSuccess'] = ResolversParentTypes['DeleteAccountSuccess']> = {
|
||||
userID?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type DeleteHighlightErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteHighlightError'] = ResolversParentTypes['DeleteHighlightError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['DeleteHighlightErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
|
@ -3617,6 +3662,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
|||
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType>;
|
||||
createReaction?: Resolver<ResolversTypes['CreateReactionResult'], ParentType, ContextType, RequireFields<MutationCreateReactionArgs, 'input'>>;
|
||||
createReminder?: Resolver<ResolversTypes['CreateReminderResult'], ParentType, ContextType, RequireFields<MutationCreateReminderArgs, 'input'>>;
|
||||
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
|
||||
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
|
||||
deleteHighlightReply?: Resolver<ResolversTypes['DeleteHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightReplyArgs, 'highlightReplyId'>>;
|
||||
deleteLabel?: Resolver<ResolversTypes['DeleteLabelResult'], ParentType, ContextType, RequireFields<MutationDeleteLabelArgs, 'id'>>;
|
||||
|
|
@ -4394,6 +4440,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
|||
CreateReminderResult?: CreateReminderResultResolvers<ContextType>;
|
||||
CreateReminderSuccess?: CreateReminderSuccessResolvers<ContextType>;
|
||||
Date?: GraphQLScalarType;
|
||||
DeleteAccountError?: DeleteAccountErrorResolvers<ContextType>;
|
||||
DeleteAccountResult?: DeleteAccountResultResolvers<ContextType>;
|
||||
DeleteAccountSuccess?: DeleteAccountSuccessResolvers<ContextType>;
|
||||
DeleteHighlightError?: DeleteHighlightErrorResolvers<ContextType>;
|
||||
DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers<ContextType>;
|
||||
DeleteHighlightReplyResult?: DeleteHighlightReplyResultResolvers<ContextType>;
|
||||
|
|
|
|||
|
|
@ -371,6 +371,22 @@ type CreateReminderSuccess {
|
|||
|
||||
scalar Date
|
||||
|
||||
type DeleteAccountError {
|
||||
errorCodes: [DeleteAccountErrorCode!]!
|
||||
}
|
||||
|
||||
enum DeleteAccountErrorCode {
|
||||
FORBIDDEN
|
||||
UNAUTHORIZED
|
||||
USER_NOT_FOUND
|
||||
}
|
||||
|
||||
union DeleteAccountResult = DeleteAccountError | DeleteAccountSuccess
|
||||
|
||||
type DeleteAccountSuccess {
|
||||
userID: ID!
|
||||
}
|
||||
|
||||
type DeleteHighlightError {
|
||||
errorCodes: [DeleteHighlightErrorCode!]!
|
||||
}
|
||||
|
|
@ -766,6 +782,7 @@ type Mutation {
|
|||
createNewsletterEmail: CreateNewsletterEmailResult!
|
||||
createReaction(input: CreateReactionInput!): CreateReactionResult!
|
||||
createReminder(input: CreateReminderInput!): CreateReminderResult!
|
||||
deleteAccount(userID: ID!): DeleteAccountResult!
|
||||
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
|
||||
deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult!
|
||||
deleteLabel(id: ID!): DeleteLabelResult!
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ export const createArticleResolver = authorized<
|
|||
let uploadFileUrlOverride = ''
|
||||
if (uploadFileId) {
|
||||
const uploadFileData = await authTrx(async (tx) => {
|
||||
return await models.uploadFile.setFileUploadComplete(uploadFileId, tx)
|
||||
return models.uploadFile.setFileUploadComplete(uploadFileId, tx)
|
||||
})
|
||||
if (!uploadFileData || !uploadFileData.id || !uploadFileData.fileName) {
|
||||
return pageError(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
createLabelResolver,
|
||||
createNewsletterEmailResolver,
|
||||
createReminderResolver,
|
||||
deleteAccountResolver,
|
||||
deleteHighlightResolver,
|
||||
deleteLabelResolver,
|
||||
deleteNewsletterEmailResolver,
|
||||
|
|
@ -118,6 +119,7 @@ export const functionResolvers = {
|
|||
googleLogin: googleLoginResolver,
|
||||
googleSignup: googleSignupResolver,
|
||||
logOut: logOutResolver,
|
||||
deleteAccount: deleteAccountResolver,
|
||||
saveArticleReadingProgress: saveArticleReadingProgressResolver,
|
||||
updateUser: updateUserResolver,
|
||||
updateUserProfile: updateUserProfileResolver,
|
||||
|
|
@ -586,4 +588,5 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('Webhook'),
|
||||
...resultResolveTypeResolver('ApiKeys'),
|
||||
...resultResolveTypeResolver('RevokeApiKey'),
|
||||
...resultResolveTypeResolver('DeleteAccount'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import {
|
||||
DeleteAccountError,
|
||||
DeleteAccountErrorCode,
|
||||
DeleteAccountSuccess,
|
||||
GoogleSignupResult,
|
||||
LoginErrorCode,
|
||||
LoginResult,
|
||||
LogOutErrorCode,
|
||||
LogOutResult,
|
||||
MutationDeleteAccountArgs,
|
||||
MutationGoogleLoginArgs,
|
||||
MutationGoogleSignupArgs,
|
||||
MutationLoginArgs,
|
||||
|
|
@ -34,6 +38,7 @@ import { validateUsername } from '../../utils/usernamePolicy'
|
|||
import * as jwt from 'jsonwebtoken'
|
||||
import { createUser } from '../../services/create_user'
|
||||
import { comparePassword, hashPassword } from '../../utils/auth'
|
||||
import { deletePagesByParam } from '../../elastic/pages'
|
||||
|
||||
export const updateUserResolver = authorized<
|
||||
UpdateUserSuccess,
|
||||
|
|
@ -355,3 +360,45 @@ export const signupResolver: ResolverFn<
|
|||
return { errorCodes: [SignupErrorCode.Unknown] }
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteAccountResolver = authorized<
|
||||
DeleteAccountSuccess,
|
||||
DeleteAccountError,
|
||||
MutationDeleteAccountArgs
|
||||
>(async (_, { userID }, { models, claims, log, pubsub }) => {
|
||||
const user = await models.user.get(userID)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.UserNotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (user.id !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Deleting a user account', {
|
||||
userID,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteAccountResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
const deletedUser = await models.user.delete(userID)
|
||||
if ('error' in deletedUser) {
|
||||
log.error('Error deleting user account', deletedUser.error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.UserNotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// delete this user's pages in elastic
|
||||
await deletePagesByParam({ userId: userID }, { uid: userID, pubsub })
|
||||
|
||||
return { userID }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -189,6 +189,22 @@ const schema = gql`
|
|||
message: String
|
||||
}
|
||||
|
||||
enum DeleteAccountErrorCode {
|
||||
USER_NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
FORBIDDEN
|
||||
}
|
||||
|
||||
type DeleteAccountError {
|
||||
errorCodes: [DeleteAccountErrorCode!]!
|
||||
}
|
||||
|
||||
type DeleteAccountSuccess {
|
||||
userID: ID!
|
||||
}
|
||||
|
||||
union DeleteAccountResult = DeleteAccountSuccess | DeleteAccountError
|
||||
|
||||
union UpdateUserResult = UpdateUserSuccess | UpdateUserError
|
||||
input UpdateUserInput {
|
||||
name: String! @sanitize(maxLength: 50)
|
||||
|
|
@ -1752,6 +1768,7 @@ const schema = gql`
|
|||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
|
||||
logOut: LogOutResult!
|
||||
deleteAccount(userID: ID!): DeleteAccountResult!
|
||||
updateUser(input: UpdateUserInput!): UpdateUserResult!
|
||||
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult!
|
||||
createArticle(input: CreateArticleInput!): CreateArticleResult!
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
countByCreatedAt,
|
||||
createPage,
|
||||
deletePage,
|
||||
deletePagesByParam,
|
||||
getPageById,
|
||||
getPageByParam,
|
||||
searchPages,
|
||||
|
|
@ -301,4 +302,41 @@ describe('elastic api', () => {
|
|||
expect(result).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePagesByParam', () => {
|
||||
const userId = 'test user id'
|
||||
|
||||
before(async () => {
|
||||
// create a testing page
|
||||
await createPage(
|
||||
{
|
||||
content: 'deletePagesByParam content',
|
||||
createdAt: new Date(),
|
||||
hash: '',
|
||||
id: '',
|
||||
pageType: PageType.Article,
|
||||
readingProgressAnchorIndex: 0,
|
||||
readingProgressPercent: 0,
|
||||
savedAt: new Date(),
|
||||
slug: 'deletePagesByParam slug',
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
title: 'deletePagesByParam title',
|
||||
url: 'https://localhost/deletePagesByParam',
|
||||
userId,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes page by userId', async () => {
|
||||
const deleted = await deletePagesByParam(
|
||||
{
|
||||
userId,
|
||||
},
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(deleted).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
71
packages/api/test/resolvers/user_delete_account.test.ts
Normal file
71
packages/api/test/resolvers/user_delete_account.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { createTestUser, deleteTestUser } from '../db'
|
||||
import { graphqlRequest, request } from '../util'
|
||||
import * as chai from 'chai'
|
||||
import { expect } from 'chai'
|
||||
import 'mocha'
|
||||
import { User } from '../../src/entity/user'
|
||||
import chaiString from 'chai-string'
|
||||
import { DeleteAccountErrorCode } from '../../src/generated/graphql'
|
||||
|
||||
chai.use(chaiString)
|
||||
|
||||
const deleteAccountRequest = async (authToken: string, userId: string) => {
|
||||
const mutation = `
|
||||
mutation {
|
||||
deleteAccount(
|
||||
userID: "${userId}",
|
||||
) {
|
||||
... on DeleteAccountSuccess {
|
||||
userID
|
||||
}
|
||||
... on DeleteAccountError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
return graphqlRequest(mutation, authToken).expect(200)
|
||||
}
|
||||
|
||||
describe('the deleteAccount API', () => {
|
||||
const username = 'newFakeUser'
|
||||
let authToken: string
|
||||
let user: User
|
||||
|
||||
before(async () => {
|
||||
// create test user and login
|
||||
user = await createTestUser(username)
|
||||
const res = await request
|
||||
.post('/local/debug/fake-user-login')
|
||||
.send({ fakeEmail: user.email })
|
||||
|
||||
authToken = res.body.authToken
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteTestUser(username)
|
||||
})
|
||||
|
||||
context('deleting a user that exists', () => {
|
||||
it('should return a unauthorized error if authToken is invalid', async () => {
|
||||
const res = await deleteAccountRequest('invalid-auth-token', user.id)
|
||||
expect(res.body.data.deleteAccount.errorCodes).to.contain(
|
||||
DeleteAccountErrorCode.Unauthorized
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the user id after a successful user deletion', async () => {
|
||||
const res = await deleteAccountRequest(authToken, user.id)
|
||||
expect(res.body.data.deleteAccount.userID).to.eql(user.id)
|
||||
})
|
||||
})
|
||||
|
||||
context('deleting a user that does not exist', () => {
|
||||
it('should return a user not found error if user id is invalid', async () => {
|
||||
const res = await deleteAccountRequest(authToken, 'invalid-user-id')
|
||||
expect(res.body.data.deleteAccount.errorCodes).to.contain(
|
||||
DeleteAccountErrorCode.UserNotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
9
packages/db/migrations/0086.do.grant_delete_on_user_table.sql
Executable file
9
packages/db/migrations/0086.do.grant_delete_on_user_table.sql
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
-- Type: DO
|
||||
-- Name: grant_delete_on_user_table
|
||||
-- Description: Allows the Omnivore User to delete themselves (for delete account app feature)
|
||||
|
||||
BEGIN;
|
||||
|
||||
GRANT DELETE ON omnivore.user TO omnivore_user;
|
||||
|
||||
COMMIT;
|
||||
9
packages/db/migrations/0086.undo.grant_delete_on_user_table.sql
Executable file
9
packages/db/migrations/0086.undo.grant_delete_on_user_table.sql
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
-- Type: UNDO
|
||||
-- Name: grant_delete_on_user_table
|
||||
-- Description: Allows the Omnivore User to delete themselves (for delete account app feature)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- do nothing here, there's no reason to undo this migration.
|
||||
|
||||
COMMIT;
|
||||
Loading…
Reference in a new issue