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 {
8686 /// repositories it can write to (v1.19.0); nil until read, or on a
8787 /// server whose `show` has no `watch`.
8888 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?
8992 /// Whether replying to notification mail comments (gitbay v1.39.0).
9093 private(set) var replyOn: Bool?
9194 private var replyAvailable: Bool?
@@ -177,6 +180,45 @@ final class AccountViewModel {
177180 return false
178181 }
179182
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
180222 // MARK: - SSH keys
181223
182224 /// `keys add [--scope full|git] [--label <text>]` — the
gitbay/Views/Account/AccountView.swift +49
@@ -4,7 +4,10 @@ import SwiftUI
44/// API tokens have their own screen.
55struct AccountView: View {
66
7 @Environment(SessionStore.self) private var session
78 @State private var model: AccountViewModel
9 @State private var deletingAccount = false
10 @State private var deleteConfirm = ""
811 @State private var addingSSH = false
912 @State private var addingPGP = false
1013 @State private var emailAddress = ""
@@ -54,6 +57,7 @@ struct AccountView: View {
5457 }
5558 }
5659 emailSection
60 deleteAccountSection
5761 }
5862 }
5963 .overlay { LoadStateOverlay(state: model.state) }
@@ -62,6 +66,18 @@ struct AccountView: View {
6266 .task { await model.load() }
6367 .task { await model.loadNotificationPreferences() }
6468 .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 }
6581 .confirmationDialog(
6682 "Remove \(removingEmail?.address ?? "")?",
6783 isPresented: Binding(
@@ -169,6 +185,39 @@ struct AccountView: View {
169185
170186 // MARK: - Sections
171187
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
172221 /// `notifications settings mail` and `watch`. The inbox is filed
173222 /// either way, and login links still arrive. Watch lands with
174223 /// 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}