Admin: account detail, create, delete, invite and limits !173
8 files changed, +904 −2
Layout: unified · split
gitbay/Admin/AdminAccount.swift added +260
| @@ -0,0 +1,260 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `admin user show <username>`: an account as an instance admin sees it. | |
| 5 | nonisolated 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. | |
| 87 | nonisolated 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 | |
| 132 | final 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 | ||
| 222 | extension 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 | 65 | private var reloadTask: Task<Void, Never>? |
| 66 | 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 | 70 | private(set) var working = false |
| 68 | 71 | |
| 69 | private let client: GitbayClient | |
| 72 | let client: GitbayClient | |
| 70 | 73 | |
| 71 | 74 | init(client: GitbayClient) { |
| 72 | 75 | self.client = client |
| @@ -88,6 +91,30 @@ final class AdminUsersViewModel { | ||
| 88 | 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 | 118 | func perform(_ action: AdminAction, on username: String) async { |
| 92 | 119 | working = true |
| 93 | 120 | actionError = nil |
gitbay/ContentView.swift +2
| @@ -347,6 +347,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 347 | 347 | AdminView(client: client) |
| 348 | 348 | case .adminUsers: |
| 349 | 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 @@ | ||
| 1 | import 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. | |
| 6 | struct 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. | |
| 199 | struct 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 | 5 | /// the username typed, as the web does. |
| 6 | 6 | struct AdminUsersView: View { |
| 7 | 7 | |
| 8 | @Environment(\.pushRoute) private var pushRoute | |
| 8 | 9 | @State private var model: AdminUsersViewModel |
| 9 | 10 | @State private var pending: Pending? |
| 10 | 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 | 16 | nonisolated struct Pending: Hashable, Sendable { |
| 13 | 17 | let action: AdminAction |
| @@ -32,11 +36,17 @@ struct AdminUsersView: View { | ||
| 32 | 36 | .accessibilityIdentifier("admin-user-filter") |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | if let error = model.actionError { | |
| 39 | if let error = model.actionError, !creating { | |
| 36 | 40 | Section { |
| 37 | 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 | 50 | ForEach(model.list.state.value ?? []) { user in |
| 41 | 51 | row(user) |
| 42 | 52 | } |
| @@ -47,6 +57,38 @@ struct AdminUsersView: View { | ||
| 47 | 57 | .navigationBarTitleDisplayMode(.inline) |
| 48 | 58 | .task { await model.loadIfNeeded() } |
| 49 | 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 | 92 | .alert( |
| 51 | 93 | pending.map { "\($0.action.title) \($0.username)?" } ?? "", |
| 52 | 94 | isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), |
| @@ -70,6 +112,11 @@ struct AdminUsersView: View { | ||
| 70 | 112 | |
| 71 | 113 | private func row(_ user: AdminUser) -> some View { |
| 72 | 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 | 120 | VStack(alignment: .leading, spacing: 3) { |
| 74 | 121 | HStack(spacing: 6) { |
| 75 | 122 | Text(user.username) |
| @@ -83,6 +130,10 @@ struct AdminUsersView: View { | ||
| 83 | 130 | .font(.gbSans(.caption)) |
| 84 | 131 | .foregroundStyle(.secondary) |
| 85 | 132 | } |
| 133 | .contentShape(Rectangle()) | |
| 134 | } | |
| 135 | .buttonStyle(.plain) | |
| 136 | .accessibilityIdentifier("admin-user-\(user.username)") | |
| 86 | 137 | Spacer() |
| 87 | 138 | Menu { |
| 88 | 139 | Button(user.roleAction.title) { ask(user.roleAction, user) } |
| @@ -122,3 +173,137 @@ struct AdminUsersView: View { | ||
| 122 | 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. | |
| 179 | private 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. | |
| 259 | private 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 | 32 | case notifications |
| 33 | 33 | case admin |
| 34 | 34 | case adminUsers |
| 35 | case adminUser(String) | |
| 35 | 36 | } |
gitbay/Views/Shared/ChangeNotices.swift +2
| @@ -8,4 +8,6 @@ extension Notification.Name { | ||
| 8 | 8 | static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged") |
| 9 | 9 | /// A saved query was saved, pinned, unpinned or removed. |
| 10 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 22 | private 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 | ||
| 31 | private 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 | ||
| 36 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 37 | ||
| 38 | @MainActor | |
| 39 | struct 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 | |
| 106 | struct 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 | } | |