Account: browser sessions !184

merged merged by cmc on 2026-10-16 03:33 UTC · krz/gitbay-ios:web-sessions into main

6 files changed, +222 −0

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 {
347347 AccountView(client: client)
348348 case .tokens:
349349 TokensView(client: client)
350 case .webSessions:
351 WebSessionsView(client: client)
350352 case .addAccount:
351353 SignInView()
352354 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)) · 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 {
271271 NavigationLink(value: RepoRoute.tokens) {
272272 Label("API Tokens", systemImage: "key.horizontal")
273273 }
274 NavigationLink(value: RepoRoute.webSessions) {
275 Label("Browser Sessions", systemImage: "macwindow")
276 }
274277 NavigationLink(value: SnippetRoute.list(owner: nil)) {
275278 Label("Snippets", systemImage: "scissors")
276279 }
gitbay/Views/Repos/RepoRoute.swift +1
@@ -32,6 +32,7 @@ nonisolated enum RepoRoute: Hashable {
3232 case profile(String)
3333 case account
3434 case tokens
35 case webSessions
3536 case addAccount
3637 case notifications
3738 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}