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 | 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 @@ | |||
| 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 | /// the username typed, as the web does. | 5 | /// the username typed, as the web does. |
| 6 | struct AdminUsersView: View { | 6 | struct 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. | ||
| 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 | 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 @@ | |||
| 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 | } | ||