Sign-in: link to the web token page; say what full scope allows !136
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 | ||
| 29 | Everything the CLI can do reaches the app, except what is SSH-only by | 29 | Everything the CLI can do reaches the app, except minting an API token, |
| 30 | design: minting an API token, and deleting or transferring a repository, | 30 | which is done on the web or over SSH, and deleting or transferring a |
| 31 | both of which want a typed confirmation or carry a credential. | 31 | repository, which is SSH-only by design since it wants a typed |
| 32 | confirmation. | ||
| 32 | 33 | ||
| 33 | * Signing in | 34 | * Signing in |
| 34 | 35 | ||
| 35 | As =gitbay= requires, your SSH key is your identity. To login to the mobile app, | 36 | To sign in to the mobile app you need a bearer token. Create one with full |
| 36 | you will need to mint a bearer token: | 37 | scope 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 |
| 39 | gitbay auth token create --name iphone --scope full --ttl 90d | 41 | gitbay auth token create --name iphone --scope full --ttl 90d |
| 40 | #+end_src | 42 | #+end_src |
| 41 | 43 | ||
| 42 | Alternatively, =--scope read= works too, but a read-only token cannot comment or | 44 | Full scope can comment and merge, and on an admin account, administer the |
| 45 | instance. =--scope read= works too, but a read-only token cannot comment or | ||
| 43 | merge. | 46 | merge. |
| 44 | 47 | ||
| 45 | Any gitbay instance works. You just need to enter the host at sign-in. | 48 | Any 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 | |||
| 2 | import Observation | 2 | import 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 |
| 8 | final class AccountViewModel { | 8 | final 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 @@ | |||
| 1 | import SwiftUI | 1 | import 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. |
| 5 | struct AccountView: View { | 5 | struct 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 @@ | |||
| 1 | import SwiftUI | 1 | import 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. |
| 6 | struct SignInView: View { | 6 | struct 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 | ||
| 20 | private 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 | |||
| 25 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | 20 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# |
| 26 | 21 | ||
| 27 | private let hooksJSON = """ | 22 | private 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 { |