Commit 4078400b37
Verified · cmc
Layout: unified · split
gitbay/Account/WebSessions.swift added +66
| @@ -0,0 +1,66 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// One row of `web sessions list`: a browser signed in to the web. | ||
| 5 | nonisolated struct WebSession: Decodable, Sendable, Hashable, Identifiable { | ||
| 6 | /// The first characters of the session's hash, which `revoke` takes. | ||
| 7 | let id: String | ||
| 8 | let createdAt: Date | ||
| 9 | let expiresAt: Date | ||
| 10 | let lastUsedAt: Date | ||
| 11 | |||
| 12 | enum CodingKeys: String, CodingKey { | ||
| 13 | case id | ||
| 14 | case createdAt = "created_at" | ||
| 15 | case expiresAt = "expires_at" | ||
| 16 | case lastUsedAt = "last_used_at" | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | /// `web sessions list` and `revoke`. The app signs in with an API token, | ||
| 21 | /// so ending a browser session never signs the app out. | ||
| 22 | @Observable | ||
| 23 | @MainActor | ||
| 24 | final class WebSessionsViewModel { | ||
| 25 | |||
| 26 | private(set) var state: LoadState<[WebSession]> = .loading | ||
| 27 | private(set) var actionError: String? | ||
| 28 | private(set) var working = false | ||
| 29 | |||
| 30 | private let client: GitbayClient | ||
| 31 | |||
| 32 | init(client: GitbayClient) { | ||
| 33 | self.client = client | ||
| 34 | } | ||
| 35 | |||
| 36 | func load() async { | ||
| 37 | do { | ||
| 38 | let sessions = try await client.readList(["web", "sessions", "list"], of: WebSession.self) | ||
| 39 | state = sessions.isEmpty ? .empty("No browser is signed in.") : .loaded(sessions) | ||
| 40 | } catch { | ||
| 41 | state = .from(error) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | func revoke(id: String) async { | ||
| 46 | await perform(["web", "sessions", "revoke", id]) | ||
| 47 | } | ||
| 48 | |||
| 49 | func revokeAll() async { | ||
| 50 | await perform(["web", "sessions", "revoke", "--all"]) | ||
| 51 | } | ||
| 52 | |||
| 53 | private func perform(_ argv: [String]) async { | ||
| 54 | working = true | ||
| 55 | actionError = nil | ||
| 56 | defer { working = false } | ||
| 57 | do { | ||
| 58 | try await client.run(argv) | ||
| 59 | await load() | ||
| 60 | } catch let error as GitbayError { | ||
| 61 | actionError = error.userFacingMessage | ||
| 62 | } catch { | ||
| 63 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
gitbay/ContentView.swift +2
| @@ -347,6 +347,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 347 | AccountView(client: client) | 347 | AccountView(client: client) |
| 348 | case .tokens: | 348 | case .tokens: |
| 349 | TokensView(client: client) | 349 | TokensView(client: client) |
| 350 | case .webSessions: | ||
| 351 | WebSessionsView(client: client) | ||
| 350 | case .addAccount: | 352 | case .addAccount: |
| 351 | SignInView() | 353 | SignInView() |
| 352 | case .notifications: | 354 | case .notifications: |
gitbay/Views/Account/WebSessionsView.swift added +78
| @@ -0,0 +1,78 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Browsers signed in to the web as you, with a way to end one or all. | ||
| 4 | struct WebSessionsView: View { | ||
| 5 | |||
| 6 | @State private var model: WebSessionsViewModel | ||
| 7 | @State private var revoking: WebSession? | ||
| 8 | @State private var revokingAll = false | ||
| 9 | |||
| 10 | init(client: GitbayClient) { | ||
| 11 | _model = State(initialValue: WebSessionsViewModel(client: client)) | ||
| 12 | } | ||
| 13 | |||
| 14 | var body: some View { | ||
| 15 | List { | ||
| 16 | if let error = model.actionError { | ||
| 17 | Section { GBNotice(error, .gbWarn) } | ||
| 18 | } | ||
| 19 | Section { | ||
| 20 | ForEach(model.state.value ?? []) { session in | ||
| 21 | VStack(alignment: .leading, spacing: 3) { | ||
| 22 | Text("Used \(session.lastUsedAt.formatted(.relative(presentation: .named)))") | ||
| 23 | .font(.gbSans(.subheadline)) | ||
| 24 | Text("Signed in \(session.createdAt.formatted(date: .abbreviated, time: .shortened)) · ends \(session.expiresAt.formatted(date: .abbreviated, time: .omitted))") | ||
| 25 | .font(.gbSans(.caption2)) | ||
| 26 | .foregroundStyle(.secondary) | ||
| 27 | Text(session.id) | ||
| 28 | .font(.gbMono(.caption2)) | ||
| 29 | .foregroundStyle(.secondary) | ||
| 30 | } | ||
| 31 | .padding(.vertical, 2) | ||
| 32 | .accessibilityElement(children: .combine) | ||
| 33 | .swipeActions { | ||
| 34 | Button("Sign Out", role: .destructive) { revoking = session } | ||
| 35 | .disabled(model.working) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } footer: { | ||
| 39 | if model.state.value != nil { | ||
| 40 | Text("Each is a browser signed in to the web. This app uses its token and stays signed in.") | ||
| 41 | } | ||
| 42 | } | ||
| 43 | if let sessions = model.state.value, sessions.count > 1 { | ||
| 44 | Section { | ||
| 45 | Button("Sign Out Everywhere", role: .destructive) { revokingAll = true } | ||
| 46 | .disabled(model.working) | ||
| 47 | .accessibilityIdentifier("sessions-revoke-all") | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 52 | .navigationTitle("Browser Sessions") | ||
| 53 | .navigationBarTitleDisplayMode(.inline) | ||
| 54 | .confirmationDialog( | ||
| 55 | "Sign this browser out? It will need to sign in again.", | ||
| 56 | isPresented: Binding(get: { revoking != nil }, set: { if !$0 { revoking = nil } }), | ||
| 57 | titleVisibility: .visible | ||
| 58 | ) { | ||
| 59 | Button("Sign Out", role: .destructive) { | ||
| 60 | if let session = revoking { Task { await model.revoke(id: session.id) } } | ||
| 61 | revoking = nil | ||
| 62 | } | ||
| 63 | Button("Cancel", role: .cancel) {} | ||
| 64 | } | ||
| 65 | .confirmationDialog( | ||
| 66 | "Sign out every browser? Each will need to sign in again.", | ||
| 67 | isPresented: $revokingAll, | ||
| 68 | titleVisibility: .visible | ||
| 69 | ) { | ||
| 70 | Button("Sign Out Everywhere", role: .destructive) { | ||
| 71 | Task { await model.revokeAll() } | ||
| 72 | } | ||
| 73 | Button("Cancel", role: .cancel) {} | ||
| 74 | } | ||
| 75 | .task { await model.load() } | ||
| 76 | .refreshable { await model.load() } | ||
| 77 | } | ||
| 78 | } | ||
gitbay/Views/Repos/RepoListView.swift +3
| @@ -271,6 +271,9 @@ struct AccountMenu: ToolbarContent { | |||
| 271 | NavigationLink(value: RepoRoute.tokens) { | 271 | NavigationLink(value: RepoRoute.tokens) { |
| 272 | Label("API Tokens", systemImage: "key.horizontal") | 272 | Label("API Tokens", systemImage: "key.horizontal") |
| 273 | } | 273 | } |
| 274 | NavigationLink(value: RepoRoute.webSessions) { | ||
| 275 | Label("Browser Sessions", systemImage: "macwindow") | ||
| 276 | } | ||
| 274 | NavigationLink(value: SnippetRoute.list(owner: nil)) { | 277 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 275 | Label("Snippets", systemImage: "scissors") | 278 | Label("Snippets", systemImage: "scissors") |
| 276 | } | 279 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -32,6 +32,7 @@ nonisolated enum RepoRoute: Hashable { | |||
| 32 | case profile(String) | 32 | case profile(String) |
| 33 | case account | 33 | case account |
| 34 | case tokens | 34 | case tokens |
| 35 | case webSessions | ||
| 35 | case addAccount | 36 | case addAccount |
| 36 | case notifications | 37 | case notifications |
| 37 | case admin | 38 | case admin |
gitbayTests/WebSessionTests.swift added +72
| @@ -0,0 +1,72 @@ | |||
| 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 writes(_ stub: StubProtocol.Box) throws -> [[String]] { | ||
| 16 | try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map { | ||
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any]) | ||
| 18 | return try #require(body["argv"] as? [String]) | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | private let sessionsJSON = """ | ||
| 23 | {"protocol_version":1,"data":[\ | ||
| 24 | {"id":"a1b2c3d4e5f6","created_at":"2026-10-14T08:00:00.000Z","expires_at":"2026-11-13T08:00:00.000Z","last_used_at":"2026-10-15T09:00:00.000Z"},\ | ||
| 25 | {"id":"0f9e8d7c6b5a","created_at":"2026-10-01T08:00:00.000Z","expires_at":"2026-10-31T08:00:00.000Z","last_used_at":"2026-10-01T08:00:00.000Z"}\ | ||
| 26 | ],"exit_code":0} | ||
| 27 | """ | ||
| 28 | |||
| 29 | @MainActor | ||
| 30 | struct WebSessionsViewModelTests { | ||
| 31 | |||
| 32 | @Test func listsSessionsNewestFirst() async throws { | ||
| 33 | let (client, stub) = try makeClient() | ||
| 34 | stub.enqueue(.init(status: 200, json: sessionsJSON)) | ||
| 35 | let model = WebSessionsViewModel(client: client) | ||
| 36 | await model.load() | ||
| 37 | |||
| 38 | let sessions = try #require(model.state.value) | ||
| 39 | #expect(sessions.map(\.id) == ["a1b2c3d4e5f6", "0f9e8d7c6b5a"]) | ||
| 40 | #expect(sessions[0].lastUsedAt > sessions[1].lastUsedAt) | ||
| 41 | #expect(stub.seen.first?.url.query() == "argv=web&argv=sessions&argv=list") | ||
| 42 | } | ||
| 43 | |||
| 44 | @Test func noSessionsIsAnEmptyState() async throws { | ||
| 45 | let (client, stub) = try makeClient() | ||
| 46 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#)) | ||
| 47 | let model = WebSessionsViewModel(client: client) | ||
| 48 | await model.load() | ||
| 49 | guard case .empty = model.state else { | ||
| 50 | Issue.record("expected .empty, got \(model.state)") | ||
| 51 | return | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | @Test func revokeOneThenAll() async throws { | ||
| 56 | let (client, stub) = try makeClient() | ||
| 57 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"revoked":"a1b2c3d4e5f6"},"exit_code":0}"#, match: "cmd")) | ||
| 58 | stub.enqueue(.init(status: 200, json: sessionsJSON, match: "argv=list")) | ||
| 59 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"revoked":2},"exit_code":0}"#, match: "cmd")) | ||
| 60 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#, match: "argv=list")) | ||
| 61 | let model = WebSessionsViewModel(client: client) | ||
| 62 | |||
| 63 | await model.revoke(id: "a1b2c3d4e5f6") | ||
| 64 | await model.revokeAll() | ||
| 65 | |||
| 66 | #expect(try writes(stub) == [["web", "sessions", "revoke", "a1b2c3d4e5f6"], ["web", "sessions", "revoke", "--all"]]) | ||
| 67 | guard case .empty = model.state else { | ||
| 68 | Issue.record("expected .empty after revoking all, got \(model.state)") | ||
| 69 | return | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||