feat(auth): add api token login support

This commit is contained in:
PandaDEV 2026-03-08 00:48:20 +01:00
parent 9fccd2812f
commit d60c392489
No known key found for this signature in database
2 changed files with 121 additions and 50 deletions

View file

@ -19,6 +19,29 @@ class AuthenticationService {
try await storeCredentials(result: result, password: password)
}
func loginWithToken(serverURL: String, token: String, allowSelfSigned: Bool = false) async throws {
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
let tokenClient = ForgejoClient(
serverURL: normalizedURL,
username: "",
token: token,
allowSelfSignedCertificates: allowSelfSigned,
)
let user = try await tokenClient.fetchCurrentUser()
let tokenClientWithUsername = ForgejoClient(
serverURL: normalizedURL,
username: user.login,
token: token,
allowSelfSignedCertificates: allowSelfSigned,
)
try await KeychainManager.shared.saveToken(
token, for: normalizedURL, username: user.login,
)
client = tokenClientWithUsername
currentUser = user
isAuthenticated = true
}
func loginWithOTP(
serverURL: String, username: String, password: String, otp: String,
allowSelfSigned: Bool = false,

View file

@ -10,11 +10,16 @@ struct InstanceFormView: View {
var id: String {
switch self {
case .add: "add"
case let .edit(instance): instance.serverURL + instance.username
case .edit(let instance): instance.serverURL + instance.username
}
}
}
enum AuthMode: String, CaseIterable {
case credentials = "Credentials"
case token = "API Token"
}
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance]
@ -29,10 +34,12 @@ struct InstanceFormView: View {
@AppStorage("dev_allowSelfSigned") private var devAllowSelfSigned = false
#endif
@State private var authMode: AuthMode = .credentials
@State private var name = ""
@State private var serverURL = ""
@State private var username = ""
@State private var password = ""
@State private var apiToken = ""
@State private var allowSelfSignedCertificates = false
@State private var isDefault = false
@State private var otpCode = ""
@ -49,7 +56,7 @@ struct InstanceFormView: View {
var body: some View {
NavigationStack {
Form {
Section {
Section("Forgejo Instance") {
TextField("Name (optional)", text: $name)
.autocorrectionDisabled()
.accessibilityIdentifier("instance-name-field")
@ -60,36 +67,63 @@ struct InstanceFormView: View {
.autocorrectionDisabled()
.keyboardType(.URL)
.accessibilityIdentifier("login-serverURL-field")
}
TextField("Username", text: $username)
.textContentType(.username)
.autocapitalization(.none)
.autocorrectionDisabled()
.accessibilityIdentifier("login-username-field")
Section {
if authMode == .credentials {
TextField("Username", text: $username)
.textContentType(.username)
.autocapitalization(.none)
.autocorrectionDisabled()
.accessibilityIdentifier("login-username-field")
SecureField("Password", text: $password)
.textContentType(.password)
.accessibilityIdentifier("login-password-field")
SecureField("Password", text: $password)
.textContentType(.password)
.accessibilityIdentifier("login-password-field")
if needsOTP {
TextField("One-Time Code", text: $otpCode)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.accessibilityIdentifier("login-otp-field")
if needsOTP {
TextField("One-Time Code", text: $otpCode)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.accessibilityIdentifier("login-otp-field")
}
} else {
SecureField("API Token", text: $apiToken)
.textContentType(.password)
.autocorrectionDisabled()
.accessibilityIdentifier("login-token-field")
}
} header: {
Text("Forgejo Instance")
} footer: {
if needsOTP {
Text("Enter the 6-digit code from your authenticator app.")
} else {
switch mode {
case .add:
Text("Enter your Forgejo server URL (e.g., forgejo.example.com)")
case .edit:
Text("Leave password blank to keep the existing one.")
VStack(alignment: .leading, spacing: 8) {
Text("Credentials")
if isAddMode {
Picker("Login Method", selection: $authMode) {
ForEach(AuthMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
}
.textCase(nil)
.padding(.bottom, 4)
} footer: {
if authMode == .token {
Text(
"""
Generate a token under Settings Applications with the following scopes:
read/write:user
read/write:repository
read/write:issue
read/write:notification
read:organization
"""
)
} else if needsOTP {
Text("Enter the 6-digit code from your authenticator app.")
} else if case .edit = mode {
Text("Leave password blank to keep the existing one.")
}
}
Section {
@ -122,8 +156,11 @@ struct InstanceFormView: View {
}
.buttonStyle(.borderedProminent)
.disabled(
isLoading || serverURL.isEmpty || username.isEmpty
|| (isAddMode && password.isEmpty) || (needsOTP && otpCode.isEmpty),
isLoading || serverURL.isEmpty
|| (authMode == .credentials
&& (username.isEmpty || (isAddMode && password.isEmpty)
|| (needsOTP && otpCode.isEmpty)))
|| (authMode == .token && apiToken.isEmpty),
)
.listRowBackground(Color.clear)
.accessibilityIdentifier("login-button")
@ -157,7 +194,7 @@ struct InstanceFormView: View {
if !devPassword.isEmpty { password = devPassword }
allowSelfSignedCertificates = devAllowSelfSigned
#endif
case let .edit(instance):
case .edit(let instance):
name = instance.name
serverURL = instance.serverURL
username = instance.username
@ -167,21 +204,30 @@ struct InstanceFormView: View {
}
private func performLogin() async throws {
if needsOTP, !otpCode.isEmpty {
try await authService.loginWithOTP(
switch authMode {
case .token:
try await authService.loginWithToken(
serverURL: serverURL,
username: username,
password: password,
otp: otpCode,
allowSelfSigned: allowSelfSignedCertificates,
)
} else {
try await authService.login(
serverURL: serverURL,
username: username,
password: password,
token: apiToken,
allowSelfSigned: allowSelfSignedCertificates,
)
case .credentials:
if needsOTP, !otpCode.isEmpty {
try await authService.loginWithOTP(
serverURL: serverURL,
username: username,
password: password,
otp: otpCode,
allowSelfSigned: allowSelfSignedCertificates,
)
} else {
try await authService.login(
serverURL: serverURL,
username: username,
password: password,
allowSelfSigned: allowSelfSignedCertificates,
)
}
}
}
@ -203,9 +249,11 @@ struct InstanceFormView: View {
}
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
let resolvedUsername =
authMode == .token ? (authService.currentUser?.login ?? username) : username
let instance = ForgejoInstance(
serverURL: normalizedURL,
username: username,
username: resolvedUsername,
name: name,
isDefault: isDefault,
allowSelfSignedCertificates: allowSelfSignedCertificates,
@ -218,7 +266,7 @@ struct InstanceFormView: View {
}
authService.currentInstance = instance
case let .edit(instance):
case .edit(let instance):
if !password.isEmpty {
try await performLogin()
}
@ -257,13 +305,13 @@ struct InstanceFormView: View {
}
#if DEBUG
#Preview("Add") {
InstanceFormView(authService: .previewDefault, mode: .add)
.modelContainer(for: ForgejoInstance.self, inMemory: true)
}
#Preview("Add") {
InstanceFormView(authService: .previewDefault, mode: .add)
.modelContainer(for: ForgejoInstance.self, inMemory: true)
}
#Preview("Edit") {
InstanceFormView(authService: .previewDefault, mode: .edit(.preview))
.modelContainer(for: ForgejoInstance.self, inMemory: true)
}
#Preview("Edit") {
InstanceFormView(authService: .previewDefault, mode: .edit(.preview))
.modelContainer(for: ForgejoInstance.self, inMemory: true)
}
#endif