Admin: account detail, create, delete, invite and limits !173

merged merged by cmc on 2026-10-14 08:50 UTC · krz/gitbay-ios:admin-accounts into main

8 files changed, +904 −2

Layout: unified · split

gitbay/Admin/AdminAccount.swift added +260
@@ -0,0 +1,260 @@
1import Foundation
2import Observation
3
4/// `admin user show <username>`: an account as an instance admin sees it.
5nonisolated struct AdminAccountDetail: Decodable, Sendable, Hashable {
6 nonisolated struct Key: Decodable, Sendable, Hashable, Identifiable {
7 let fingerprint: String
8 let algo: String
9 let scope: String
10 let label: String
11 let createdAt: Date
12 let lastUsedAt: Date?
13 var id: String { fingerprint }
14 enum CodingKeys: String, CodingKey {
15 case fingerprint, algo, scope, label
16 case createdAt = "created_at"
17 case lastUsedAt = "last_used_at"
18 }
19 }
20
21 nonisolated struct Email: Decodable, Sendable, Hashable, Identifiable {
22 let address: String
23 let verified: Bool
24 let primary: Bool
25 var id: String { address }
26 }
27
28 nonisolated struct Membership: Decodable, Sendable, Hashable, Identifiable {
29 let org: String
30 let role: String
31 var id: String { org }
32 }
33
34 nonisolated struct Token: Decodable, Sendable, Hashable, Identifiable {
35 let name: String
36 let scope: String
37 let expiresAt: Date?
38 let lastUsedAt: Date?
39 var id: String { name }
40 enum CodingKeys: String, CodingKey {
41 case name, scope
42 case expiresAt = "expires_at"
43 case lastUsedAt = "last_used_at"
44 }
45 }
46
47 let username: String
48 let state: String
49 let admin: Bool
50 let createdAt: Date
51 let keys: [Key]
52 let emails: [Email]
53 let orgs: [Membership]
54 let repos: Int64
55 let apiTokens: [Token]
56 let webSessions: Int64
57 /// When a self-deletion the account confirmed will purge it.
58 let deleteAfter: String?
59
60 enum CodingKeys: String, CodingKey {
61 case username, state, admin, keys, emails, orgs, repos
62 case createdAt = "created_at"
63 case apiTokens = "api_tokens"
64 case webSessions = "web_sessions"
65 case deleteAfter = "delete_after"
66 }
67
68 init(from decoder: any Decoder) throws {
69 let c = try decoder.container(keyedBy: CodingKeys.self)
70 username = try c.decode(String.self, forKey: .username)
71 state = try c.decode(String.self, forKey: .state)
72 admin = try c.decodeIfPresent(Bool.self, forKey: .admin) ?? false
73 createdAt = try c.decode(Date.self, forKey: .createdAt)
74 keys = try c.decodeIfPresent([Key].self, forKey: .keys) ?? []
75 emails = try c.decodeIfPresent([Email].self, forKey: .emails) ?? []
76 orgs = try c.decodeIfPresent([Membership].self, forKey: .orgs) ?? []
77 repos = try c.decodeIfPresent(Int64.self, forKey: .repos) ?? 0
78 apiTokens = try c.decodeIfPresent([Token].self, forKey: .apiTokens) ?? []
79 webSessions = try c.decodeIfPresent(Int64.self, forKey: .webSessions) ?? 0
80 deleteAfter = try c.decodeIfPresent(String.self, forKey: .deleteAfter)
81 }
82}
83
84/// `admin user limits` and `admin org limits`: the effective caps, 0 for
85/// unlimited, and what the owner holds against them. Organizations have
86/// no organization cap.
87nonisolated struct OwnerLimits: Decodable, Sendable, Hashable {
88 let repos: Int64
89 let bytes: Int64
90 let orgs: Int64?
91 let reposOwned: Int64
92 let bytesOwned: Int64
93 let orgsCreated: Int64?
94 /// Some cap is set on this owner rather than taken from the instance.
95 let override: Bool
96
97 enum CodingKeys: String, CodingKey {
98 case repos, bytes, orgs, override
99 case reposOwned = "repos_owned"
100 case bytesOwned = "bytes_owned"
101 case orgsCreated = "orgs_created"
102 }
103
104 /// The flags for a limits edit. Each field is left out when empty,
105 /// sent as `default` when it says so, and otherwise as a number;
106 /// storage is typed in megabytes and sent in bytes.
107 static func flags(repos: String, megabytes: String, orgs: String?) -> [String]? {
108 var flags: [String] = []
109 func add(_ flag: String, _ text: String, scale: Int64 = 1) -> Bool {
110 let text = text.trimmingCharacters(in: .whitespaces).lowercased()
111 guard !text.isEmpty else { return true }
112 if text == "default" {
113 flags += [flag, "default"]
114 return true
115 }
116 guard let n = Int64(text), n >= 0 else { return false }
117 let (scaled, overflow) = n.multipliedReportingOverflow(by: scale)
118 guard !overflow else { return false }
119 flags += [flag, String(scaled)]
120 return true
121 }
122 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil }
123 if let orgs, !add("--orgs", orgs) { return nil }
124 return flags
125 }
126}
127
128/// One account for an instance admin: `admin user show`, its limits,
129/// editing them, and deleting the account.
130@Observable
131@MainActor
132final class AdminAccountViewModel {
133
134 nonisolated struct Loaded: Sendable, Hashable {
135 let detail: AdminAccountDetail
136 let limits: OwnerLimits?
137 /// Why the caps could not be read, when they could not.
138 let limitsError: String?
139 }
140
141 private(set) var state: LoadState<Loaded> = .loading
142 private(set) var actionError: String?
143 private(set) var working = false
144
145 private let client: GitbayClient
146 let username: String
147
148 init(client: GitbayClient, username: String) {
149 self.client = client
150 self.username = username
151 }
152
153 func load() async {
154 do {
155 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self)
156 // Not a read-only command (it also sets caps), so it goes as a
157 // command even to read; with no flags it changes nothing. A
158 // failed read says why under the account, not as its absence.
159 async let limits = readLimits()
160 let (caps, why) = await limits
161 state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why))
162 } catch {
163 state = .from(error)
164 }
165 }
166
167 private func readLimits() async -> (OwnerLimits?, String?) {
168 do {
169 return (try await client.run(["admin", "user", "limits", username], as: OwnerLimits.self), nil)
170 } catch let error as GitbayError {
171 return (nil, error.userFacingMessage)
172 } catch {
173 return (nil, GitbayError.transport(error).userFacingMessage)
174 }
175 }
176
177 /// Returns whether the caps changed; a field that is not a number or
178 /// `default` is refused here, before the server is asked.
179 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool {
180 guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: orgs) else {
181 actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's."
182 return false
183 }
184 guard !flags.isEmpty else { return true }
185 return await perform(["admin", "user", "limits", username] + flags)
186 }
187
188 /// `admin user delete <username> --yes`, for an account that anchors
189 /// nothing; the server's refusal names what it still holds.
190 func delete() async -> Bool {
191 if await perform(["admin", "user", "delete", username, "--yes"], reload: false) { return true }
192 // A failure is retried once; a delete that went through and then
193 // failed answers the retry with not found, and the account is gone.
194 return notFoundOnRetry
195 }
196
197 private var notFoundOnRetry = false
198
199 private func perform(_ argv: [String], reload: Bool = true) async -> Bool {
200 working = true
201 actionError = nil
202 defer { working = false }
203 notFoundOnRetry = false
204 do {
205 try await client.run(argv)
206 } catch GitbayError.notFound(let message) where argv.contains("delete") {
207 notFoundOnRetry = true
208 actionError = message
209 return false
210 } catch let error as GitbayError {
211 actionError = error.messageForTypedInput
212 return false
213 } catch {
214 actionError = GitbayError.transport(error).userFacingMessage
215 return false
216 }
217 if reload { await load() }
218 return true
219 }
220}
221
222extension AdminUsersViewModel {
223
224 nonisolated private struct Invited: Decodable, Sendable {
225 let email: String
226 let mailed: Bool?
227 let code: String?
228 }
229
230 /// `admin user create <username> [--admin] [--email <a> [--verified]]
231 /// [--key -]`, the public key on stdin. Returns whether it was made.
232 func create(username: String, email: String, verified: Bool, admin: Bool, key: String) async -> Bool {
233 var argv = ["admin", "user", "create", username.trimmingCharacters(in: .whitespaces)]
234 if admin { argv.append("--admin") }
235 let email = email.trimmingCharacters(in: .whitespaces)
236 if !email.isEmpty {
237 argv += ["--email", email]
238 if verified { argv.append("--verified") }
239 }
240 let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
241 if !key.isEmpty { argv += ["--key", "-"] }
242 return await write(argv, stdin: key.isEmpty ? nil : key)
243 }
244
245 /// `admin invite --email <address>`. Says where the code went: mailed,
246 /// or, where the instance cannot mail, the code itself to pass on.
247 func invite(email: String) async -> String? {
248 let email = email.trimmingCharacters(in: .whitespaces)
249 do {
250 let invited = try await client.run(["admin", "invite", "--email", email], as: Invited.self)
251 if let code = invited?.code { return "Invited \(email). Mail is off here, so pass on the code: \(code)" }
252 return "Invited \(email); the code is in their mail."
253 } catch let error as GitbayError {
254 inviteError = error.messageForTypedInput
255 } catch {
256 inviteError = GitbayError.transport(error).userFacingMessage
257 }
258 return nil
259 }
260}
gitbay/Admin/AdminViewModels.swift +28 −1
@@ -64,9 +64,12 @@ final class AdminUsersViewModel {
64 } 64 }
65 private var reloadTask: Task<Void, Never>? 65 private var reloadTask: Task<Void, Never>?
66 private(set) var actionError: String? 66 private(set) var actionError: String?
67 /// A refused invite, kept apart from the list's banner: the invite
68 /// sheet shows it.
69 var inviteError: String?
67 private(set) var working = false 70 private(set) var working = false
68 71
69 private let client: GitbayClient 72 let client: GitbayClient
70 73
71 init(client: GitbayClient) { 74 init(client: GitbayClient) {
72 self.client = client 75 self.client = client
@@ -88,6 +91,30 @@ final class AdminUsersViewModel {
88 await load() 91 await load()
89 } 92 }
90 93
94 /// A write from a sheet (create): its refusal stays on the sheet, and
95 /// the list reloads after one that went through.
96 func write(_ argv: [String], stdin: String?) async -> Bool {
97 working = true
98 actionError = nil
99 defer { working = false }
100 do {
101 try await client.run(argv, stdin: stdin)
102 } catch let error as GitbayError {
103 actionError = error.messageForTypedInput
104 return false
105 } catch {
106 actionError = GitbayError.transport(error).userFacingMessage
107 return false
108 }
109 await load()
110 return true
111 }
112
113 func clearError() {
114 actionError = nil
115 inviteError = nil
116 }
117
91 func perform(_ action: AdminAction, on username: String) async { 118 func perform(_ action: AdminAction, on username: String) async {
92 working = true 119 working = true
93 actionError = nil 120 actionError = nil
gitbay/ContentView.swift +2
@@ -347,6 +347,8 @@ private struct RouteDestinations: ViewModifier {
347 AdminView(client: client) 347 AdminView(client: client)
348 case .adminUsers: 348 case .adminUsers:
349 AdminUsersView(client: client) 349 AdminUsersView(client: client)
350 case .adminUser(let name):
351 AdminAccountView(client: client, username: name)
350 } 352 }
351 } 353 }
352 354
gitbay/Views/Admin/AdminAccountView.swift added +258
@@ -0,0 +1,258 @@
1import SwiftUI
2
3/// One account as an instance admin sees it, `admin user show`: its keys,
4/// addresses, organizations, tokens and sessions, its caps with what it
5/// holds against them, and deletion.
6struct AdminAccountView: View {
7
8 @State private var model: AdminAccountViewModel
9 @State private var editingLimits = false
10 @State private var deleting = false
11 @State private var typed = ""
12 @State private var deleted = false
13 @State private var refusal: String?
14
15 init(client: GitbayClient, username: String) {
16 _model = State(initialValue: AdminAccountViewModel(client: client, username: username))
17 }
18
19 var body: some View {
20 List {
21 if deleted {
22 Section {
23 Label("\(model.username) is deleted", systemImage: "trash")
24 .font(.gbSans(.subheadline))
25 .accessibilityIdentifier("admin-account-deleted")
26 }
27 } else if let loaded = model.state.value {
28 header(loaded.detail)
29 if let limits = loaded.limits {
30 limitsSection(limits)
31 } else if let why = loaded.limitsError {
32 Section("Limits") { GBNotice(why, .gbWarn) }
33 }
34 keysSection(loaded.detail)
35 emailsSection(loaded.detail)
36 if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) }
37 if !loaded.detail.apiTokens.isEmpty { tokensSection(loaded.detail) }
38 Section {
39 Button(role: .destructive) {
40 typed = ""
41 deleting = true
42 } label: {
43 Label("Delete account", systemImage: "trash")
44 .font(.gbSans(.subheadline))
45 }
46 .disabled(model.working)
47 .accessibilityIdentifier("admin-account-delete")
48 } footer: {
49 Text("Only an account that anchors nothing: no repositories, no organization it is the only admin of, and no issues, comments or reviews. The refusal names what it still holds.")
50 }
51 }
52 }
53 .overlay { if !deleted { LoadStateOverlay(state: model.state) } }
54 .navigationTitle(model.username)
55 .navigationBarTitleDisplayMode(.inline)
56 .task { if !deleted { await model.load() } }
57 .refreshable { if !deleted { await model.load() } }
58 .sheet(isPresented: $editingLimits) {
59 LimitsSheet(heading: "Limits for \(model.username)", hasOrgs: true, working: model.working,
60 error: model.actionError) { repos, megabytes, orgs in
61 if await model.setLimits(repos: repos, megabytes: megabytes, orgs: orgs) { editingLimits = false }
62 }
63 }
64 .alert("Delete \(model.username)?", isPresented: $deleting) {
65 TextField(model.username, text: $typed)
66 .autocorrectionDisabled()
67 .textInputAutocapitalization(.never)
68 Button("Delete", role: .destructive) {
69 Task {
70 if await model.delete() {
71 deleted = true
72 NotificationCenter.default.post(name: .gitbayAccountsChanged, object: nil)
73 } else {
74 refusal = model.actionError
75 }
76 }
77 }
78 .disabled(typed.trimmingCharacters(in: .whitespaces) != model.username)
79 Button("Cancel", role: .cancel) {}
80 } message: {
81 Text("Its keys, addresses and sessions go with it. Type the username to confirm.")
82 }
83 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
84 Button("OK", role: .cancel) {}
85 } message: {
86 Text(refusal ?? "")
87 }
88 }
89
90 private func header(_ d: AdminAccountDetail) -> some View {
91 Section {
92 HStack(spacing: 6) {
93 Text(d.username).font(.gbSans(.headline))
94 if d.admin { GBChip("admin", .secondary) }
95 GBChip(d.state, d.state == "active" ? .gbOK : d.state == "disabled" ? .gbBad : .secondary)
96 }
97 LabeledContent("Joined", value: d.createdAt.formatted(date: .abbreviated, time: .omitted))
98 LabeledContent("Repositories", value: String(d.repos))
99 LabeledContent("Web sessions", value: String(d.webSessions))
100 if let after = d.deleteAfter {
101 LabeledContent("Deletes after", value: (try? Date(after, strategy: .iso8601))
102 .map { $0.formatted(date: .abbreviated, time: .shortened) } ?? after)
103 .foregroundStyle(Color.gbBad)
104 }
105 }
106 .font(.gbSans(.subheadline))
107 }
108
109 private func limitsSection(_ l: OwnerLimits) -> some View {
110 Section {
111 LabeledContent("Repositories", value: "\(l.reposOwned) of \(cap(l.repos))")
112 LabeledContent("Storage", value: "\(bytes(l.bytesOwned)) of \(l.bytes == 0 ? "unlimited" : bytes(l.bytes))")
113 if let orgs = l.orgs {
114 LabeledContent("Organizations", value: "\(l.orgsCreated ?? 0) of \(cap(orgs))")
115 }
116 Button("Edit limits") { editingLimits = true }
117 .disabled(model.working)
118 .accessibilityIdentifier("admin-account-limits-edit")
119 } header: {
120 Text("Limits")
121 } footer: {
122 Text(l.override ? "Set on this account." : "The instance's defaults.")
123 }
124 .font(.gbSans(.subheadline))
125 }
126
127 private func keysSection(_ d: AdminAccountDetail) -> some View {
128 Section("SSH keys") {
129 if d.keys.isEmpty {
130 Text("None").foregroundStyle(.secondary)
131 }
132 ForEach(d.keys) { key in
133 VStack(alignment: .leading, spacing: 2) {
134 HStack(spacing: 6) {
135 Text(key.label.isEmpty ? key.algo : key.label).font(.gbSans(.subheadline).weight(.medium))
136 GBChip(key.scope, .secondary)
137 }
138 Text(key.fingerprint)
139 .font(.gbMono(.caption))
140 .lineLimit(1)
141 .truncationMode(.middle)
142 Text(key.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
143 .font(.gbSans(.caption2))
144 .foregroundStyle(.secondary)
145 }
146 }
147 }
148 .font(.gbSans(.subheadline))
149 }
150
151 private func emailsSection(_ d: AdminAccountDetail) -> some View {
152 Section("Email") {
153 if d.emails.isEmpty {
154 Text("None").foregroundStyle(.secondary)
155 }
156 ForEach(d.emails) { email in
157 HStack(spacing: 6) {
158 Text(email.address)
159 Spacer()
160 if email.primary { GBChip("primary", .secondary) }
161 GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
162 }
163 }
164 }
165 .font(.gbSans(.subheadline))
166 }
167
168 private func orgsSection(_ d: AdminAccountDetail) -> some View {
169 Section("Organizations") {
170 ForEach(d.orgs) { m in
171 LabeledContent(m.org, value: m.role)
172 }
173 }
174 .font(.gbSans(.subheadline))
175 }
176
177 private func tokensSection(_ d: AdminAccountDetail) -> some View {
178 Section("API tokens") {
179 ForEach(d.apiTokens) { t in
180 HStack(spacing: 6) {
181 Text(t.name)
182 GBChip(t.scope, .secondary)
183 Spacer()
184 Text(t.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
185 .font(.gbSans(.caption2))
186 .foregroundStyle(.secondary)
187 }
188 }
189 }
190 .font(.gbSans(.subheadline))
191 }
192
193 private func cap(_ n: Int64) -> String { n == 0 ? "unlimited" : String(n) }
194 private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) }
195}
196
197/// Caps for an account or an organization. An empty field stays as it
198/// is; `default` returns it to the instance's; 0 is unlimited.
199struct LimitsSheet: View {
200
201 let heading: String
202 let hasOrgs: Bool
203 let working: Bool
204 let error: String?
205 let save: (String, String, String) async -> Void
206
207 @State private var repos = ""
208 @State private var megabytes = ""
209 @State private var orgs = ""
210 @State private var sent = false
211 @Environment(\.dismiss) private var dismiss
212
213 var body: some View {
214 NavigationStack {
215 Form {
216 Section {
217 field("Repositories", $repos, id: "limits-repos")
218 field("Storage, MB", $megabytes, id: "limits-mb")
219 if hasOrgs { field("Organizations", $orgs, id: "limits-orgs") }
220 } footer: {
221 Text("Leave a field empty to keep it. A number sets the cap, 0 for unlimited; default takes the instance's.")
222 }
223 if sent, let error {
224 Section { GBNotice(error) }
225 }
226 }
227 .navigationTitle(heading)
228 .navigationBarTitleDisplayMode(.inline)
229 .toolbar {
230 ToolbarItem(placement: .cancellationAction) {
231 Button("Cancel") { dismiss() }
232 }
233 ToolbarItem(placement: .confirmationAction) {
234 if working {
235 ProgressView()
236 } else {
237 Button("Save") {
238 sent = true
239 Task { await save(repos, megabytes, orgs) }
240 }
241 .accessibilityIdentifier("limits-save")
242 }
243 }
244 }
245 }
246 }
247
248 private func field(_ title: String, _ text: Binding<String>, id: String) -> some View {
249 LabeledContent(title) {
250 TextField("unchanged", text: text)
251 .multilineTextAlignment(.trailing)
252 .autocorrectionDisabled()
253 .textInputAutocapitalization(.never)
254 .accessibilityIdentifier(id)
255 }
256 .font(.gbSans(.subheadline))
257 }
258}
gitbay/Views/Admin/AdminUsersView.swift +186 −1
@@ -5,9 +5,13 @@ import SwiftUI
5/// the username typed, as the web does. 5/// the username typed, as the web does.
6struct AdminUsersView: View { 6struct AdminUsersView: View {
7 7
8 @Environment(\.pushRoute) private var pushRoute
8 @State private var model: AdminUsersViewModel 9 @State private var model: AdminUsersViewModel
9 @State private var pending: Pending? 10 @State private var pending: Pending?
10 @State private var typed = "" 11 @State private var typed = ""
12 @State private var creating = false
13 @State private var inviting = false
14 @State private var inviteNote: String?
11 15
12 nonisolated struct Pending: Hashable, Sendable { 16 nonisolated struct Pending: Hashable, Sendable {
13 let action: AdminAction 17 let action: AdminAction
@@ -32,11 +36,17 @@ struct AdminUsersView: View {
32 .accessibilityIdentifier("admin-user-filter") 36 .accessibilityIdentifier("admin-user-filter")
33 } 37 }
34 38
35 if let error = model.actionError { 39 if let error = model.actionError, !creating {
36 Section { 40 Section {
37 GBNotice(error, .gbWarn) 41 GBNotice(error, .gbWarn)
38 } 42 }
39 } 43 }
44 if let note = inviteNote {
45 Section {
46 GBNotice(note, .gbOK)
47 .textSelection(.enabled)
48 }
49 }
40 ForEach(model.list.state.value ?? []) { user in 50 ForEach(model.list.state.value ?? []) { user in
41 row(user) 51 row(user)
42 } 52 }
@@ -47,6 +57,38 @@ struct AdminUsersView: View {
47 .navigationBarTitleDisplayMode(.inline) 57 .navigationBarTitleDisplayMode(.inline)
48 .task { await model.loadIfNeeded() } 58 .task { await model.loadIfNeeded() }
49 .refreshable { await model.load() } 59 .refreshable { await model.load() }
60 .onReceive(NotificationCenter.default.publisher(for: .gitbayAccountsChanged)) { _ in
61 Task { await model.load() }
62 }
63 .toolbar {
64 ToolbarItem(placement: .topBarTrailing) {
65 Menu {
66 Button {
67 creating = true
68 } label: {
69 Label("Create account", systemImage: "person.badge.plus")
70 }
71 Button {
72 inviting = true
73 } label: {
74 Label("Invite by email", systemImage: "envelope")
75 }
76 } label: {
77 Image(systemName: "plus")
78 }
79 .accessibilityLabel("Add an account")
80 .accessibilityIdentifier("admin-accounts-add")
81 }
82 }
83 .sheet(isPresented: $creating, onDismiss: { model.clearError() }) {
84 CreateAccountSheet(model: model) { creating = false }
85 }
86 .sheet(isPresented: $inviting, onDismiss: { model.clearError() }) {
87 InviteSheet(model: model) { note in
88 inviteNote = note
89 inviting = false
90 }
91 }
50 .alert( 92 .alert(
51 pending.map { "\($0.action.title) \($0.username)?" } ?? "", 93 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
52 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), 94 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
@@ -70,6 +112,11 @@ struct AdminUsersView: View {
70 112
71 private func row(_ user: AdminUser) -> some View { 113 private func row(_ user: AdminUser) -> some View {
72 HStack { 114 HStack {
115 // A button rather than a link: the row also holds the menu,
116 // and a link would claim the whole row.
117 Button {
118 pushRoute(RepoRoute.adminUser(user.username))
119 } label: {
73 VStack(alignment: .leading, spacing: 3) { 120 VStack(alignment: .leading, spacing: 3) {
74 HStack(spacing: 6) { 121 HStack(spacing: 6) {
75 Text(user.username) 122 Text(user.username)
@@ -83,6 +130,10 @@ struct AdminUsersView: View {
83 .font(.gbSans(.caption)) 130 .font(.gbSans(.caption))
84 .foregroundStyle(.secondary) 131 .foregroundStyle(.secondary)
85 } 132 }
133 .contentShape(Rectangle())
134 }
135 .buttonStyle(.plain)
136 .accessibilityIdentifier("admin-user-\(user.username)")
86 Spacer() 137 Spacer()
87 Menu { 138 Menu {
88 Button(user.roleAction.title) { ask(user.roleAction, user) } 139 Button(user.roleAction.title) { ask(user.roleAction, user) }
@@ -122,3 +173,137 @@ struct AdminUsersView: View {
122 return "\(created) · \(last)" 173 return "\(created) · \(last)"
123 } 174 }
124} 175}
176
177/// `admin user create`: a username, optionally an address (verified or
178/// not), admin, and a public key to sign in with.
179private struct CreateAccountSheet: View {
180
181 let model: AdminUsersViewModel
182 let onDone: () -> Void
183
184 @State private var username = ""
185 @State private var email = ""
186 /// Off: a mistyped address marked verified would take sign-in links.
187 @State private var verified = false
188 @State private var admin = false
189 @State private var key = ""
190 @State private var sent = false
191 @Environment(\.dismiss) private var dismiss
192
193 var body: some View {
194 NavigationStack {
195 Form {
196 Section("Username") {
197 TextField("username", text: $username)
198 .autocorrectionDisabled()
199 .textInputAutocapitalization(.never)
200 .accessibilityIdentifier("create-account-username")
201 }
202 Section {
203 TextField("address (optional)", text: $email)
204 .keyboardType(.emailAddress)
205 .autocorrectionDisabled()
206 .textInputAutocapitalization(.never)
207 .accessibilityIdentifier("create-account-email")
208 Toggle("Already verified", isOn: $verified)
209 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
210 } header: {
211 Text("Email")
212 }
213 Section {
214 TextEditor(text: $key)
215 .font(.gbMono(.caption))
216 .frame(minHeight: 80)
217 .autocorrectionDisabled()
218 .textInputAutocapitalization(.never)
219 .accessibilityIdentifier("create-account-key")
220 } header: {
221 Text("SSH key (optional)")
222 } footer: {
223 Text("An authorized_keys line. Without a key or a verified address the account has no way to sign in.")
224 }
225 Section {
226 Toggle("Instance admin", isOn: $admin)
227 }
228 if sent, let error = model.actionError {
229 Section { GBNotice(error) }
230 }
231 }
232 .navigationTitle("New Account")
233 .navigationBarTitleDisplayMode(.inline)
234 .toolbar {
235 ToolbarItem(placement: .cancellationAction) {
236 Button("Cancel") { dismiss() }
237 }
238 ToolbarItem(placement: .confirmationAction) {
239 if model.working {
240 ProgressView()
241 } else {
242 Button("Create") {
243 sent = true
244 Task {
245 if await model.create(username: username, email: email, verified: verified,
246 admin: admin, key: key) { onDone() }
247 }
248 }
249 .disabled(username.trimmingCharacters(in: .whitespaces).isEmpty)
250 .accessibilityIdentifier("create-account-submit")
251 }
252 }
253 }
254 }
255 }
256}
257
258/// `admin invite --email <address>`: a registration code by mail.
259private struct InviteSheet: View {
260
261 let model: AdminUsersViewModel
262 let onDone: (String) -> Void
263
264 @State private var email = ""
265 @State private var working = false
266 @Environment(\.dismiss) private var dismiss
267
268 var body: some View {
269 NavigationStack {
270 Form {
271 Section {
272 TextField("address", text: $email)
273 .keyboardType(.emailAddress)
274 .autocorrectionDisabled()
275 .textInputAutocapitalization(.never)
276 .accessibilityIdentifier("invite-email")
277 } footer: {
278 Text("Mails a code that lets them register, for an address no account holds.")
279 }
280 if let error = model.inviteError {
281 Section { GBNotice(error) }
282 }
283 }
284 .navigationTitle("Invite")
285 .navigationBarTitleDisplayMode(.inline)
286 .toolbar {
287 ToolbarItem(placement: .cancellationAction) {
288 Button("Cancel") { dismiss() }
289 }
290 ToolbarItem(placement: .confirmationAction) {
291 if working {
292 ProgressView()
293 } else {
294 Button("Invite") {
295 working = true
296 Task {
297 model.inviteError = nil
298 if let note = await model.invite(email: email) { onDone(note) }
299 working = false
300 }
301 }
302 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
303 .accessibilityIdentifier("invite-submit")
304 }
305 }
306 }
307 }
308 }
309}
gitbay/Views/Repos/RepoRoute.swift +1
@@ -32,4 +32,5 @@ nonisolated enum RepoRoute: Hashable {
32 case notifications 32 case notifications
33 case admin 33 case admin
34 case adminUsers 34 case adminUsers
35 case adminUser(String)
35} 36}
gitbay/Views/Shared/ChangeNotices.swift +2
@@ -8,4 +8,6 @@ extension Notification.Name {
8 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged") 8 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged")
9 /// A saved query was saved, pinned, unpinned or removed. 9 /// A saved query was saved, pinned, unpinned or removed.
10 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged") 10 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged")
11 /// An instance admin created or deleted an account.
12 static let gitbayAccountsChanged = Notification.Name("gitbayAccountsChanged")
11} 13}
gitbayTests/AdminAccountTests.swift added +167
@@ -0,0 +1,167 @@
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], 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]), body["stdin"] as? String)
19 }
20}
21
22private let showJSON = """
23 {"protocol_version":1,"data":{"username":"other","state":"active","admin":true,\
24 "created_at":"2026-10-14T07:49:15.505Z","keys":[{"fingerprint":"SHA256:a","algo":"ssh-ed25519",\
25 "scope":"full","label":"other","created_at":"2026-10-14T07:49:15.505Z"}],\
26 "emails":[{"address":"other@example.test","verified":true,"verified_by":"admin","primary":true}],\
27 "pgp_keys":[],"orgs":[{"org":"acme","role":"admin"}],"repos":2,"repo_limit":0,"byte_limit":0,\
28 "api_tokens":[{"name":"ci","scope":"read","created_at":"2026-10-14T07:49:15.505Z"}],"web_sessions":1}}
29 """
30
31private let limitsJSON = """
32 {"protocol_version":1,"data":{"user":"other","repos":10,"bytes":0,"orgs":0,"repos_owned":2,\
33 "bytes_owned":4096,"orgs_created":0,"override":true}}
34 """
35
36private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
37
38@MainActor
39struct AdminAccountTests {
40
41 private func loaded() async throws -> (AdminAccountViewModel, StubProtocol.Box) {
42 let (client, stub) = try makeClient()
43 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
44 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
45 let model = AdminAccountViewModel(client: client, username: "other")
46 await model.load()
47 return (model, stub)
48 }
49
50 @Test func anAccountLoadsWithItsLimits() async throws {
51 let (model, _) = try await loaded()
52 let loaded = try #require(model.state.value)
53 #expect(loaded.detail.keys.first?.label == "other")
54 #expect(loaded.detail.emails.first?.primary == true)
55 #expect(loaded.detail.orgs.first?.org == "acme")
56 #expect(loaded.detail.apiTokens.first?.name == "ci")
57 #expect(loaded.detail.webSessions == 1)
58 #expect(loaded.limits?.repos == 10)
59 #expect(loaded.limits?.reposOwned == 2)
60 #expect(loaded.limits?.override == true)
61 }
62
63 @Test func limitFlagsLeaveEmptyFieldsOutAndScaleMegabytes() {
64 #expect(OwnerLimits.flags(repos: "5", megabytes: "", orgs: "default")
65 == ["--repos", "5", "--orgs", "default"])
66 #expect(OwnerLimits.flags(repos: "", megabytes: "2", orgs: nil) == ["--bytes", "2000000"])
67 #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil)
68 #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil)
69 #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == [])
70 // A cap too large to hold in bytes is refused, not a crash.
71 #expect(OwnerLimits.flags(repos: "", megabytes: "10000000000000", orgs: nil) == nil)
72 }
73
74 @Test func settingLimitsSendsTheChangedCaps() async throws {
75 let (model, stub) = try await loaded()
76 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
78 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
79
80 #expect(await model.setLimits(repos: "20", megabytes: "", orgs: "0"))
81
82 // The first post is the load's read of the caps; the write follows.
83 #expect(try posted(stub).map(\.0) == [["admin", "user", "limits", "other"],
84 ["admin", "user", "limits", "other", "--repos", "20", "--orgs", "0"],
85 ["admin", "user", "limits", "other"]])
86 }
87
88 @Test func aMalformedCapIsRefusedBeforeAsking() async throws {
89 let (model, stub) = try await loaded()
90
91 #expect(!(await model.setLimits(repos: "lots", megabytes: "", orgs: "")))
92 #expect(try posted(stub).count == 1) // the load's read only
93 #expect(model.actionError != nil)
94 }
95
96 @Test func deletingSendsYes() async throws {
97 let (model, stub) = try await loaded()
98 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"other"},"exit_code":0}"#, match: "cmd"))
99
100 #expect(await model.delete())
101 #expect(try posted(stub).map(\.0).last == ["admin", "user", "delete", "other", "--yes"])
102 }
103}
104
105@MainActor
106struct AdminCreateInviteTests {
107
108 @Test func creatingSendsTheKeyOnStdin() async throws {
109 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"dee"},"exit_code":0}"#, match: "cmd"))
111 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
112 let model = AdminUsersViewModel(client: client)
113
114 let made = await model.create(username: " dee ", email: "dee@example.test", verified: true, admin: false,
115 key: "ssh-ed25519 AAAA dee\n")
116
117 #expect(made)
118 let (argv, stdin) = try #require(try posted(stub).first)
119 #expect(argv == ["admin", "user", "create", "dee", "--email", "dee@example.test", "--verified", "--key", "-"])
120 #expect(stdin == "ssh-ed25519 AAAA dee")
121 }
122
123 @Test func noEmailMeansNoVerifiedFlagAndNoKeyMeansNoStdin() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
126 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
127 let model = AdminUsersViewModel(client: client)
128
129 _ = await model.create(username: "eve", email: "", verified: true, admin: true, key: " ")
130
131 let (argv, stdin) = try #require(try posted(stub).first)
132 #expect(argv == ["admin", "user", "create", "eve", "--admin"])
133 #expect(stdin == nil)
134 }
135
136 @Test func anInviteThatCouldNotBeMailedHandsOverTheCode() async throws {
137 let (client, stub) = try makeClient()
138 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"email":"fay@example.test","mailed":false,"code":"abc123"},"exit_code":0}"#, match: "cmd"))
139 let model = AdminUsersViewModel(client: client)
140
141 let note = await model.invite(email: "fay@example.test")
142
143 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]])
144 #expect(note?.contains("abc123") == true)
145 }
146
147 @Test func aLimitsReadThatFailsSaysWhy() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
150 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"a read-scoped token cannot run that","exit_code":4}"#, match: "cmd"))
151 let model = AdminAccountViewModel(client: client, username: "other")
152
153 await model.load()
154
155 #expect(model.state.value?.limits == nil)
156 #expect(model.state.value?.limitsError == "a read-scoped token cannot run that")
157 }
158
159 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd"))
162 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no user \"other\"","exit_code":3}"#, match: "cmd"))
163 let model = AdminAccountViewModel(client: client, username: "other")
164
165 #expect(await model.delete())
166 }
167}