Merge pull request #849 from omnivore-app/feature/delete-account

Delete account API
This commit is contained in:
Hongbo Wu 2022-07-09 08:54:57 +08:00 committed by GitHub
commit 4fe81eaf92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 686 additions and 46 deletions

View file

@ -26,7 +26,7 @@ struct DebugMenuView: View {
Button(
action: {
authenticator.logout()
authenticator.logout(dataService: dataService)
dataService.switchAppEnvironment(appEnvironment: selectedEnvironment)
},
label: { Text("Apply Changes") }

View file

@ -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)
}
}
}
}

View file

@ -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()
)

View file

@ -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
}

View file

@ -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

View file

@ -95,7 +95,7 @@ public final class DataService: ObservableObject {
}
}
private func resetCoreData() {
func resetCoreData() {
clearCoreData()
persistentContainer = PersistentContainer.make()

View file

@ -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 {

View file

@ -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"))
}
}
}
}
}

View file

@ -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

View file

@ -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()
)
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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
}
}

View file

@ -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>;

View file

@ -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!

View file

@ -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(

View file

@ -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'),
}

View file

@ -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 }
})

View file

@ -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!

View file

@ -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
})
})
})

View 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
)
})
})
})

View 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;

View 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;