Commit 8a1dc95b82

8a1dc95b82c8a3b37e5d80164f5535a3301a8510

parent: 3d6141f15c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 08:07 UTC

Delete your account: remembered on return; refusals in view

The server has no read for a pending request, so the screen remembers
one for the link's 24 hours and offers to withdraw it on a later visit;
withdrawing one already gone clears the note. A refusal, such as being
the only admin, comes up in an alert beside the control rather than in
the banner far above it, and the export command is selectable.

Ref #35

Layout: unified · split

gitbay/Account/AccountViewModel.swift +33 −2
@@ -98,8 +98,13 @@ final class AccountViewModel {
9898
9999 private let client: GitbayClient
100100
101 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) {
102106 self.client = client
107 self.defaults = defaults
103108 }
104109
105110 func load() async {
@@ -186,6 +191,24 @@ final class AccountViewModel {
186191 let mailed: String
187192 }
188193
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
189212 /// `account delete --confirm <username>`: the server mails a link;
190213 /// opening it disables the account, and seven days later it is purged.
191214 func requestDeletion(username: String) async {
@@ -196,6 +219,9 @@ final class AccountViewModel {
196219 let requested = try await client.run(["account", "delete", "--confirm", username],
197220 as: DeletionRequested.self)
198221 deletionMailedTo = requested?.mailed
222 if let address = requested?.mailed {
223 defaults.set(["mailed": address, "at": Date.now], forKey: deletionKey(username))
224 }
199225 } catch let error as GitbayError {
200226 actionError = error.userFacingMessage
201227 } catch {
@@ -205,13 +231,18 @@ final class AccountViewModel {
205231
206232 /// `account delete --cancel`: withdraws a request whose link has not
207233 /// been opened.
208 func cancelDeletion() async {
234 func cancelDeletion(username: String) async {
209235 working = true
210236 actionError = nil
211237 defer { working = false }
212238 do {
213239 try await client.run(["account", "delete", "--cancel"])
214240 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))
215246 } catch let error as GitbayError {
216247 actionError = error.userFacingMessage
217248 } catch {
gitbay/Views/Account/AccountView.swift +19 −3
@@ -8,6 +8,7 @@ struct AccountView: View {
88 @State private var model: AccountViewModel
99 @State private var deletingAccount = false
1010 @State private var deleteConfirm = ""
11 @State private var deletionRefusal: String?
1112 @State private var addingSSH = false
1213 @State private var addingPGP = false
1314 @State private var emailAddress = ""
@@ -66,12 +67,22 @@ struct AccountView: View {
6667 .task { await model.load() }
6768 .task { await model.loadNotificationPreferences() }
6869 .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 }
6976 .alert("Delete your account", isPresented: $deletingAccount) {
7077 TextField("type \(username) to confirm", text: $deleteConfirm)
7178 .autocorrectionDisabled()
7279 .textInputAutocapitalization(.never)
7380 Button("Mail the link", role: .destructive) {
74 Task { await model.requestDeletion(username: username) }
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 }
7586 }
7687 .disabled(deleteConfirm != username)
7788 Button("Cancel", role: .cancel) {}
@@ -198,7 +209,7 @@ struct AccountView: View {
198209 .font(.gbSans(.subheadline))
199210 .accessibilityIdentifier("account-delete-mailed")
200211 Button("Withdraw the request") {
201 Task { await model.cancelDeletion() }
212 Task { await model.cancelDeletion(username: username) }
202213 }
203214 .disabled(model.working)
204215 .accessibilityIdentifier("account-delete-cancel")
@@ -214,7 +225,12 @@ struct AccountView: View {
214225 .accessibilityIdentifier("account-delete-button")
215226 }
216227 } footer: {
217 Text("Keep a copy first: ssh git@\(session.current?.instance.baseURL.host() ?? "the instance") account export > bundle.json")
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 }
218234 }
219235 }
220236
gitbayTests/AccountDeleteTests.swift +34 −4
@@ -19,13 +19,43 @@ private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
1919 }
2020}
2121
22private func scratchDefaults() -> UserDefaults {
23 UserDefaults(suiteName: "gb-test-\(UUID().uuidString)")!
24}
25
2226@MainActor
2327struct AccountDeleteTests {
2428
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
2555 @Test func askingMailsALinkToTheAddressTheServerNames() async throws {
2656 let (client, stub) = try makeClient()
2757 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
28 let model = AccountViewModel(client: client)
58 let model = AccountViewModel(client: client, defaults: scratchDefaults())
2959
3060 await model.requestDeletion(username: "sim")
3161
@@ -36,7 +66,7 @@ struct AccountDeleteTests {
3666 @Test func aRefusalSaysWhyAndNothingIsPending() async throws {
3767 let (client, stub) = try makeClient()
3868 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"))
39 let model = AccountViewModel(client: client)
69 let model = AccountViewModel(client: client, defaults: scratchDefaults())
4070
4171 await model.requestDeletion(username: "sim")
4272
@@ -48,10 +78,10 @@ struct AccountDeleteTests {
4878 let (client, stub) = try makeClient()
4979 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
5080 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","cancelled":true},"exit_code":0}"#, match: "cmd"))
51 let model = AccountViewModel(client: client)
81 let model = AccountViewModel(client: client, defaults: scratchDefaults())
5282 await model.requestDeletion(username: "sim")
5383
54 await model.cancelDeletion()
84 await model.cancelDeletion(username: "sim")
5585
5686 #expect(try posted(stub).last == ["account", "delete", "--cancel"])
5787 #expect(model.deletionMailedTo == nil)