Account: browser sessions !184
6 files changed, +222 −0
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 | 347 | AccountView(client: client) |
| 348 | 348 | case .tokens: |
| 349 | 349 | TokensView(client: client) |
| 350 | case .webSessions: | |
| 351 | WebSessionsView(client: client) | |
| 350 | 352 | case .addAccount: |
| 351 | 353 | SignInView() |
| 352 | 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)) · signs out \(session.expiresAt.formatted(.relative(presentation: .named))) if unused") | |
| 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 | 271 | NavigationLink(value: RepoRoute.tokens) { |
| 272 | 272 | Label("API Tokens", systemImage: "key.horizontal") |
| 273 | 273 | } |
| 274 | NavigationLink(value: RepoRoute.webSessions) { | |
| 275 | Label("Browser Sessions", systemImage: "macwindow") | |
| 276 | } | |
| 274 | 277 | NavigationLink(value: SnippetRoute.list(owner: nil)) { |
| 275 | 278 | Label("Snippets", systemImage: "scissors") |
| 276 | 279 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -32,6 +32,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 32 | 32 | case profile(String) |
| 33 | 33 | case account |
| 34 | 34 | case tokens |
| 35 | case webSessions | |
| 35 | 36 | case addAccount |
| 36 | 37 | case notifications |
| 37 | 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 | } | |