diff --git a/locales/index.d.ts b/locales/index.d.ts index 1b1e1e4cc7..22befdc3f7 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4007,9 +4007,7 @@ export interface Locale extends ILocale { * アカウントが削除されます。よろしいですか? */ "deleteAccountConfirm": string; - /** - * パスワードが間違っています。 - */ + "truncateAccountConfirm": string; "incorrectPassword": string; /** * 「{choice}」に投票しますか? @@ -4195,9 +4193,7 @@ export interface Locale extends ILocale { * アカウント削除 */ "deleteAccount": string; - /** - * ドキュメント - */ + "truncateAccount": string; "document": string; /** * ページキャッシュ数 @@ -7514,6 +7510,13 @@ export interface Locale extends ILocale { */ "inProgress": string; }; + "_accountTruncate": { + "accountDelete": string; + "mayTakeTime": string; + "requestAccountTruncate": string; + "started": string; + "inProgress": string; + }; "_ad": { /** * 戻る diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b209a2b4ad..84c34a44cc 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -996,6 +996,7 @@ followingVisibility: "フォローの公開範囲" followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見る" deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" +truncateAccountConfirm: "ダイレクトとピン留めされたノート、関連ドライブのファイルを除くすべてのノートとドライブのファイルが削除されます。続行しますか?" incorrectPassword: "パスワードが間違っています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" @@ -1043,6 +1044,7 @@ requireAdminForView: "閲覧するには管理者アカウントでログイン isSystemAccount: "システムにより自動で作成・管理されているアカウントです。" typeToConfirm: "この操作を行うには {x} と入力してください" deleteAccount: "アカウント削除" +truncateAccount: "アカウント整理" document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" @@ -1955,6 +1957,13 @@ _accountDelete: started: "削除処理が開始されました。" inProgress: "削除が進行中" +_accountTruncate: + accountDelete: "アカウントの整理" + mayTakeTime: "アカウントの整理は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。" + requestAccountTruncate: "アカウント整理をリクエスト" + started: "整理処理が開始されました。" + inProgress: "整理が進行中" + _ad: back: "戻る" reduceFrequencyOfThisAd: "この広告の表示頻度を下げる" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index acbc41a785..861559f148 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -995,6 +995,7 @@ unmuteThread: "글타래 뮤트 해제" followingVisibility: "팔로우의 공개 범위" followersVisibility: "팔로워의 공개 범위" continueThread: "이 글타래 이어서 보기" +truncateAccountConfirm: "다이렉트와 고정된 노트, 관련된 파일을 제외한 모든 노트와 파일이 삭제됩니다. 계속하시겠습니까? " deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 돼요. 그래도 계속할까요? " incorrectPassword: "비밀번호가 다른 것 같아요!" voteConfirm: "\"{choice}\"에 투표할까요?" @@ -1043,6 +1044,7 @@ requireAdminForView: "열람하려면 관리자 계정으로 로그인 해야해 isSystemAccount: "시스템에 의해 자동으로 생성되어 관리되는 계정이에요." typeToConfirm: "계속하시려면 {x} 을(를) 입력해 주세요" deleteAccount: "계정 삭제" +truncateAccount: "계정 청소" document: "문서" numberOfPageCache: "페이지 캐시 수" numberOfPageCacheDescription: "숫자가 클 수록 편리성이 높아지지만, 시스템 자원과 메모리를 더 많이 사용하게 돼요." @@ -1922,6 +1924,12 @@ _accountDelete: requestAccountDelete: "계정 삭제 요청" started: "삭제 작업이 시작되었어요." inProgress: "삭제 진행 중" +_accountTruncate: + accountTruncate: "계정 청소" + mayTakeTime: "계정 청소는 서버에 부하를 가하기 때문에, 작성한 콘텐츠나 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있습니다." + requestAccountTruncate: "계정 청소 요청" + started: "청소 작업이 시작되었습니다." + inProgress: "청소 진행 중" _ad: back: "뒤로" reduceFrequencyOfThisAd: "이 광고의 표시 빈도 낮추기" diff --git a/misskey-assets b/misskey-assets index cf3ce27b2e..8149938737 160000 --- a/misskey-assets +++ b/misskey-assets @@ -1 +1 @@ -Subproject commit cf3ce27b2eb8417233072e3d6d2fb7c5356c2364 +Subproject commit 81499387376c00cf7d287a179d15ba2988b5c7eb diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index cda1b641d1..127ae1ea09 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -17,6 +17,7 @@ import { CaptchaService } from './CaptchaService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; +import { TruncateAccountService } from './TruncateAccountService.js'; import { DownloadService } from './DownloadService.js'; import { DriveService } from './DriveService.js'; import { EmailService } from './EmailService.js'; @@ -160,6 +161,7 @@ const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: Capt const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; +const $TruncateAccountService: Provider = { provide: 'TruncateAccountService', useExisting: TruncateAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; const $EmailService: Provider = { provide: 'EmailService', useExisting: EmailService }; @@ -305,6 +307,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv CreateSystemUserService, CustomEmojiService, DeleteAccountService, + TruncateAccountService, DownloadService, DriveService, EmailService, @@ -446,6 +449,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, + $TruncateAccountService, $DownloadService, $DriveService, $EmailService, @@ -588,6 +592,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv CreateSystemUserService, CustomEmojiService, DeleteAccountService, + TruncateAccountService, DownloadService, DriveService, EmailService, @@ -728,6 +733,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, + $TruncateAccountService, $DownloadService, $DriveService, $EmailService, diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 00d9faea3c..e211b865d2 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -366,6 +366,16 @@ export class QueueService { return this.dbQueue.add('reportAbuse', report); } + @bindThis + public createTruncateAccountJob(user: ThinUser, opts = {}) { + return this.dbQueue.add('truncateAccount', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean, withReplies?: boolean }[]) { const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); diff --git a/packages/backend/src/core/TruncateAccountService.ts b/packages/backend/src/core/TruncateAccountService.ts new file mode 100644 index 0000000000..ef042cf259 --- /dev/null +++ b/packages/backend/src/core/TruncateAccountService.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class TruncateAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private queueService: QueueService, + ) { + } + + @bindThis + public async truncateAccount(user: { + id: string; + host: string | null; + }): Promise { + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); + + this.queueService.createTruncateAccountJob(user, { + soft: false, + }); + } +} diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 8b9e5a45b7..70b1e5761f 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -17,6 +17,7 @@ import { CleanChartsProcessorService } from './processors/CleanChartsProcessorSe import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; @@ -69,6 +70,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ImportCustomEmojisProcessorService, ImportAntennasProcessorService, DeleteAccountProcessorService, + TruncateAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, RelationshipProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 255bbada07..69938401b7 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -30,6 +30,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; @@ -111,6 +112,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, private importAntennasProcessorService: ImportAntennasProcessorService, private deleteAccountProcessorService: DeleteAccountProcessorService, + private truncateAccountProcessorService: TruncateAccountProcessorService, private deleteFileProcessorService: DeleteFileProcessorService, private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, private relationshipProcessorService: RelationshipProcessorService, @@ -189,6 +191,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'importAntennas': return this.importAntennasProcessorService.process(job); case 'deleteAccount': return this.deleteAccountProcessorService.process(job); case 'reportAbuse': return this.reportAbuseProcessorService.process(job); + case 'truncateAccount': return this.truncateAccountProcessorService.process(job); default: throw new Error(`unrecognized job type ${job.name} for db`); } }, { diff --git a/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts b/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts new file mode 100644 index 0000000000..4b2f33b989 --- /dev/null +++ b/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { And, In, MoreThan, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { DriveFilesRepository, NotesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import { bindThis } from '@/decorators.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbUserTruncateJobData } from '../types.js'; + +@Injectable() +export class TruncateAccountProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + private noteDeleteService: NoteDeleteService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('truncate-account'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`Truncate notes and drives account of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); + const piningNoteIds = pinings.map(pining => pining.noteId); // pining.note always undefined (bug?) + + const specifiedNotes = await this.notesRepository.findBy({ + userId: user.id, + visibility: Not(In(['public', 'home', 'followers'])), + }); + const specifiedNoteIds = specifiedNotes.map(note => note.id); + + const keepFileIds = (await Promise.all([...piningNoteIds, ...specifiedNoteIds].map(async (noteId) => { + const note = await this.notesRepository.findOneBy({ id: noteId }); + + return note?.fileIds; + }))).flat().filter((fileId) => fileId !== undefined); + + { // Delete notes + let cursor: MiNote['id'] | null = null; + + while (true) { + const notes = await this.notesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { + id: And(Not(In([...piningNoteIds, ...specifiedNoteIds])), MoreThan(cursor)), + } : { + id: Not(In([...piningNoteIds, ...specifiedNoteIds])), + }), + }, + take: 100, + order: { + id: 1, + }, + }) as MiNote[]; + + if (notes.length === 0) { + break; + } + + cursor = notes.at(-1)?.id ?? null; + + await Promise.all(notes.map((note) => { + return this.noteDeleteService.delete(user, note, false, user); + })); + } + + this.logger.succ('All of notes deleted'); + } + + { // Delete files + let cursor: MiDriveFile['id'] | null = null; + + while (true) { + const files = await this.driveFilesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { + id: And(Not(In(keepFileIds)), MoreThan(cursor)), + } : { + id: Not(In(keepFileIds)), + }), + }, + take: 10, + order: { + id: 1, + }, + }) as MiDriveFile[]; + + if (files.length === 0) { + break; + } + + cursor = files.at(-1)?.id ?? null; + + for (const file of files) { + await this.driveService.deleteFileSync(file); + } + } + + this.logger.succ('All of files deleted'); + } + + return 'Account notes and drives are truncated'; + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 6130740286..d426b7a4eb 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -59,6 +59,7 @@ export type DbJobMap = { importUserLists: DbUserImportJobData; importCustomEmojis: DbUserImportJobData; deleteAccount: DbUserDeleteJobData; + truncateAccount: DbUserTruncateJobData; } export type DbJobDataWithUser = { @@ -80,6 +81,10 @@ export type DbUserDeleteJobData = { soft?: boolean; }; +export type DbUserTruncateJobData = { + user: ThinUser; +}; + export type DbUserImportJobData = { user: ThinUser; fileId: MiDriveFile['id']; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 9bc3506135..5eab6e6866 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -212,6 +212,7 @@ import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; +import * as ep___i_truncateAccount from './endpoints/i/truncate-account.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; @@ -614,6 +615,7 @@ const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default }; const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; +const $i_truncateAccount: Provider = { provide: 'ep:i/truncate-account', useClass: ep___i_truncateAccount.default }; const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; @@ -1021,6 +1023,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_claimAchievement, $i_changePassword, $i_deleteAccount, + $i_truncateAccount, $i_exportBlocking, $i_exportFollowing, $i_exportMute, @@ -1421,6 +1424,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_claimAchievement, $i_changePassword, $i_deleteAccount, + $i_truncateAccount, $i_exportBlocking, $i_exportFollowing, $i_exportMute, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index ff75ec81b9..bbce31f79d 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -211,6 +211,7 @@ import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; +import * as ep___i_truncateAccount from './endpoints/i/truncate-account.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; @@ -611,6 +612,7 @@ const eps = [ ['i/claim-achievement', ep___i_claimAchievement], ['i/change-password', ep___i_changePassword], ['i/delete-account', ep___i_deleteAccount], + ['i/truncate-account', ep___i_truncateAccount], ['i/export-blocking', ep___i_exportBlocking], ['i/export-following', ep___i_exportFollowing], ['i/export-mute', ep___i_exportMute], diff --git a/packages/backend/src/server/api/endpoints/i/truncate-account.ts b/packages/backend/src/server/api/endpoints/i/truncate-account.ts new file mode 100644 index 0000000000..8ff841789a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/truncate-account.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import bcrypt from 'bcryptjs'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { TruncateAccountService } from '@/core/TruncateAccountService.js'; +import { DI } from '@/di-symbols.js'; +import { UserAuthService } from '@/core/UserAuthService.js'; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + password: { type: 'string' }, + token: { type: 'string', nullable: true }, + }, + required: ['password'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userAuthService: UserAuthService, + private truncateAccountService: TruncateAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const token = ps.token; + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + if (profile.twoFactorEnabled) { + if (token == null) { + throw new Error('authentication failed'); + } + + try { + await this.userAuthService.twoFactorAuthenticate(profile, token); + } catch (e) { + throw new Error('authentication failed'); + } + } + + const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id }); + if (userDetailed.isDeleted) { + return; + } + + const passwordMatched = await bcrypt.compare(ps.password, profile.password!); + if (!passwordMatched) { + throw new Error('incorrect password'); + } + + await this.truncateAccountService.truncateAccount(me); + }); + } +} diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 71573b017e..117809d36e 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -48,6 +48,17 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ {{ i18n.ts._accountTruncate.mayTakeTime }} + {{ i18n.ts._accountTruncate.requestAccountTruncate }} + {{ i18n.ts._accountTruncate.inProgress }} +
+
+ @@ -135,6 +146,28 @@ async function deleteAccount() { await signout(); } +async function truncateAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.truncateAccountConfirm, + }); + if (canceled) return; + } + + const auth = await os.authenticateDialog(); + if (auth.canceled) return; + + await os.apiWithDialog('i/truncate-account', { + password: auth.result.password, + token: auth.result.token, + }); + + await os.alert({ + title: i18n.ts._accountTruncate.started, + }); +} + async function reloadAsk() { if (defaultStore.state.requireRefreshBehavior === 'dialog') { const { canceled } = await os.confirm({