A banner that greys out on success and turns red on failure was written as one ternary between two style shorthands, and those come from different families: the muted one is a hierarchical style, the red one is a colour. The compiler anchors the whole expression on whichever branch it reads first, so the same line with the branches the other way round builds fine, which is why three like it already ship. Both branches now name the colour type outright, and the order stops mattering. The check that would have caught it is the one already written down for button roles, widened to style shorthands and to the order that fails.
131 lines
3.9 KiB
Swift
131 lines
3.9 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|