mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 00:47:17 +00:00
An account whose source is gone was listed nowhere: `orphanedAccounts` had no readers, and the credential behind it could not be reached at all — `AppEnvironment.accountCredentialsManager(for:)` resolves the store from the account's instance, and an orphan has none. The new accounts screen lists them and removes them by account ID, asking every account-keyed store because the backend is no longer knowable. The pre-revamp instance-keyed item is deliberately left behind: identifying it needs the instance too. Deleting a source has always signed out and removed the accounts on it. Both confirmations now say so, and name them. The sign-in that files a credential against an account is now one implementation instead of a private method on the source editor, so the accounts screen and the new optional sign-in step after adding a source attach a login exactly as the editor does. Extracted verbatim — the identity-scope guard, the account re-resolve, the write-then-read-back and the rollback are what keep a half-written login from looking real. Signing out from the per-instance section now asks first, and both sign-out paths run one cascade rather than two copies of it.
260 lines
9.6 KiB
Swift
260 lines
9.6 KiB
Swift
//
|
|
// InstanceLoginView.swift
|
|
// Yattee
|
|
//
|
|
// Shared login sheet for Invidious and Piped accounts.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct InstanceLoginView: View {
|
|
let instance: Instance
|
|
|
|
/// Whether signing in is optional here.
|
|
///
|
|
/// Set when the sheet follows an action that already succeeded — adding a
|
|
/// source, which is saved before this appears. The way out is then "Skip"
|
|
/// rather than "Cancel", and it is offered as a button in the form as well:
|
|
/// tvOS has no cancellation affordance at all, so an optional step without
|
|
/// one would be a dead end there.
|
|
var isOptionalStep: Bool = false
|
|
|
|
/// Receives the credential **and** the identifier the user signed in with,
|
|
/// and reports whether it was stored.
|
|
///
|
|
/// The identifier is handed back rather than stored here because it belongs
|
|
/// beside the credential in the Keychain, not in synced account metadata —
|
|
/// so only the caller, which knows which account this login is for, can
|
|
/// file it correctly.
|
|
///
|
|
/// The return value matters because signing in successfully is not the same
|
|
/// as having somewhere to keep the result: dismissing on a Keychain failure
|
|
/// would throw away what the user typed and make them do it all again.
|
|
let onLoginSuccess: (_ credential: String, _ username: String) -> Bool
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@State private var username = ""
|
|
@State private var password = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
TVSidebarDetailContainer(systemImage: "person.badge.key", title: String(localized: "login.title")) {
|
|
formContent
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.black.ignoresSafeArea())
|
|
.accessibilityIdentifier("instance.login.view")
|
|
#else
|
|
NavigationStack {
|
|
formContent
|
|
.navigationTitle(String(localized: "login.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: isOptionalStep ? "common.skip" : "common.cancel")) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("instance.login.view")
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 450, minHeight: 350)
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
private var formContent: some View {
|
|
Form {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: usernameFieldLabel, text: $username)
|
|
TVSettingsTextField(title: String(localized: "login.password"), text: $password, isSecure: true)
|
|
#else
|
|
TextField(usernameFieldLabel, text: $username)
|
|
.textContentType(.username)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
.accessibilityIdentifier("instance.login.usernameField")
|
|
|
|
SecureField(String(localized: "login.password"), text: $password)
|
|
.textContentType(.password)
|
|
.accessibilityIdentifier("instance.login.passwordField")
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "login.header.credentials"))
|
|
} footer: {
|
|
Text(footerText)
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Section {
|
|
Label(error, systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(.red)
|
|
.accessibilityIdentifier("instance.login.error")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
login()
|
|
} label: {
|
|
if isLoading {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "login.loggingIn"))
|
|
}
|
|
} else {
|
|
Text(String(localized: "login.signIn"))
|
|
}
|
|
}
|
|
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
|
.accessibilityIdentifier("instance.login.submitButton")
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
|
|
if isOptionalStep {
|
|
Section {
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Text(String(localized: "common.skip"))
|
|
}
|
|
.accessibilityIdentifier("instance.login.skipButton")
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
} footer: {
|
|
Text(String(localized: "login.footer.optionalStep"))
|
|
}
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Returns the appropriate label for the username field based on instance type.
|
|
private var usernameFieldLabel: String {
|
|
switch instance.type {
|
|
case .piped:
|
|
return String(localized: "login.username")
|
|
case .invidious:
|
|
return String(localized: "login.email")
|
|
default:
|
|
return String(localized: "login.username")
|
|
}
|
|
}
|
|
|
|
/// Returns the appropriate footer text based on instance type.
|
|
private var footerText: String {
|
|
switch instance.type {
|
|
case .piped:
|
|
return String(localized: "login.footer.pipedAccount \(instance.displayName)")
|
|
case .invidious:
|
|
return String(localized: "login.footer.invidiousAccount \(instance.displayName)")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func login() {
|
|
guard let appEnvironment else { return }
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
// Which Apple identity this login is being made under. Credentials are
|
|
// filed per identity, and the round trip below leaves room for the
|
|
// scope to move — storing afterwards would file the account obtained
|
|
// here in somebody else's namespace, and sync it to their Keychain.
|
|
let scopeAtStart = appEnvironment.credentialIdentityScope.current
|
|
|
|
Task {
|
|
do {
|
|
let credential = try await performLogin(appEnvironment: appEnvironment)
|
|
|
|
await MainActor.run {
|
|
// Only a move between two *named* identities is a mismatch.
|
|
// Unresolved at either end is left to `storeLogin`, which
|
|
// already refuses to write under a scope it cannot name —
|
|
// rejecting here as well would turn an ordinary sign-in
|
|
// that began before iCloud settled, or a tokenless scope
|
|
// revalidating on activation, into a failed login.
|
|
if let scopeAtStart,
|
|
let scopeNow = appEnvironment.credentialIdentityScope.current,
|
|
scopeNow != scopeAtStart {
|
|
errorMessage = String(localized: "accounts.signIn.identityChanged")
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
guard onLoginSuccess(credential, username) else {
|
|
// The failure is reported by a toast from the storing
|
|
// side; staying open keeps the form filled in so the
|
|
// user can retry once it can succeed.
|
|
isLoading = false
|
|
return
|
|
}
|
|
dismiss()
|
|
}
|
|
} catch APIError.unauthorized {
|
|
await MainActor.run {
|
|
errorMessage = String(localized: "login.error.invalidCredentials")
|
|
isLoading = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Performs the login based on instance type.
|
|
/// - Returns: The credential (SID for Invidious, token for Piped)
|
|
private func performLogin(appEnvironment: AppEnvironment) async throws -> String {
|
|
// The service owns the per-instance configuration a login needs — the
|
|
// reverse-proxy basic auth (without which the POST 401s before reaching
|
|
// the upstream login endpoint), the certificate policy, and the
|
|
// User-Agent instances may insist on.
|
|
try await appEnvironment.contentService.login(
|
|
username: username,
|
|
password: password,
|
|
instance: instance
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Invidious") {
|
|
InstanceLoginView(
|
|
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
|
|
) { _, _ in true }
|
|
.appEnvironment(.preview)
|
|
}
|
|
|
|
#Preview("Piped") {
|
|
InstanceLoginView(
|
|
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
|
|
) { _, _ in true }
|
|
.appEnvironment(.preview)
|
|
}
|