Sign-in: link to the web token page; say what full scope allows !136

merged merged by cmc on 2026-09-30 02:12 UTC · krz/gitbay-ios:sign-in-token-page into main

8 files changed, +46 −27

Layout: unified · split

README.org +9 −6
@@ -26,20 +26,23 @@ not at a keyboard.
26| Explore, feed and profiles | Discovery, activity, contribution graph | 26| Explore, feed and profiles | Discovery, activity, contribution graph |
27| Markup | Bodies and comments are written and rendered in the format they were stored in. Org renders natively through [[https://gitbay.org/krz/org-swift][OrgSwiftUI]] — selectable text, Dynamic Type, VoiceOver — rather than in a web view | 27| Markup | Bodies and comments are written and rendered in the format they were stored in. Org renders natively through [[https://gitbay.org/krz/org-swift][OrgSwiftUI]] — selectable text, Dynamic Type, VoiceOver — rather than in a web view |
28 28
29Everything the CLI can do reaches the app, except what is SSH-only by 29Everything the CLI can do reaches the app, except minting an API token,
30design: minting an API token, and deleting or transferring a repository, 30which is done on the web or over SSH, and deleting or transferring a
31both of which want a typed confirmation or carry a credential. 31repository, which is SSH-only by design since it wants a typed
32confirmation.
32 33
33* Signing in 34* Signing in
34 35
35As =gitbay= requires, your SSH key is your identity. To login to the mobile app, 36To sign in to the mobile app you need a bearer token. Create one with full
36you will need to mint a bearer token: 37scope under Settings → API tokens on the instance's website
38(=https://<instance>/settings#tokens=), or over SSH:
37 39
38#+begin_src sh 40#+begin_src sh
39gitbay auth token create --name iphone --scope full --ttl 90d 41gitbay auth token create --name iphone --scope full --ttl 90d
40#+end_src 42#+end_src
41 43
42Alternatively, =--scope read= works too, but a read-only token cannot comment or 44Full scope can comment and merge, and on an admin account, administer the
45instance. =--scope read= works too, but a read-only token cannot comment or
43merge. 46merge.
44 47
45Any gitbay instance works. You just need to enter the host at sign-in. 48Any gitbay instance works. You just need to enter the host at sign-in.
gitbay/Account/AccountViewModel.swift +1 −1
@@ -2,7 +2,7 @@ import Foundation
2import Observation 2import Observation
3 3
4/// SSH keys, PGP keys, and email verification — the account rows the web 4/// SSH keys, PGP keys, and email verification — the account rows the web
5/// has. Tokens stay SSH-only by design and get no UI here. 5/// has. Tokens are minted on the web or over SSH and get no UI here.
6@Observable 6@Observable
7@MainActor 7@MainActor
8final class AccountViewModel { 8final class AccountViewModel {
gitbay/Networking/GitbayInstance.swift +8
@@ -67,6 +67,14 @@ nonisolated struct GitbayInstance: Sendable, Hashable, Codable {
67 baseURL.appending(path: "/api/v1/cmd") 67 baseURL.appending(path: "/api/v1/cmd")
68 } 68 }
69 69
70 /// The web Settings → API tokens section.
71 var tokenPageURL: URL {
72 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
73 components.path = "/settings"
74 components.fragment = "tokens"
75 return components.url!
76 }
77
70 /// Whether a URL is on this instance. Checked before the Authorization 78 /// Whether a URL is on this instance. Checked before the Authorization
71 /// header goes on a request, and again on every redirect. 79 /// header goes on a request, and again on every redirect.
72 func isOwn(_ url: URL) -> Bool { 80 func isOwn(_ url: URL) -> Bool {
gitbay/Repos/WebhookViewModels.swift +5 −6
@@ -28,18 +28,17 @@ final class WebhookListViewModel {
28 } 28 }
29 } 29 }
30 30
31 /// `webhook add <repo> <url> [--secret -] [--events <e>]` — an empty 31 /// `webhook add <repo> <url> [--secret <s>] [--events <e>]` — an empty
32 /// secret or event list is left out, so the server applies its default. 32 /// secret or event list is left out, so the server applies its default.
33 /// The secret travels on stdin; the server refuses it in argv.
34 func add(url: String, secret: String, events: String) async -> Bool { 33 func add(url: String, secret: String, events: String) async -> Bool {
35 let url = url.trimmingCharacters(in: .whitespaces) 34 let url = url.trimmingCharacters(in: .whitespaces)
36 guard !url.isEmpty else { return false } 35 guard !url.isEmpty else { return false }
37 var argv = ["webhook", "add", repoPath, url] 36 var argv = ["webhook", "add", repoPath, url]
38 let secret = secret.trimmingCharacters(in: .whitespaces) 37 let secret = secret.trimmingCharacters(in: .whitespaces)
39 if !secret.isEmpty { argv += ["--secret", "-"] } 38 if !secret.isEmpty { argv += ["--secret", secret] }
40 let events = events.trimmingCharacters(in: .whitespaces) 39 let events = events.trimmingCharacters(in: .whitespaces)
41 if !events.isEmpty { argv += ["--events", events] } 40 if !events.isEmpty { argv += ["--events", events] }
42 await perform(argv, stdin: secret.isEmpty ? nil : secret + "\n") 41 await perform(argv)
43 return actionError == nil 42 return actionError == nil
44 } 43 }
45 44
@@ -47,12 +46,12 @@ final class WebhookListViewModel {
47 await perform(["webhook", "remove", repoPath, String(id)]) 46 await perform(["webhook", "remove", repoPath, String(id)])
48 } 47 }
49 48
50 private func perform(_ argv: [String], stdin: String? = nil) async { 49 private func perform(_ argv: [String]) async {
51 working = true 50 working = true
52 actionError = nil 51 actionError = nil
53 defer { working = false } 52 defer { working = false }
54 do { 53 do {
55 try await client.run(argv, stdin: stdin) 54 try await client.run(argv)
56 await load() 55 await load()
57 } catch let error as GitbayError { 56 } catch let error as GitbayError {
58 actionError = error.userFacingMessage 57 actionError = error.userFacingMessage
gitbay/Views/Account/AccountView.swift +2 −2
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3/// SSH keys, PGP keys, and email verification. Tokens are minted over 3/// SSH keys, PGP keys, and email verification. Tokens are minted on the
4/// SSH only, by design — no UI implies otherwise. 4/// web settings page or over SSH, not here.
5struct AccountView: View { 5struct AccountView: View {
6 6
7 @State private var model: AccountViewModel 7 @State private var model: AccountViewModel
gitbay/Views/SignInView.swift +13 −4
@@ -1,8 +1,8 @@
1import SwiftUI 1import SwiftUI
2 2
3/// Paste a token, name the instance, done. There is no browser flow and no 3/// Paste a token, name the instance, done. There is no browser flow and no
4/// password anywhere in the system: tokens are minted over SSH on a 4/// password in the app: tokens are minted on the instance's web settings
5/// machine that has it. 5/// page or over SSH.
6struct SignInView: View { 6struct SignInView: View {
7 7
8 @Environment(SessionStore.self) private var session 8 @Environment(SessionStore.self) private var session
@@ -42,11 +42,20 @@ struct SignInView: View {
42 Text("Token") 42 Text("Token")
43 } footer: { 43 } footer: {
44 VStack(alignment: .leading, spacing: 8) { 44 VStack(alignment: .leading, spacing: 8) {
45 Text("Mint one over SSH on a machine that has your key:") 45 if let tokenPage = (try? GitbayInstance(url: instanceURL))?.tokenPageURL {
46 Link(destination: tokenPage) {
47 Text("Create one with full scope under Settings → API tokens")
48 .multilineTextAlignment(.leading)
49 .frame(maxWidth: .infinity, alignment: .leading)
50 }
51 Text("Or over SSH on a machine that has your key:")
52 } else {
53 Text("Create one over SSH on a machine that has your key:")
54 }
46 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d") 55 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d")
47 .font(.gbMono(.caption)) 56 .font(.gbMono(.caption))
48 .textSelection(.enabled) 57 .textSelection(.enabled)
49 Text("`--scope read` also works, but a read-only token cannot comment or merge.") 58 Text("Full scope can comment and merge, and on an admin account, administer the instance. A read-only token cannot comment or merge.")
50 } 59 }
51 } 60 }
52 61
gitbayTests/GitbayClientTests.swift +7
@@ -338,6 +338,13 @@ struct GitbayInstanceTests {
338 #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go") 338 #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
339 } 339 }
340 340
341 @Test func tokenPageURLIsSettingsTokensOnTheInstance() throws {
342 let instance = try GitbayInstance(url: "gitbay.org/krz/solar?x=1")
343 #expect(instance.tokenPageURL.absoluteString == "https://gitbay.org/settings#tokens")
344 let local = try GitbayInstance(url: "http://localhost:3000")
345 #expect(local.tokenPageURL.absoluteString == "http://localhost:3000/settings#tokens")
346 }
347
341 @Test func ownHostCheckCoversSchemeHostAndPort() throws { 348 @Test func ownHostCheckCoversSchemeHostAndPort() throws {
342 let instance = try GitbayInstance(url: "https://gitbay.org") 349 let instance = try GitbayInstance(url: "https://gitbay.org")
343 #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!)) 350 #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
gitbayTests/WebhookTests.swift +1 −8
@@ -17,11 +17,6 @@ private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
17 return try #require(body["argv"] as? [String]) 17 return try #require(body["argv"] as? [String])
18} 18}
19 19
20private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return body["stdin"] as? String
23}
24
25private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# 20private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
26 21
27private let hooksJSON = """ 22private let hooksJSON = """
@@ -86,11 +81,9 @@ struct WebhookListViewModelTests {
86 #expect(first && second) 81 #expect(first && second)
87 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) 82 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
88 #expect(writes == [ 83 #expect(writes == [
89 ["webhook", "add", "krz/gitbay", "https://a.example/h", "--secret", "-", "--events", "push"], 84 ["webhook", "add", "krz/gitbay", "https://a.example/h", "--secret", "s3", "--events", "push"],
90 ["webhook", "add", "krz/gitbay", "https://b.example/h"], 85 ["webhook", "add", "krz/gitbay", "https://b.example/h"],
91 ]) 86 ])
92 let posts = stub.seen.filter { $0.method == "POST" }
93 #expect(try posts.map(stdinOf) == ["s3\n", nil])
94 } 87 }
95 88
96 @Test func removeSendsTheIdThenReloads() async throws { 89 @Test func removeSendsTheIdThenReloads() async throws {