Commit 98784709d7

98784709d76c118c4067654d0491e57e62c899f8

parent: d03ffe4db6

Verified · cmc

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

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

An account's row opens what admin user show reports: its keys,
addresses, organizations, tokens and sessions, with its repository,
storage and organization caps against what it holds. Edit limits sets
any of them, default restoring the instance's; Delete account removes
one that anchors nothing, the username typed first. The list's +
creates an account, with an address, admin and a key on stdin, or
invites by email and says the code when the instance cannot mail it.

Closes #36

Layout: unified · split

gitbay/Admin/AdminAccount.swift added +234
@@ -0,0 +1,234 @@
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 flags += [flag, String(n * scale)]
118 return true
119 }
120 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil }
121 if let orgs, !add("--orgs", orgs) { return nil }
122 return flags
123 }
124}
125
126/// One account for an instance admin: `admin user show`, its limits,
127/// editing them, and deleting the account.
128@Observable
129@MainActor
130final class AdminAccountViewModel {
131
132 nonisolated struct Loaded: Sendable, Hashable {
133 let detail: AdminAccountDetail
134 let limits: OwnerLimits?
135 }
136
137 private(set) var state: LoadState<Loaded> = .loading
138 private(set) var actionError: String?
139 private(set) var working = false
140
141 private let client: GitbayClient
142 let username: String
143
144 init(client: GitbayClient, username: String) {
145 self.client = client
146 self.username = username
147 }
148
149 func load() async {
150 do {
151 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self)
152 // Not a read-only command (it also sets caps), so it goes as a
153 // command even to read; with no flags it changes nothing.
154 async let limits = try? client.run(["admin", "user", "limits", username], as: OwnerLimits.self)
155 state = .loaded(Loaded(detail: try await detail, limits: await limits))
156 } catch {
157 state = .from(error)
158 }
159 }
160
161 /// Returns whether the caps changed; a field that is not a number or
162 /// `default` is refused here, before the server is asked.
163 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool {
164 guard let flags = OwnerLimits.flags(repos: repos, megabytes: megabytes, orgs: orgs) else {
165 actionError = "A cap is a whole number, 0 for unlimited, or default for the instance's."
166 return false
167 }
168 guard !flags.isEmpty else { return true }
169 return await perform(["admin", "user", "limits", username] + flags)
170 }
171
172 /// `admin user delete <username> --yes`, for an account that anchors
173 /// nothing; the server's refusal names what it still holds.
174 func delete() async -> Bool {
175 await perform(["admin", "user", "delete", username, "--yes"], reload: false)
176 }
177
178 private func perform(_ argv: [String], reload: Bool = true) async -> Bool {
179 working = true
180 actionError = nil
181 defer { working = false }
182 do {
183 try await client.run(argv)
184 } catch let error as GitbayError {
185 actionError = error.messageForTypedInput
186 return false
187 } catch {
188 actionError = GitbayError.transport(error).userFacingMessage
189 return false
190 }
191 if reload { await load() }
192 return true
193 }
194}
195
196extension AdminUsersViewModel {
197
198 nonisolated private struct Invited: Decodable, Sendable {
199 let email: String
200 let mailed: Bool?
201 let code: String?
202 }
203
204 /// `admin user create <username> [--admin] [--email <a> [--verified]]
205 /// [--key -]`, the public key on stdin. Returns whether it was made.
206 func create(username: String, email: String, verified: Bool, admin: Bool, key: String) async -> Bool {
207 var argv = ["admin", "user", "create", username.trimmingCharacters(in: .whitespaces)]
208 if admin { argv.append("--admin") }
209 let email = email.trimmingCharacters(in: .whitespaces)
210 if !email.isEmpty {
211 argv += ["--email", email]
212 if verified { argv.append("--verified") }
213 }
214 let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
215 if !key.isEmpty { argv += ["--key", "-"] }
216 return await write(argv, stdin: key.isEmpty ? nil : key)
217 }
218
219 /// `admin invite --email <address>`. Says where the code went: mailed,
220 /// or, where the instance cannot mail, the code itself to pass on.
221 func invite(email: String) async -> String? {
222 let email = email.trimmingCharacters(in: .whitespaces)
223 do {
224 let invited = try await client.run(["admin", "invite", "--email", email], as: Invited.self)
225 if let code = invited?.code { return "Invited \(email). Mail is off here, so pass on the code: \(code)" }
226 return "Invited \(email); the code is in their mail."
227 } catch let error as GitbayError {
228 inviteError = error.messageForTypedInput
229 } catch {
230 inviteError = GitbayError.transport(error).userFacingMessage
231 }
232 return nil
233 }
234}
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 +253
@@ -0,0 +1,253 @@
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 { limitsSection(limits) }
30 keysSection(loaded.detail)
31 emailsSection(loaded.detail)
32 if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) }
33 if !loaded.detail.apiTokens.isEmpty { tokensSection(loaded.detail) }
34 Section {
35 Button(role: .destructive) {
36 typed = ""
37 deleting = true
38 } label: {
39 Label("Delete account", systemImage: "trash")
40 .font(.gbSans(.subheadline))
41 }
42 .disabled(model.working)
43 .accessibilityIdentifier("admin-account-delete")
44 } footer: {
45 Text("Only an account that anchors nothing: no repositories, organizations or authored threads. The refusal names what it still holds.")
46 }
47 }
48 }
49 .overlay { if !deleted { LoadStateOverlay(state: model.state) } }
50 .navigationTitle(model.username)
51 .navigationBarTitleDisplayMode(.inline)
52 .task { if !deleted { await model.load() } }
53 .refreshable { if !deleted { await model.load() } }
54 .sheet(isPresented: $editingLimits) {
55 LimitsSheet(heading: "Limits for \(model.username)", hasOrgs: true, working: model.working,
56 error: model.actionError) { repos, megabytes, orgs in
57 if await model.setLimits(repos: repos, megabytes: megabytes, orgs: orgs) { editingLimits = false }
58 }
59 }
60 .alert("Delete \(model.username)?", isPresented: $deleting) {
61 TextField(model.username, text: $typed)
62 .autocorrectionDisabled()
63 .textInputAutocapitalization(.never)
64 Button("Delete", role: .destructive) {
65 Task {
66 if await model.delete() {
67 deleted = true
68 NotificationCenter.default.post(name: .gitbayAccountsChanged, object: nil)
69 } else {
70 refusal = model.actionError
71 }
72 }
73 }
74 .disabled(typed.trimmingCharacters(in: .whitespaces) != model.username)
75 Button("Cancel", role: .cancel) {}
76 } message: {
77 Text("Its keys, addresses and sessions go with it. Type the username to confirm.")
78 }
79 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
80 Button("OK", role: .cancel) {}
81 } message: {
82 Text(refusal ?? "")
83 }
84 }
85
86 private func header(_ d: AdminAccountDetail) -> some View {
87 Section {
88 HStack(spacing: 6) {
89 Text(d.username).font(.gbSans(.headline))
90 if d.admin { GBChip("admin", .secondary) }
91 GBChip(d.state, d.state == "active" ? .gbOK : d.state == "disabled" ? .gbBad : .secondary)
92 }
93 LabeledContent("Joined", value: d.createdAt.formatted(date: .abbreviated, time: .omitted))
94 LabeledContent("Repositories", value: String(d.repos))
95 LabeledContent("Web sessions", value: String(d.webSessions))
96 if let after = d.deleteAfter {
97 LabeledContent("Deletes after", value: after)
98 .foregroundStyle(Color.gbBad)
99 }
100 }
101 .font(.gbSans(.subheadline))
102 }
103
104 private func limitsSection(_ l: OwnerLimits) -> some View {
105 Section {
106 LabeledContent("Repositories", value: "\(l.reposOwned) of \(cap(l.repos))")
107 LabeledContent("Storage", value: "\(bytes(l.bytesOwned)) of \(l.bytes == 0 ? "unlimited" : bytes(l.bytes))")
108 if let orgs = l.orgs {
109 LabeledContent("Organizations", value: "\(l.orgsCreated ?? 0) of \(cap(orgs))")
110 }
111 Button("Edit limits") { editingLimits = true }
112 .disabled(model.working)
113 .accessibilityIdentifier("admin-account-limits-edit")
114 } header: {
115 Text("Limits")
116 } footer: {
117 Text(l.override ? "Set on this account." : "The instance's defaults.")
118 }
119 .font(.gbSans(.subheadline))
120 }
121
122 private func keysSection(_ d: AdminAccountDetail) -> some View {
123 Section("SSH keys") {
124 if d.keys.isEmpty {
125 Text("None").foregroundStyle(.secondary)
126 }
127 ForEach(d.keys) { key in
128 VStack(alignment: .leading, spacing: 2) {
129 HStack(spacing: 6) {
130 Text(key.label.isEmpty ? key.algo : key.label).font(.gbSans(.subheadline).weight(.medium))
131 GBChip(key.scope, .secondary)
132 }
133 Text(key.fingerprint)
134 .font(.gbMono(.caption))
135 .lineLimit(1)
136 .truncationMode(.middle)
137 Text(key.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
138 .font(.gbSans(.caption2))
139 .foregroundStyle(.secondary)
140 }
141 }
142 }
143 .font(.gbSans(.subheadline))
144 }
145
146 private func emailsSection(_ d: AdminAccountDetail) -> some View {
147 Section("Email") {
148 if d.emails.isEmpty {
149 Text("None").foregroundStyle(.secondary)
150 }
151 ForEach(d.emails) { email in
152 HStack(spacing: 6) {
153 Text(email.address)
154 Spacer()
155 if email.primary { GBChip("primary", .secondary) }
156 GBChip(email.verified ? "verified" : "unverified", email.verified ? .gbDone : .gbWarn)
157 }
158 }
159 }
160 .font(.gbSans(.subheadline))
161 }
162
163 private func orgsSection(_ d: AdminAccountDetail) -> some View {
164 Section("Organizations") {
165 ForEach(d.orgs) { m in
166 LabeledContent(m.org, value: m.role)
167 }
168 }
169 .font(.gbSans(.subheadline))
170 }
171
172 private func tokensSection(_ d: AdminAccountDetail) -> some View {
173 Section("API tokens") {
174 ForEach(d.apiTokens) { t in
175 HStack(spacing: 6) {
176 Text(t.name)
177 GBChip(t.scope, .secondary)
178 Spacer()
179 Text(t.lastUsedAt.map { "used \($0.formatted(.relative(presentation: .named)))" } ?? "never used")
180 .font(.gbSans(.caption2))
181 .foregroundStyle(.secondary)
182 }
183 }
184 }
185 .font(.gbSans(.subheadline))
186 }
187
188 private func cap(_ n: Int64) -> String { n == 0 ? "unlimited" : String(n) }
189 private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) }
190}
191
192/// Caps for an account or an organization. An empty field stays as it
193/// is; `default` returns it to the instance's; 0 is unlimited.
194struct LimitsSheet: View {
195
196 let heading: String
197 let hasOrgs: Bool
198 let working: Bool
199 let error: String?
200 let save: (String, String, String) async -> Void
201
202 @State private var repos = ""
203 @State private var megabytes = ""
204 @State private var orgs = ""
205 @State private var sent = false
206 @Environment(\.dismiss) private var dismiss
207
208 var body: some View {
209 NavigationStack {
210 Form {
211 Section {
212 field("Repositories", $repos, id: "limits-repos")
213 field("Storage, MB", $megabytes, id: "limits-mb")
214 if hasOrgs { field("Organizations", $orgs, id: "limits-orgs") }
215 } footer: {
216 Text("Leave a field empty to keep it. A number sets the cap, 0 for unlimited; default takes the instance's.")
217 }
218 if sent, let error {
219 Section { GBNotice(error) }
220 }
221 }
222 .navigationTitle(heading)
223 .navigationBarTitleDisplayMode(.inline)
224 .toolbar {
225 ToolbarItem(placement: .cancellationAction) {
226 Button("Cancel") { dismiss() }
227 }
228 ToolbarItem(placement: .confirmationAction) {
229 if working {
230 ProgressView()
231 } else {
232 Button("Save") {
233 sent = true
234 Task { await save(repos, megabytes, orgs) }
235 }
236 .accessibilityIdentifier("limits-save")
237 }
238 }
239 }
240 }
241 }
242
243 private func field(_ title: String, _ text: Binding<String>, id: String) -> some View {
244 LabeledContent(title) {
245 TextField("unchanged", text: text)
246 .multilineTextAlignment(.trailing)
247 .autocorrectionDisabled()
248 .textInputAutocapitalization(.never)
249 .accessibilityIdentifier(id)
250 }
251 .font(.gbSans(.subheadline))
252 }
253}
gitbay/Views/Admin/AdminUsersView.swift +185 −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,136 @@ 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 @State private var verified = true
187 @State private var admin = false
188 @State private var key = ""
189 @State private var sent = false
190 @Environment(\.dismiss) private var dismiss
191
192 var body: some View {
193 NavigationStack {
194 Form {
195 Section("Username") {
196 TextField("username", text: $username)
197 .autocorrectionDisabled()
198 .textInputAutocapitalization(.never)
199 .accessibilityIdentifier("create-account-username")
200 }
201 Section {
202 TextField("address (optional)", text: $email)
203 .keyboardType(.emailAddress)
204 .autocorrectionDisabled()
205 .textInputAutocapitalization(.never)
206 .accessibilityIdentifier("create-account-email")
207 Toggle("Already verified", isOn: $verified)
208 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
209 } header: {
210 Text("Email")
211 }
212 Section {
213 TextEditor(text: $key)
214 .font(.gbMono(.caption))
215 .frame(minHeight: 80)
216 .autocorrectionDisabled()
217 .textInputAutocapitalization(.never)
218 .accessibilityIdentifier("create-account-key")
219 } header: {
220 Text("SSH key (optional)")
221 } footer: {
222 Text("An authorized_keys line. Without a key or a verified address the account has no way to sign in.")
223 }
224 Section {
225 Toggle("Instance admin", isOn: $admin)
226 }
227 if sent, let error = model.actionError {
228 Section { GBNotice(error) }
229 }
230 }
231 .navigationTitle("New Account")
232 .navigationBarTitleDisplayMode(.inline)
233 .toolbar {
234 ToolbarItem(placement: .cancellationAction) {
235 Button("Cancel") { dismiss() }
236 }
237 ToolbarItem(placement: .confirmationAction) {
238 if model.working {
239 ProgressView()
240 } else {
241 Button("Create") {
242 sent = true
243 Task {
244 if await model.create(username: username, email: email, verified: verified,
245 admin: admin, key: key) { onDone() }
246 }
247 }
248 .disabled(username.trimmingCharacters(in: .whitespaces).isEmpty)
249 .accessibilityIdentifier("create-account-submit")
250 }
251 }
252 }
253 }
254 }
255}
256
257/// `admin invite --email <address>`: a registration code by mail.
258private struct InviteSheet: View {
259
260 let model: AdminUsersViewModel
261 let onDone: (String) -> Void
262
263 @State private var email = ""
264 @State private var working = false
265 @Environment(\.dismiss) private var dismiss
266
267 var body: some View {
268 NavigationStack {
269 Form {
270 Section {
271 TextField("address", text: $email)
272 .keyboardType(.emailAddress)
273 .autocorrectionDisabled()
274 .textInputAutocapitalization(.never)
275 .accessibilityIdentifier("invite-email")
276 } footer: {
277 Text("Mails a code that lets them register, for an address no account holds.")
278 }
279 if let error = model.inviteError {
280 Section { GBNotice(error) }
281 }
282 }
283 .navigationTitle("Invite")
284 .navigationBarTitleDisplayMode(.inline)
285 .toolbar {
286 ToolbarItem(placement: .cancellationAction) {
287 Button("Cancel") { dismiss() }
288 }
289 ToolbarItem(placement: .confirmationAction) {
290 if working {
291 ProgressView()
292 } else {
293 Button("Invite") {
294 working = true
295 Task {
296 model.inviteError = nil
297 if let note = await model.invite(email: email) { onDone(note) }
298 working = false
299 }
300 }
301 .disabled(email.trimmingCharacters(in: .whitespaces).isEmpty)
302 .accessibilityIdentifier("invite-submit")
303 }
304 }
305 }
306 }
307 }
308}
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 +144
@@ -0,0 +1,144 @@
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 }
71
72 @Test func settingLimitsSendsTheChangedCaps() async throws {
73 let (model, stub) = try await loaded()
74 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
75 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
76 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
77
78 #expect(await model.setLimits(repos: "20", megabytes: "", orgs: "0"))
79
80 // The first post is the load's read of the caps; the write follows.
81 #expect(try posted(stub).map(\.0) == [["admin", "user", "limits", "other"],
82 ["admin", "user", "limits", "other", "--repos", "20", "--orgs", "0"],
83 ["admin", "user", "limits", "other"]])
84 }
85
86 @Test func aMalformedCapIsRefusedBeforeAsking() async throws {
87 let (model, stub) = try await loaded()
88
89 #expect(!(await model.setLimits(repos: "lots", megabytes: "", orgs: "")))
90 #expect(try posted(stub).count == 1) // the load's read only
91 #expect(model.actionError != nil)
92 }
93
94 @Test func deletingSendsYes() async throws {
95 let (model, stub) = try await loaded()
96 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"other"},"exit_code":0}"#, match: "cmd"))
97
98 #expect(await model.delete())
99 #expect(try posted(stub).map(\.0).last == ["admin", "user", "delete", "other", "--yes"])
100 }
101}
102
103@MainActor
104struct AdminCreateInviteTests {
105
106 @Test func creatingSendsTheKeyOnStdin() async throws {
107 let (client, stub) = try makeClient()
108 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"dee"},"exit_code":0}"#, match: "cmd"))
109 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
110 let model = AdminUsersViewModel(client: client)
111
112 let made = await model.create(username: " dee ", email: "dee@example.test", verified: true, admin: false,
113 key: "ssh-ed25519 AAAA dee\n")
114
115 #expect(made)
116 let (argv, stdin) = try #require(try posted(stub).first)
117 #expect(argv == ["admin", "user", "create", "dee", "--email", "dee@example.test", "--verified", "--key", "-"])
118 #expect(stdin == "ssh-ed25519 AAAA dee")
119 }
120
121 @Test func noEmailMeansNoVerifiedFlagAndNoKeyMeansNoStdin() async throws {
122 let (client, stub) = try makeClient()
123 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
124 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
125 let model = AdminUsersViewModel(client: client)
126
127 _ = await model.create(username: "eve", email: "", verified: true, admin: true, key: " ")
128
129 let (argv, stdin) = try #require(try posted(stub).first)
130 #expect(argv == ["admin", "user", "create", "eve", "--admin"])
131 #expect(stdin == nil)
132 }
133
134 @Test func anInviteThatCouldNotBeMailedHandsOverTheCode() async throws {
135 let (client, stub) = try makeClient()
136 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"email":"fay@example.test","mailed":false,"code":"abc123"},"exit_code":0}"#, match: "cmd"))
137 let model = AdminUsersViewModel(client: client)
138
139 let note = await model.invite(email: "fay@example.test")
140
141 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]])
142 #expect(note?.contains("abc123") == true)
143 }
144}