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 {
98 98
99 private let client: GitbayClient 99 private let client: GitbayClient
100 100
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) {
102 self.client = client 106 self.client = client
107 self.defaults = defaults
103 } 108 }
104 109
105 func load() async { 110 func load() async {
@@ -186,6 +191,24 @@ final class AccountViewModel {
186 let mailed: String 191 let mailed: String
187 } 192 }
188 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
189 /// `account delete --confirm <username>`: the server mails a link; 212 /// `account delete --confirm <username>`: the server mails a link;
190 /// opening it disables the account, and seven days later it is purged. 213 /// opening it disables the account, and seven days later it is purged.
191 func requestDeletion(username: String) async { 214 func requestDeletion(username: String) async {
@@ -196,6 +219,9 @@ final class AccountViewModel {
196 let requested = try await client.run(["account", "delete", "--confirm", username], 219 let requested = try await client.run(["account", "delete", "--confirm", username],
197 as: DeletionRequested.self) 220 as: DeletionRequested.self)
198 deletionMailedTo = requested?.mailed 221 deletionMailedTo = requested?.mailed
222 if let address = requested?.mailed {
223 defaults.set(["mailed": address, "at": Date.now], forKey: deletionKey(username))
224 }
199 } catch let error as GitbayError { 225 } catch let error as GitbayError {
200 actionError = error.userFacingMessage 226 actionError = error.userFacingMessage
201 } catch { 227 } catch {
@@ -205,13 +231,18 @@ final class AccountViewModel {
205 231
206 /// `account delete --cancel`: withdraws a request whose link has not 232 /// `account delete --cancel`: withdraws a request whose link has not
207 /// been opened. 233 /// been opened.
208 func cancelDeletion() async { 234 func cancelDeletion(username: String) async {
209 working = true 235 working = true
210 actionError = nil 236 actionError = nil
211 defer { working = false } 237 defer { working = false }
212 do { 238 do {
213 try await client.run(["account", "delete", "--cancel"]) 239 try await client.run(["account", "delete", "--cancel"])
214 deletionMailedTo = nil 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))
215 } catch let error as GitbayError { 246 } catch let error as GitbayError {
216 actionError = error.userFacingMessage 247 actionError = error.userFacingMessage
217 } catch { 248 } catch {
gitbay/Views/Account/AccountView.swift +19 −3
@@ -8,6 +8,7 @@ struct AccountView: View {
8 @State private var model: AccountViewModel 8 @State private var model: AccountViewModel
9 @State private var deletingAccount = false 9 @State private var deletingAccount = false
10 @State private var deleteConfirm = "" 10 @State private var deleteConfirm = ""
11 @State private var deletionRefusal: String?
11 @State private var addingSSH = false 12 @State private var addingSSH = false
12 @State private var addingPGP = false 13 @State private var addingPGP = false
13 @State private var emailAddress = "" 14 @State private var emailAddress = ""
@@ -66,12 +67,22 @@ struct AccountView: View {
66 .task { await model.load() } 67 .task { await model.load() }
67 .task { await model.loadNotificationPreferences() } 68 .task { await model.loadNotificationPreferences() }
68 .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 }
69 .alert("Delete your account", isPresented: $deletingAccount) { 76 .alert("Delete your account", isPresented: $deletingAccount) {
70 TextField("type \(username) to confirm", text: $deleteConfirm) 77 TextField("type \(username) to confirm", text: $deleteConfirm)
71 .autocorrectionDisabled() 78 .autocorrectionDisabled()
72 .textInputAutocapitalization(.never) 79 .textInputAutocapitalization(.never)
73 Button("Mail the link", role: .destructive) { 80 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 }
75 } 86 }
76 .disabled(deleteConfirm != username) 87 .disabled(deleteConfirm != username)
77 Button("Cancel", role: .cancel) {} 88 Button("Cancel", role: .cancel) {}
@@ -198,7 +209,7 @@ struct AccountView: View {
198 .font(.gbSans(.subheadline)) 209 .font(.gbSans(.subheadline))
199 .accessibilityIdentifier("account-delete-mailed") 210 .accessibilityIdentifier("account-delete-mailed")
200 Button("Withdraw the request") { 211 Button("Withdraw the request") {
201 Task { await model.cancelDeletion() } 212 Task { await model.cancelDeletion(username: username) }
202 } 213 }
203 .disabled(model.working) 214 .disabled(model.working)
204 .accessibilityIdentifier("account-delete-cancel") 215 .accessibilityIdentifier("account-delete-cancel")
@@ -214,7 +225,12 @@ struct AccountView: View {
214 .accessibilityIdentifier("account-delete-button") 225 .accessibilityIdentifier("account-delete-button")
215 } 226 }
216 } footer: { 227 } 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 }
218 } 234 }
219 } 235 }
220 236
gitbayTests/AccountDeleteTests.swift +34 −4
@@ -19,13 +19,43 @@ private func posted(_ stub: StubProtocol.Box) throws -> [[String]] {
19 } 19 }
20} 20}
21 21
22private func scratchDefaults() -> UserDefaults {
23 UserDefaults(suiteName: "gb-test-\(UUID().uuidString)")!
24}
25
22@MainActor 26@MainActor
23struct AccountDeleteTests { 27struct AccountDeleteTests {
24 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
25 @Test func askingMailsALinkToTheAddressTheServerNames() async throws { 55 @Test func askingMailsALinkToTheAddressTheServerNames() async throws {
26 let (client, stub) = try makeClient() 56 let (client, stub) = try makeClient()
27 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd")) 57 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())
29 59
30 await model.requestDeletion(username: "sim") 60 await model.requestDeletion(username: "sim")
31 61
@@ -36,7 +66,7 @@ struct AccountDeleteTests {
36 @Test func aRefusalSaysWhyAndNothingIsPending() async throws { 66 @Test func aRefusalSaysWhyAndNothingIsPending() async throws {
37 let (client, stub) = try makeClient() 67 let (client, stub) = try makeClient()
38 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")) 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"))
39 let model = AccountViewModel(client: client) 69 let model = AccountViewModel(client: client, defaults: scratchDefaults())
40 70
41 await model.requestDeletion(username: "sim") 71 await model.requestDeletion(username: "sim")
42 72
@@ -48,10 +78,10 @@ struct AccountDeleteTests {
48 let (client, stub) = try makeClient() 78 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd")) 79 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","mailed":"sim@example.test"},"exit_code":0}"#, match: "cmd"))
50 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"sim","cancelled":true},"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"))
51 let model = AccountViewModel(client: client) 81 let model = AccountViewModel(client: client, defaults: scratchDefaults())
52 await model.requestDeletion(username: "sim") 82 await model.requestDeletion(username: "sim")
53 83
54 await model.cancelDeletion() 84 await model.cancelDeletion(username: "sim")
55 85
56 #expect(try posted(stub).last == ["account", "delete", "--cancel"]) 86 #expect(try posted(stub).last == ["account", "delete", "--cancel"])
57 #expect(model.deletionMailedTo == nil) 87 #expect(model.deletionMailedTo == nil)