Delete your own account !171

merged merged by cmc on 2026-10-14 08:08 UTC · krz/gitbay-ios:account-delete into main

3 files changed, +228 −1

Layout: unified · split

gitbay/Account/AccountViewModel.swift +74 −1
@@ -86,6 +86,9 @@ final class AccountViewModel {
86 /// repositories it can write to (v1.19.0); nil until read, or on a 86 /// repositories it can write to (v1.19.0); nil until read, or on a
87 /// server whose `show` has no `watch`. 87 /// server whose `show` has no `watch`.
88 private(set) var watchOn: Bool? 88 private(set) var watchOn: Bool?
89 /// Set once `account delete` has mailed its confirmation link: the
90 /// address it went to. Nothing changes until the link is opened.
91 private(set) var deletionMailedTo: String?
89 /// Whether replying to notification mail comments (gitbay v1.39.0). 92 /// Whether replying to notification mail comments (gitbay v1.39.0).
90 private(set) var replyOn: Bool? 93 private(set) var replyOn: Bool?
91 private var replyAvailable: Bool? 94 private var replyAvailable: Bool?
@@ -95,8 +98,13 @@ final class AccountViewModel {
95 98
96 private let client: GitbayClient 99 private let client: GitbayClient
97 100
98 init(client: GitbayClient) { 101 /// Where a deletion request is remembered across visits: the server
102 /// has no read that says one is pending.
103 private let defaults: UserDefaults
104
105 init(client: GitbayClient, defaults: UserDefaults = .standard) {
99 self.client = client 106 self.client = client
107 self.defaults = defaults
100 } 108 }
101 109
102 func load() async { 110 func load() async {
@@ -177,6 +185,71 @@ final class AccountViewModel {
177 return false 185 return false
178 } 186 }
179 187
188 // MARK: - Deleting the account
189
190 nonisolated private struct DeletionRequested: Decodable, Sendable {
191 let mailed: String
192 }
193
194 /// The link works for a day; after that the request is as good as gone.
195 nonisolated static let deletionLinkLife: TimeInterval = 24 * 60 * 60
196
197 private func deletionKey(_ username: String) -> String {
198 "gb.deletionRequest.\(client.instance.baseURL.absoluteString).\(username)"
199 }
200
201 /// A request made on an earlier visit whose link still works.
202 func loadPendingDeletion(username: String, now: Date = .now) {
203 guard let saved = defaults.dictionary(forKey: deletionKey(username)),
204 let address = saved["mailed"] as? String,
205 let at = saved["at"] as? Date, now.timeIntervalSince(at) < Self.deletionLinkLife else {
206 defaults.removeObject(forKey: deletionKey(username))
207 return
208 }
209 deletionMailedTo = address
210 }
211
212 /// `account delete --confirm <username>`: the server mails a link;
213 /// opening it disables the account, and seven days later it is purged.
214 func requestDeletion(username: String) async {
215 working = true
216 actionError = nil
217 defer { working = false }
218 do {
219 let requested = try await client.run(["account", "delete", "--confirm", username],
220 as: DeletionRequested.self)
221 deletionMailedTo = requested?.mailed
222 if let address = requested?.mailed {
223 defaults.set(["mailed": address, "at": Date.now], forKey: deletionKey(username))
224 }
225 } catch let error as GitbayError {
226 actionError = error.userFacingMessage
227 } catch {
228 actionError = GitbayError.transport(error).userFacingMessage
229 }
230 }
231
232 /// `account delete --cancel`: withdraws a request whose link has not
233 /// been opened.
234 func cancelDeletion(username: String) async {
235 working = true
236 actionError = nil
237 defer { working = false }
238 do {
239 try await client.run(["account", "delete", "--cancel"])
240 deletionMailedTo = nil
241 defaults.removeObject(forKey: deletionKey(username))
242 } catch GitbayError.notFound {
243 // Nothing pending: the link was opened, or it lapsed.
244 deletionMailedTo = nil
245 defaults.removeObject(forKey: deletionKey(username))
246 } catch let error as GitbayError {
247 actionError = error.userFacingMessage
248 } catch {
249 actionError = GitbayError.transport(error).userFacingMessage
250 }
251 }
252
180 // MARK: - SSH keys 253 // MARK: - SSH keys
181 254
182 /// `keys add [--scope full|git] [--label <text>]` — the 255 /// `keys add [--scope full|git] [--label <text>]` — the
gitbay/Views/Account/AccountView.swift +65
@@ -4,7 +4,11 @@ import SwiftUI
4/// API tokens have their own screen. 4/// API tokens have their own screen.
5struct AccountView: View { 5struct AccountView: View {
6 6
7 @Environment(SessionStore.self) private var session
7 @State private var model: AccountViewModel 8 @State private var model: AccountViewModel
9 @State private var deletingAccount = false
10 @State private var deleteConfirm = ""
11 @State private var deletionRefusal: String?
8 @State private var addingSSH = false 12 @State private var addingSSH = false
9 @State private var addingPGP = false 13 @State private var addingPGP = false
10 @State private var emailAddress = "" 14 @State private var emailAddress = ""
@@ -54,6 +58,7 @@ struct AccountView: View {
54 } 58 }
55 } 59 }
56 emailSection 60 emailSection
61 deleteAccountSection
57 } 62 }
58 } 63 }
59 .overlay { LoadStateOverlay(state: model.state) } 64 .overlay { LoadStateOverlay(state: model.state) }
@@ -62,6 +67,28 @@ struct AccountView: View {
62 .task { await model.load() } 67 .task { await model.load() }
63 .task { await model.loadNotificationPreferences() } 68 .task { await model.loadNotificationPreferences() }
64 .task { await model.loadEmails() } 69 .task { await model.loadEmails() }
70 .task { model.loadPendingDeletion(username: username) }
71 .alert("Not deleted", isPresented: Binding(get: { deletionRefusal != nil }, set: { if !$0 { deletionRefusal = nil } })) {
72 Button("OK", role: .cancel) {}
73 } message: {
74 Text(deletionRefusal ?? "")
75 }
76 .alert("Delete your account", isPresented: $deletingAccount) {
77 TextField("type \(username) to confirm", text: $deleteConfirm)
78 .autocorrectionDisabled()
79 .textInputAutocapitalization(.never)
80 Button("Mail the link", role: .destructive) {
81 Task {
82 await model.requestDeletion(username: username)
83 // The list's banner is far above this last row.
84 if model.deletionMailedTo == nil { deletionRefusal = model.actionError }
85 }
86 }
87 .disabled(deleteConfirm != username)
88 Button("Cancel", role: .cancel) {}
89 } message: {
90 Text("We mail a link to your address. Opening it disables the account at once, and seven days later deletes it: repositories, snippets, keys and addresses. What you wrote on other people's repositories stays, under the name ghost. Type \(username) to confirm.")
91 }
65 .confirmationDialog( 92 .confirmationDialog(
66 "Remove \(removingEmail?.address ?? "")?", 93 "Remove \(removingEmail?.address ?? "")?",
67 isPresented: Binding( 94 isPresented: Binding(
@@ -169,6 +196,44 @@ struct AccountView: View {
169 196
170 // MARK: - Sections 197 // MARK: - Sections
171 198
199 private var username: String { session.current?.username ?? "" }
200
201 /// Deleting the account is confirmed by mail, as on the web: nothing
202 /// changes until the link is opened, and signing in during the seven
203 /// days after cancels it.
204 private var deleteAccountSection: some View {
205 Section {
206 if let address = model.deletionMailedTo {
207 Label("A link went to \(address). It works for 24 hours; nothing changes until it is opened.",
208 systemImage: "envelope")
209 .font(.gbSans(.subheadline))
210 .accessibilityIdentifier("account-delete-mailed")
211 Button("Withdraw the request") {
212 Task { await model.cancelDeletion(username: username) }
213 }
214 .disabled(model.working)
215 .accessibilityIdentifier("account-delete-cancel")
216 } else {
217 Button(role: .destructive) {
218 deleteConfirm = ""
219 deletingAccount = true
220 } label: {
221 Label("Delete account", systemImage: "person.crop.circle.badge.xmark")
222 .font(.gbSans(.subheadline))
223 }
224 .disabled(model.working || username.isEmpty)
225 .accessibilityIdentifier("account-delete-button")
226 }
227 } footer: {
228 VStack(alignment: .leading, spacing: 4) {
229 Text("Keep a copy first:")
230 Text(verbatim: "ssh git@\(session.current?.instance.baseURL.host() ?? "the instance") account export > bundle.json")
231 .font(.gbMono(.caption))
232 .textSelection(.enabled)
233 }
234 }
235 }
236
172 /// `notifications settings mail` and `watch`. The inbox is filed 237 /// `notifications settings mail` and `watch`. The inbox is filed
173 /// either way, and login links still arrive. Watch lands with 238 /// either way, and login links still arrive. Watch lands with
174 /// v1.19.0; an older server shows mail alone. 239 /// v1.19.0; an older server shows mail alone.
gitbayTests/AccountDeleteTests.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return try #require(body["argv"] as? [String])
19 }
20}
21
22private func scratchDefaults() -> UserDefaults {
23 UserDefaults(suiteName: "gb-test-\(UUID().uuidString)")!
24}
25
26@MainActor
27struct AccountDeleteTests {
28
29 @Test func aRequestIsRememberedForTheLinksDayAndNoLonger() async throws {
30 let (client, stub) = try makeClient()
31 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
32 let defaults = scratchDefaults()
33 await AccountViewModel(client: client, defaults: defaults).requestDeletion(username: "sim")
34
35 let later = AccountViewModel(client: client, defaults: defaults)
36 later.loadPendingDeletion(username: "sim", now: .now.addingTimeInterval(60 * 60))
37 #expect(later.deletionMailedTo == "sim@example.test")
38
39 let tooLate = AccountViewModel(client: client, defaults: defaults)
40 tooLate.loadPendingDeletion(username: "sim", now: .now.addingTimeInterval(25 * 60 * 60))
41 #expect(tooLate.deletionMailedTo == nil)
42 }
43
44 @Test func withdrawingWhenNothingIsPendingClearsTheNotice() async throws {
45 let (client, stub) = try makeClient()
46 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no deletion is pending for sim","exit_code":3}"#, match: "cmd"))
47 let model = AccountViewModel(client: client, defaults: scratchDefaults())
48
49 await model.cancelDeletion(username: "sim")
50
51 #expect(model.deletionMailedTo == nil)
52 #expect(model.actionError == nil)
53 }
54
55 @Test func askingMailsALinkToTheAddressTheServerNames() async throws {
56 let (client, stub) = try makeClient()
57 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
58 let model = AccountViewModel(client: client, defaults: scratchDefaults())
59
60 await model.requestDeletion(username: "sim")
61
62 #expect(try posted(stub) == [["account", "delete", "--confirm", "sim"]])
63 #expect(model.deletionMailedTo == "sim@example.test")
64 }
65
66 @Test func aRefusalSaysWhyAndNothingIsPending() async throws {
67 let (client, stub) = try makeClient()
68 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"you are the instance's only admin; promote another account first","exit_code":4}"#, match: "cmd"))
69 let model = AccountViewModel(client: client, defaults: scratchDefaults())
70
71 await model.requestDeletion(username: "sim")
72
73 #expect(model.deletionMailedTo == nil)
74 #expect(model.actionError == "you are the instance's only admin; promote another account first")
75 }
76
77 @Test func withdrawingSendsCancel() async throws {
78 let (client, stub) = try makeClient()
79 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
80 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","cancelled":true},"exit_code":0}"#, match: "cmd"))
81 let model = AccountViewModel(client: client, defaults: scratchDefaults())
82 await model.requestDeletion(username: "sim")
83
84 await model.cancelDeletion(username: "sim")
85
86 #expect(try posted(stub).last == ["account", "delete", "--cancel"])
87 #expect(model.deletionMailedTo == nil)
88 }
89}