// // AdminPasswordView.swift // FTStore Admin // // Change the password on the operator account this app is signed in as. // // **It does not ask for the current one, and that is a decision rather than an // oversight.** The session in the Keychain is already the credential for // everything else here: it authorises emptying the library, deleting every // account and every board, and minting invitations. Refusing it a password // change would not protect anything somebody holding it would care about, and // the app's own lock is Face ID, so getting this far already meant getting past // that. // // What asking for the old password would buy is protection against permanent // lockout, and that is bought a different way: the change signs every other // browser and device out, so a hostile one is noticed immediately rather than // quietly. What it would cost is the only recovery path a self-hosted server // has. There is no email here to send a link to, and an operator who has // forgotten their password and is still signed in on this app would otherwise // have nothing left but shell access to the database. // import SwiftUI struct AdminPasswordView: View { @EnvironmentObject private var session: AdminSession @Environment(\.dismiss) private var dismiss @State private var _password = "" @State private var _repeat = "" @State private var _busy = false @State private var _banner: String? @State private var _done = false /// The floor the server keeps, restated here so a refusal arrives before the /// round trip rather than after it. private static let _minimum = 6 private var _tooShort: Bool { !_password.isEmpty && _password.count < Self._minimum } private var _mismatched: Bool { !_repeat.isEmpty && _password != _repeat } private var _canSend: Bool { _password.count >= Self._minimum && _password == _repeat } var body: some View { List { if let banner = _banner { Section { Text(banner) .font(.footnote) // Both branches spelled `Color`: `.secondary` on its own is a // `HierarchicalShapeStyle` and `.red` is a `Color`, and a // ternary between the two has no type they share. .foregroundStyle(_done ? Color.secondary : Color.red) } } Section { SecureField("New password", text: $_password) .textContentType(.newPassword) SecureField("Repeat it", text: $_repeat) .textContentType(.newPassword) if _tooShort { Text(verbatim: .localized("Use at least %lld characters.", Self._minimum)) .font(.caption) .foregroundStyle(.red) } else if _mismatched { Text("Those two don't match.") .font(.caption) .foregroundStyle(.red) } } header: { Text(verbatim: session.user?.username ?? .localized("Your account")) } footer: { Text("The current password is not asked for. Being signed in here is what proves this is your account, which is the same thing every other screen in FTStore Admin already trusts.") } Section { Button { _change() } label: { HStack { Label("Change password", systemImage: "key") if _busy { Spacer() ProgressView() } } } .disabled(_busy || !_canSend) } footer: { Text("Every other browser and phone signed in as this account is signed out. This one stays, so you are not logged out for fixing your own password.") } } .navigationTitle("Password") .navigationBarTitleDisplayMode(.inline) } private func _change() { let next = _password _busy = true _banner = nil Task { @MainActor in do { let ack = try await session.act( .put, "/api/users/password", body: AdminRequest.OperatorPassword(password: next) ) _banner = ack.summary _done = true _password = "" _repeat = "" } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription _done = false } _busy = false } } }