Commit 4078400b37

4078400b3779360753932fa3f723518e93d6c511

parent: 7d4c8f49e2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:13 UTC

Account: browser sessions

Browser Sessions, in the account menu, lists the browsers signed in to
the web as you, with when each signed in, was last used and ends.
Swiping one signs it out; Sign Out Everywhere ends them all. The app
signs in with its token, so neither signs it out.

Closes #46

Layout: unified · split

gitbay/Account/WebSessions.swift added +66
@@ -0,0 +1,66 @@
1import Foundation
2import Observation
3
4/// One row of `web sessions list`: a browser signed in to the web.
5nonisolated 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
24final 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 @@
1import SwiftUI
2
3/// Browsers signed in to the web as you, with a way to end one or all.
4struct 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 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func 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
22private 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
30struct 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}