Commit 3d6141f15c

3d6141f15c7fe4d7a365175fd45867cdbc5d2e5b

parent: 90ac1840bb

Verified · cmc

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

Delete your own account

The Account screen ends with Delete account: with your username typed
out, the server mails a link that disables the account when opened and
deletes it seven days later. The screen says where the link went and
offers to withdraw the request, and its footer names account export
for keeping a copy first.

Closes #35

Layout: unified · split

gitbay/Account/AccountViewModel.swift +42
@@ -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?
@@ -177,6 +180,45 @@ final class AccountViewModel {
177 return false 180 return false
178 } 181 }
179 182
183 // MARK: - Deleting the account
184
185 nonisolated private struct DeletionRequested: Decodable, Sendable {
186 let mailed: String
187 }
188
189 /// `account delete --confirm <username>`: the server mails a link;
190 /// opening it disables the account, and seven days later it is purged.
191 func requestDeletion(username: String) async {
192 working = true
193 actionError = nil
194 defer { working = false }
195 do {
196 let requested = try await client.run(["account", "delete", "--confirm", username],
197 as: DeletionRequested.self)
198 deletionMailedTo = requested?.mailed
199 } catch let error as GitbayError {
200 actionError = error.userFacingMessage
201 } catch {
202 actionError = GitbayError.transport(error).userFacingMessage
203 }
204 }
205
206 /// `account delete --cancel`: withdraws a request whose link has not
207 /// been opened.
208 func cancelDeletion() async {
209 working = true
210 actionError = nil
211 defer { working = false }
212 do {
213 try await client.run(["account", "delete", "--cancel"])
214 deletionMailedTo = nil
215 } catch let error as GitbayError {
216 actionError = error.userFacingMessage
217 } catch {
218 actionError = GitbayError.transport(error).userFacingMessage
219 }
220 }
221
180 // MARK: - SSH keys 222 // MARK: - SSH keys
181 223
182 /// `keys add [--scope full|git] [--label <text>]` — the 224 /// `keys add [--scope full|git] [--label <text>]` — the
gitbay/Views/Account/AccountView.swift +49
@@ -4,7 +4,10 @@ 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 = ""
8 @State private var addingSSH = false 11 @State private var addingSSH = false
9 @State private var addingPGP = false 12 @State private var addingPGP = false
10 @State private var emailAddress = "" 13 @State private var emailAddress = ""
@@ -54,6 +57,7 @@ struct AccountView: View {
54 } 57 }
55 } 58 }
56 emailSection 59 emailSection
60 deleteAccountSection
57 } 61 }
58 } 62 }
59 .overlay { LoadStateOverlay(state: model.state) } 63 .overlay { LoadStateOverlay(state: model.state) }
@@ -62,6 +66,18 @@ struct AccountView: View {
62 .task { await model.load() } 66 .task { await model.load() }
63 .task { await model.loadNotificationPreferences() } 67 .task { await model.loadNotificationPreferences() }
64 .task { await model.loadEmails() } 68 .task { await model.loadEmails() }
69 .alert("Delete your account", isPresented: $deletingAccount) {
70 TextField("type \(username) to confirm", text: $deleteConfirm)
71 .autocorrectionDisabled()
72 .textInputAutocapitalization(.never)
73 Button("Mail the link", role: .destructive) {
74 Task { await model.requestDeletion(username: username) }
75 }
76 .disabled(deleteConfirm != username)
77 Button("Cancel", role: .cancel) {}
78 } message: {
79 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.")
80 }
65 .confirmationDialog( 81 .confirmationDialog(
66 "Remove \(removingEmail?.address ?? "")?", 82 "Remove \(removingEmail?.address ?? "")?",
67 isPresented: Binding( 83 isPresented: Binding(
@@ -169,6 +185,39 @@ struct AccountView: View {
169 185
170 // MARK: - Sections 186 // MARK: - Sections
171 187
188 private var username: String { session.current?.username ?? "" }
189
190 /// Deleting the account is confirmed by mail, as on the web: nothing
191 /// changes until the link is opened, and signing in during the seven
192 /// days after cancels it.
193 private var deleteAccountSection: some View {
194 Section {
195 if let address = model.deletionMailedTo {
196 Label("A link went to \(address). It works for 24 hours; nothing changes until it is opened.",
197 systemImage: "envelope")
198 .font(.gbSans(.subheadline))
199 .accessibilityIdentifier("account-delete-mailed")
200 Button("Withdraw the request") {
201 Task { await model.cancelDeletion() }
202 }
203 .disabled(model.working)
204 .accessibilityIdentifier("account-delete-cancel")
205 } else {
206 Button(role: .destructive) {
207 deleteConfirm = ""
208 deletingAccount = true
209 } label: {
210 Label("Delete account", systemImage: "person.crop.circle.badge.xmark")
211 .font(.gbSans(.subheadline))
212 }
213 .disabled(model.working || username.isEmpty)
214 .accessibilityIdentifier("account-delete-button")
215 }
216 } footer: {
217 Text("Keep a copy first: ssh git@\(session.current?.instance.baseURL.host() ?? "the instance") account export > bundle.json")
218 }
219 }
220
172 /// `notifications settings mail` and `watch`. The inbox is filed 221 /// `notifications settings mail` and `watch`. The inbox is filed
173 /// either way, and login links still arrive. Watch lands with 222 /// either way, and login links still arrive. Watch lands with
174 /// v1.19.0; an older server shows mail alone. 223 /// v1.19.0; an older server shows mail alone.
gitbayTests/AccountDeleteTests.swift added +59
@@ -0,0 +1,59 @@
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
22@MainActor
23struct AccountDeleteTests {
24
25 @Test func askingMailsALinkToTheAddressTheServerNames() async throws {
26 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"))
28 let model = AccountViewModel(client: client)
29
30 await model.requestDeletion(username: "sim")
31
32 #expect(try posted(stub) == [["account", "delete", "--confirm", "sim"]])
33 #expect(model.deletionMailedTo == "sim@example.test")
34 }
35
36 @Test func aRefusalSaysWhyAndNothingIsPending() async throws {
37 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"))
39 let model = AccountViewModel(client: client)
40
41 await model.requestDeletion(username: "sim")
42
43 #expect(model.deletionMailedTo == nil)
44 #expect(model.actionError == "you are the instance's only admin; promote another account first")
45 }
46
47 @Test func withdrawingSendsCancel() async throws {
48 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"))
50 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)
52 await model.requestDeletion(username: "sim")
53
54 await model.cancelDeletion()
55
56 #expect(try posted(stub).last == ["account", "delete", "--cancel"])
57 #expect(model.deletionMailedTo == nil)
58 }
59}