Skip to content

Commit 18cf104

Browse files
committed
Added description of token.
1 parent 153e180 commit 18cf104

7 files changed

Lines changed: 198 additions & 13 deletions

File tree

CCMenu.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,7 @@
10721072
"$(inherited)",
10731073
"@executable_path/../Frameworks",
10741074
);
1075+
MACOSX_DEPLOYMENT_TARGET = 14.6;
10751076
PRODUCT_BUNDLE_IDENTIFIER = net.sourceforge.cruisecontrol.CCMenu;
10761077
PRODUCT_NAME = "$(TARGET_NAME)";
10771078
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1101,6 +1102,7 @@
11011102
"$(inherited)",
11021103
"@executable_path/../Frameworks",
11031104
);
1105+
MACOSX_DEPLOYMENT_TARGET = 14.6;
11041106
PRODUCT_BUNDLE_IDENTIFIER = net.sourceforge.cruisecontrol.CCMenu;
11051107
PRODUCT_NAME = "$(TARGET_NAME)";
11061108
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1127,6 +1129,7 @@
11271129
"@executable_path/../Frameworks",
11281130
"@loader_path/../Frameworks",
11291131
);
1132+
MACOSX_DEPLOYMENT_TARGET = 14.6;
11301133
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuTests;
11311134
PRODUCT_NAME = "$(TARGET_NAME)";
11321135
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1153,6 +1156,7 @@
11531156
"@executable_path/../Frameworks",
11541157
"@loader_path/../Frameworks",
11551158
);
1159+
MACOSX_DEPLOYMENT_TARGET = 14.6;
11561160
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuTests;
11571161
PRODUCT_NAME = "$(TARGET_NAME)";
11581162
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1178,6 +1182,7 @@
11781182
"@executable_path/../Frameworks",
11791183
"@loader_path/../Frameworks",
11801184
);
1185+
MACOSX_DEPLOYMENT_TARGET = 14.6;
11811186
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuUITests;
11821187
PRODUCT_NAME = "$(TARGET_NAME)";
11831188
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1203,6 +1208,7 @@
12031208
"@executable_path/../Frameworks",
12041209
"@loader_path/../Frameworks",
12051210
);
1211+
MACOSX_DEPLOYMENT_TARGET = 14.6;
12061212
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuUITests;
12071213
PRODUCT_NAME = "$(TARGET_NAME)";
12081214
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1232,6 +1238,7 @@
12321238
"@loader_path/../Frameworks",
12331239
);
12341240
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1241+
MACOSX_DEPLOYMENT_TARGET = 14.6;
12351242
MARKETING_VERSION = 1.0;
12361243
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuIntegrationTests;
12371244
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1264,6 +1271,7 @@
12641271
"@loader_path/../Frameworks",
12651272
);
12661273
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1274+
MACOSX_DEPLOYMENT_TARGET = 14.6;
12671275
MARKETING_VERSION = 1.0;
12681276
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuIntegrationTests;
12691277
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1358,6 +1366,7 @@
13581366
"$(inherited)",
13591367
"@executable_path/../Frameworks",
13601368
);
1369+
MACOSX_DEPLOYMENT_TARGET = 14.6;
13611370
PRODUCT_BUNDLE_IDENTIFIER = net.sourceforge.cruisecontrol.CCMenu;
13621371
PRODUCT_NAME = "$(TARGET_NAME)";
13631372
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1384,6 +1393,7 @@
13841393
"@executable_path/../Frameworks",
13851394
"@loader_path/../Frameworks",
13861395
);
1396+
MACOSX_DEPLOYMENT_TARGET = 14.6;
13871397
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuTests;
13881398
PRODUCT_NAME = "$(TARGET_NAME)";
13891399
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1413,6 +1423,7 @@
14131423
"@loader_path/../Frameworks",
14141424
);
14151425
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1426+
MACOSX_DEPLOYMENT_TARGET = 14.6;
14161427
MARKETING_VERSION = 1.0;
14171428
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuIntegrationTests;
14181429
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1441,6 +1452,7 @@
14411452
"@executable_path/../Frameworks",
14421453
"@loader_path/../Frameworks",
14431454
);
1455+
MACOSX_DEPLOYMENT_TARGET = 14.6;
14441456
PRODUCT_BUNDLE_IDENTIFIER = org.ccmenu.CCMenuUITests;
14451457
PRODUCT_NAME = "$(TARGET_NAME)";
14461458
PROVISIONING_PROFILE_SPECIFIER = "";

CCMenu/Source/Pipeline Window/GitLab Sheets/AddGitLabPipelineSheet.swift

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,47 @@ struct AddGitLabPipelineSheet: View {
1818
@StateObject private var builder = GitLabPipelineBuilder()
1919
@State private var selectedProjectId: Int? = nil
2020
@State private var tokenInput: String = ""
21-
21+
@FocusState private var istokenFieldFocused: Bool
22+
2223
var body: some View {
2324
VStack {
2425
Text("Add GitLab pipeline")
2526
.font(.headline)
2627
.padding(.bottom)
27-
Text("Enter a GitLab user or group name to fetch projects. If there are many projects only the most recently updated will be shown.\n\nCreate a Personal Access Token with read_api scope to access private projects.")
28+
Text("Enter a GitLab user or group name to fetch projects. If there are many projects only the most recently updated will be shown. You can type any valid name, even if it's not shown.\n\nCreate a personal access token with `read_api` scope to access private projects.")
2829
.fixedSize(horizontal: false, vertical: true)
2930
.padding(.bottom)
3031
Form {
3132
HStack {
32-
TextField("Authentication:", text: $tokenInput, prompt: Text("personal access token"))
33+
SecureField("Authentication:", text: $tokenInput, prompt: Text("personal access token"))
3334
.accessibilityIdentifier("Token field")
34-
.onChange(of: tokenInput) { newValue in
35-
authenticator.setToken(newValue)
35+
.focused($istokenFieldFocused)
36+
.onChange(of: istokenFieldFocused) { _, newValue in
37+
if newValue == false {
38+
Task {
39+
await authenticator.setToken(tokenInput)
40+
}
41+
}
3642
}
3743
Button(authenticator.token == nil ? "Create token" : "Manage tokens") {
3844
authenticator.openTokenSettingsOnWebsite()
3945
}
4046
}
41-
.padding(.bottom)
47+
if !authenticator.tokenDescription.isEmpty {
48+
HStack {
49+
Text(LocalizedStringKey(authenticator.tokenDescription))
50+
.accessibilityIdentifier("Token description field")
51+
.font(.callout)
52+
.padding(.vertical, 4)
53+
.padding(.horizontal, 8)
54+
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary, lineWidth: 0.5))
55+
}
56+
}
4257

4358
TextField("User or Group:", text: $owner.input, prompt: Text("user or group name"))
4459
.accessibilityIdentifier("Owner field")
4560
.autocorrectionDisabled(true)
61+
.padding(.top)
4662
.onReceive(owner.$text) { t in
4763
if t.isEmpty {
4864
projectList.clearProjects()

CCMenu/Source/Pipeline Window/GitLab Sheets/GitLabAuthenticator.swift

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,74 @@ class GitLabAuthenticator: ObservableObject {
1212
@Published var token: String?
1313
@Published var tokenDescription: String = ""
1414

15-
func setToken(_ newToken: String) {
15+
func setToken(_ newToken: String) async {
1616
let trimmed = newToken.trimmingCharacters(in: .whitespacesAndNewlines)
1717
if trimmed.isEmpty {
1818
token = nil
1919
tokenDescription = ""
2020
} else {
2121
token = trimmed
22-
tokenDescription = trimmed
22+
let request = GitLabAPI.requestForTokenInfo(token: trimmed)
23+
guard let pat = await fetchTokenInfo(request: request) else {
24+
tokenDescription = "n/a"
25+
return
26+
}
27+
updateTokenDescription(pat: pat)
28+
}
29+
}
30+
31+
private func fetchTokenInfo(request: URLRequest) async -> GitLabPersonalAccessToken? {
32+
do {
33+
let (data, response) = try await URLSession.shared.data(for: request)
34+
guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) }
35+
if response.statusCode != 200 {
36+
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "application")
37+
logger.error("Error when getting token information: \(response, privacy: .public)")
38+
return nil
39+
}
40+
let decoder = JSONDecoder()
41+
decoder.keyDecodingStrategy = .convertFromSnakeCase
42+
return try decoder.decode(GitLabPersonalAccessToken.self, from: data)
43+
} catch {
44+
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "application")
45+
logger.error("Error when getting token information: \(error.localizedDescription, privacy: .public)")
46+
return nil
47+
}
48+
}
49+
50+
private func updateTokenDescription(pat: GitLabPersonalAccessToken) {
51+
tokenDescription = String(format:"**%@**", pat.name)
52+
var errors: [String] = []
53+
if !pat.active {
54+
errors.append("not active")
55+
}
56+
if !pat.scopes.contains("read_api") {
57+
errors.append("missing read_api scope")
58+
}
59+
if !errors.isEmpty {
60+
let errorText = errors.joined(separator: ", ")
61+
tokenDescription.append(String(format: "\n%@", errorText))
62+
} else if let expiryDate = pat.expiresAtDate {
63+
let formatter = RelativeDateTimeFormatter()
64+
let expiryText = formatter.localizedString(for: expiryDate, relativeTo: Date())
65+
tokenDescription.append(String(format: "\nExpires: %@", expiryText))
2366
}
2467
}
2568

69+
2670
func fetchTokenFromKeychain() {
2771
do {
2872
token = try Keychain.standard.getToken(forService: "GitLab")
73+
if let token {
74+
Task {
75+
await setToken(token)
76+
}
77+
}
2978
} catch {
3079
token = nil
3180
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "application")
3281
logger.error("Error when retrieving token from keychain: \(error.localizedDescription, privacy: .public)")
3382
}
34-
tokenDescription = token ?? ""
3583
}
3684

3785
func storeTokenInKeychain() {
@@ -46,7 +94,22 @@ class GitLabAuthenticator: ObservableObject {
4694
}
4795

4896
func openTokenSettingsOnWebsite() {
97+
if token == nil {
98+
if !showExplanation() {
99+
return
100+
}
101+
}
49102
NSWorkspace.shared.open(GitLabAPI.tokenSettingsUrl())
50103
}
51104

105+
private func showExplanation() -> Bool {
106+
let alert = NSAlert()
107+
alert.messageText = "Create token with read_api scope"
108+
alert.informativeText = "The process will continue on the GitLab website in your default web browser. You must create a personal access token with read_api scope.\n\nCopy the token on the website, return to CCMenu, and paste it into the text field."
109+
alert.alertStyle = .informational
110+
alert.addButton(withTitle: "Continue")
111+
alert.addButton(withTitle: "Cancel")
112+
return alert.runModal() == .alertFirstButtonReturn
113+
}
114+
52115
}

CCMenu/Source/Pipeline Window/GitLab Sheets/GitLabSheetModel.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,30 @@ struct GitLabBranch: Identifiable, Hashable, Decodable {
7373
return !name.isEmpty && !name.starts(with: "(")
7474
}
7575
}
76+
77+
struct GitLabPersonalAccessToken: Identifiable, Hashable, Decodable {
78+
var id: Int
79+
var name: String
80+
var scopes: [String]
81+
var active: Bool
82+
var expiresAt: String
83+
84+
init() {
85+
self.id = 0
86+
self.name = ""
87+
self.scopes = []
88+
self.active = false
89+
self.expiresAt = ""
90+
}
91+
92+
func hash(into hasher: inout Hasher) {
93+
hasher.combine(id)
94+
}
95+
96+
var expiresAtDate: Date? {
97+
let formatter = ISO8601DateFormatter()
98+
formatter.formatOptions = [.withFullDate, .withDashSeparatorInDate]
99+
return formatter.date(from: expiresAt)
100+
}
101+
102+
}

CCMenu/Source/Server Monitor/GitLabAPI.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ class GitLabAPI {
6868
}
6969

7070

71-
// MARK: - token settings
71+
// MARK: - personal access tokens
72+
73+
static func requestForTokenInfo(token: String) -> URLRequest {
74+
let path = String(format: "/personal_access_tokens/self")
75+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, token: token)
76+
}
7277

7378
static func tokenSettingsUrl() -> URL {
7479
baseURL(forAPI: false).appending(path: "/-/user_settings/personal_access_tokens")

CCMenuUITests/GitLabTests.swift

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ class GitLabTests: XCTestCase {
8787
}
8888

8989
func testAddsGitLabPipeline() throws {
90-
webapp.router.get("/v4/users/erikdoe/projects") { _ in
90+
webapp.router.get("/api/v4/users/erikdoe/projects") { _ in
9191
try TestHelper.contentsOfFile("GitLabPipelinesByUserResponse.json")
9292
}
93-
webapp.router.get("/v4/projects/66079563/repository/branches") { _ in
93+
webapp.router.get("/api/v4/projects/66079563/repository/branches") { _ in
9494
try TestHelper.contentsOfFile("GitLabBranchesResponse.json")
9595
}
9696
webapp.router.get("/api/v4/projects/66079563/pipelines") { r in
@@ -135,6 +135,66 @@ class GitLabTests: XCTestCase {
135135
expectation(for: NSPredicate(format: "value CONTAINS 'Label: 3'"), evaluatedWith: descriptionText)
136136
waitForExpectations(timeout: 5)
137137
}
138+
139+
func testShowsTokenDetails() throws {
140+
webapp.router.get("/api/v4/personal_access_tokens/self") { r in
141+
if r.headers["Authorization"].first != "Bearer TEST-TOKEN" {
142+
r.response.status = .unauthorized
143+
return "{ \"message\": \"Unauthorized\" } "
144+
}
145+
return """
146+
{ "id": 19127271,
147+
"name": "Test token",
148+
"scopes": ["read_api"],
149+
"active": true,
150+
"expiresAt": "2026-02-27"
151+
}
152+
"""
153+
}
154+
155+
let app = TestHelper.launchApp(pipelines: "EmptyPipelines.json", pauseMonitor: false)
156+
let sheet = openAddGitLabPipelineSheet(app: app)
157+
158+
// Enter token
159+
sheet.secureTextFields["Token field"].click()
160+
sheet.typeText("TEST-TOKEN" + "\n")
161+
sheet.textFields["Owner field"].click()
162+
163+
// Wait for token info and check
164+
let descriptionText = sheet.staticTexts["Token description field"]
165+
expectation(for: NSPredicate(format: "value CONTAINS 'Test token'"), evaluatedWith: descriptionText)
166+
expectation(for: NSPredicate(format: "value CONTAINS 'Expires: '"), evaluatedWith: descriptionText)
167+
waitForExpectations(timeout: 5)
168+
}
169+
170+
func testShowsTokenDetailsWithErrors() throws {
171+
webapp.router.get("/api/v4/personal_access_tokens/self") { r in
172+
return """
173+
{ "id": 19127271,
174+
"name": "Test token",
175+
"scopes": ["read_user"],
176+
"active": false,
177+
"expiresAt": "2026-02-27"
178+
}
179+
"""
180+
}
181+
182+
let app = TestHelper.launchApp(pipelines: "EmptyPipelines.json", pauseMonitor: false)
183+
let sheet = openAddGitLabPipelineSheet(app: app)
184+
185+
// Enter token
186+
sheet.secureTextFields["Token field"].click()
187+
sheet.typeText("TEST-TOKEN" + "\n")
188+
sheet.textFields["Owner field"].click()
189+
190+
// Wait for token info and check
191+
let descriptionText = sheet.staticTexts["Token description field"]
192+
expectation(for: NSPredicate(format: "value CONTAINS 'not active'"), evaluatedWith: descriptionText)
193+
expectation(for: NSPredicate(format: "value CONTAINS 'missing read_api scope'"), evaluatedWith: descriptionText)
194+
waitForExpectations(timeout: 5)
195+
}
196+
197+
138198
//
139199
// func testAddsGitHubPrivatePipeline() throws {
140200
// webapp.router.get("/users/erikdoe") { _ in

CCMenuUITests/TestHelper.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ class TestHelper {
2222
"-GitHubAPIBaseURL", "http://localhost:8086",
2323
"-GitHubToken", token ?? "",
2424
"-GitLabBaseURL", "http://localhost:8086",
25-
"-GitLabToken", token ?? ""
25+
"-GitLabAPIBaseURL", "http://localhost:8086/api/v4",
26+
"-GitLabToken", token ?? "",
27+
"-ThreadPerformanceCheckerReporterEnabled NO"
2628
]
2729
app.launch()
2830
return app

0 commit comments

Comments
 (0)