Files
yattee/Yattee/Views/Settings/InstanceLoginView.swift

288 lines
11 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
/// The identifier to start the form with, when there is one to start from.
///
/// Signing in again is almost always the same person proving themselves
/// once more, and the identifier they used is already on the device — beside
/// the credential, in the Keychain. Retyping it is work the app can do.
/// A new login has nobody to name and passes nothing.
var initialUsername: String?
/// 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
.onAppear {
// Only ever fills an untouched field: the sheet's body runs again
// for every keystroke, and a prefill that reasserted itself would
// fight the user editing it.
if let initialUsername, !initialUsername.isEmpty, username.isEmpty {
username = initialUsername
}
}
}
// MARK: - Computed Properties
/// What the first field is called on this backend.
///
/// Named per backend rather than genericised: Invidious signs in by email,
/// and calling that "Username" is how a user ends up typing the wrong thing.
/// Every case is spelled out so a backend that gains accounts —
/// `AccountCapabilities.supportsAccounts` is what decides that — arrives
/// here rather than falling through to somebody else's wording.
private var usernameFieldLabel: String {
switch instance.type {
case .piped:
return String(localized: "login.username")
case .invidious:
return String(localized: "login.email")
case .yatteeServer:
return String(localized: "login.username")
case .peertube:
return String(localized: "login.username")
}
}
/// What this form is signing in to, said in the user's terms.
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)")
case .yatteeServer:
return String(localized: "login.footer.yatteeServerAccount \(instance.displayName)")
case .peertube:
// No account concept at all, so no sign-in is offered — and if one
// ever were, an empty footer explains nothing.
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)
}