There was no way to change an operator password anywhere, and no way at all to help somebody who had forgotten theirs. On a server with no mail to send a link through, that left the database as the only answer. An operator can now change their own, from the dashboard and from the account row on the console's home screen. It does not ask for the current one, and that is a decision rather than an oversight: the session already authorises emptying the library, deleting every account and every board, and minting invitations, so refusing it a password change protects nothing that somebody holding it would want, while asking would cost the only recovery this thing has. What replaces the check is that the change signs every other browser and device out and is logged, so a hostile one is visible the moment it happens. The session doing the changing is kept, or the screen would log you out for fixing your password. For everybody else there is a one-time link. An operator makes one from the person's row and hands it over however they already talk to them; opening it asks for a new password and nothing else. It is shaped exactly like an invitation, and for the same reasons: long, stored only as a digest, shown once in the response that makes it, spent once, and dead after a day. Making a second kills the first, because a replaced link left alive is a loose end nobody is tracking, and redeeming one signs every device out, since somebody resetting a password is very often doing it because somebody else has the account. The front door no longer talks about whoever runs the place. It says registrations are closed and to ask for an invitation link, which is the thing the reader can actually act on. And a pass over where things are configured. Two switches both meant "can new people sign up" for two completely different sets of people and neither said which, so they are named after their populations now and each points at the other. On the console, the buttons that start a scan moved out of the group that records what has already happened and in with the sources they act on, and the group that held both people and settings is two groups, because who may use this server and how it is set up are different questions and neither should be read past to reach the other.
359 lines
10 KiB
Swift
359 lines
10 KiB
Swift
//
|
|
// AdminRootView.swift
|
|
// FTStore Admin
|
|
//
|
|
// The app's front door and FTStore Admin's table of contents. Everything below
|
|
// it assumes an authenticated session — this view is what guarantees that.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AdminRootView: View {
|
|
@EnvironmentObject private var session: AdminSession
|
|
|
|
var body: some View {
|
|
Group {
|
|
if session.isRestoring {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Restoring session…")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if session.user == nil {
|
|
AdminLoginView()
|
|
} else if session.isLocked {
|
|
AdminLockedView()
|
|
} else {
|
|
AdminHomeView()
|
|
}
|
|
}
|
|
.tint(AdminTheme.brand)
|
|
.task {
|
|
guard session.isRestoring else { return }
|
|
await session.restore()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Home
|
|
|
|
struct AdminHomeView: View {
|
|
@EnvironmentObject private var session: AdminSession
|
|
@Environment(\.scenePhase) private var _scenePhase
|
|
|
|
@AppStorage(AdminAlertWatcher.storageKey) private var _alertStorage = false
|
|
@AppStorage(AdminAlertWatcher.feedbackKey) private var _alertFeedback = false
|
|
@AppStorage(AdminAlertWatcher.requestsKey) private var _alertRequests = false
|
|
@AppStorage(AdminAlertWatcher.accessKey) private var _alertAccess = false
|
|
|
|
@StateObject private var _status = AdminLoader<AdminEnvelope<AdminDashboardStatus>>()
|
|
@State private var _systemBusy = false
|
|
@State private var _signingOut = false
|
|
|
|
private var _data: AdminDashboardStatus? { _status.value?.data }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
_accountSection
|
|
_systemSection
|
|
_summarySection
|
|
_manageSection
|
|
_alertsSection
|
|
_securitySection
|
|
_aboutSection
|
|
}
|
|
.navigationTitle("FTStore Admin")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.refreshable { await _reload() }
|
|
.task { await _reload() }
|
|
.onChange(of: _scenePhase) { phase in
|
|
guard phase == .active else { return }
|
|
Task { await _reload() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Sections
|
|
|
|
private var _accountSection: some View {
|
|
Section {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: session.isAdmin ? "person.badge.key.fill" : "person.crop.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(AdminTheme.brand)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(session.user?.username ?? "—")
|
|
.font(.subheadline.weight(.semibold))
|
|
Text("\(String.localized(session.isAdmin ? "Admin" : "User")) · \(session.serverDescription)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
if let warning = session.connectionWarning {
|
|
Label(warning, systemImage: "wifi.exclamationmark")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
|
|
// Here rather than buried in Setup: forgetting the dashboard password
|
|
// while still signed in on this app is exactly the situation this
|
|
// screen exists for, and somebody in it is not going to go looking
|
|
// three levels down for it.
|
|
NavigationLink {
|
|
AdminPasswordView()
|
|
.environmentObject(session)
|
|
} label: {
|
|
Label("Change password", systemImage: "key")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _systemSection: some View {
|
|
Section {
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(_data?.isRunning == true ? Color.green : Color.red)
|
|
.frame(width: 9, height: 9)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(_data?.isRunning == true ? "System Online" : "System Offline")
|
|
.font(.subheadline.weight(.medium))
|
|
if let uptime = _data?.uptime {
|
|
Text("Up \(AdminFormat.duration(uptime))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
if _systemBusy {
|
|
ProgressView()
|
|
} else if let running = _data?.isRunning {
|
|
Button(running ? "Stop" : "Start") {
|
|
_toggleSystem(to: !running)
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.tint(running ? .red : .green)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Worker")
|
|
} footer: {
|
|
Text("Stopping the worker halts scanning, downloads and feed generation until it is started again.")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var _summarySection: some View {
|
|
Section {
|
|
if let data = _data {
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
|
|
spacing: 10
|
|
) {
|
|
AdminKPITile(
|
|
title: "Builds",
|
|
value: AdminFormat.count(data.totalIpas),
|
|
detail: "in the feed",
|
|
icon: "shippingbox.fill"
|
|
)
|
|
AdminKPITile(
|
|
title: "Downloads",
|
|
value: AdminFormat.count(data.totalDownloads),
|
|
detail: "all time",
|
|
icon: "arrow.down.circle.fill"
|
|
)
|
|
AdminKPITile(
|
|
title: "Storage",
|
|
value: AdminFormat.bytes(data.storageUsed),
|
|
detail: data.storageIsCapped
|
|
? "of \(AdminFormat.bytes(data.storageLimitBytes))"
|
|
: "no limit",
|
|
icon: "internaldrive.fill",
|
|
tint: data.storageOverLimit ? .red : AdminTheme.brand
|
|
)
|
|
AdminKPITile(
|
|
title: "Queue",
|
|
value: AdminFormat.count(data.queueDepth),
|
|
detail: "pending",
|
|
icon: "list.bullet.rectangle.fill",
|
|
tint: data.queueDepth > 0 ? .orange : AdminTheme.brand
|
|
)
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
|
|
.listRowBackground(Color.clear)
|
|
} else if let error = _status.errorMessage {
|
|
AdminErrorRow(message: error) { Task { await _reload() } }
|
|
} else {
|
|
AdminLoadingRow()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _manageSection: some View {
|
|
Section("Manage") {
|
|
NavigationLink {
|
|
AdminDashboardView()
|
|
} label: {
|
|
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
|
}
|
|
NavigationLink {
|
|
AdminLibraryView()
|
|
} label: {
|
|
Label("Library", systemImage: "shippingbox")
|
|
}
|
|
NavigationLink {
|
|
AdminQueueView()
|
|
} label: {
|
|
Label("Queue", systemImage: "list.bullet.rectangle")
|
|
}
|
|
NavigationLink {
|
|
AdminTweakRequestsView()
|
|
} label: {
|
|
Label("Tweak Requests", systemImage: "tray.and.arrow.down")
|
|
}
|
|
NavigationLink {
|
|
AdminFeedbackView()
|
|
} label: {
|
|
Label("Feedback", systemImage: "bubble.left.and.bubble.right")
|
|
}
|
|
// Next to Feedback, and its own row rather than a filter inside it:
|
|
// both are things people wrote in the open, but a report is about
|
|
// FTStore and a review is about one app in it, and the two are
|
|
// answered from different screens.
|
|
NavigationLink {
|
|
AdminReviewsView()
|
|
} label: {
|
|
Label("Reviews", systemImage: "star.bubble")
|
|
}
|
|
// The people who write all of the above: what they wrote, and
|
|
// whether they still may. Deliberately not the same list as Users
|
|
// under Setup & Diagnostics, which is who may sign in and run the
|
|
// server — a delete button means something very different on each,
|
|
// which is why they are two screens and now two names.
|
|
NavigationLink {
|
|
AdminFeedbackUsersView()
|
|
} label: {
|
|
Label("Users", systemImage: "person.2.circle")
|
|
}
|
|
// Everything else is one row down. The five above are what an
|
|
// operator opens on an ordinary day — what arrived, what is
|
|
// downloading, what people are asking for. The rest is how the
|
|
// server is set up and what it has been doing, which is looked at
|
|
// when something needs changing or explaining, not daily.
|
|
NavigationLink {
|
|
AdminMoreView()
|
|
} label: {
|
|
Label("Setup & Diagnostics", systemImage: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What FTStore Admin is allowed to interrupt somebody about.
|
|
///
|
|
/// Four switches rather than one. An operator who wants to know the disk is
|
|
/// filling up does not necessarily want a banner for every feature request,
|
|
/// and a single toggle would make them take both or neither.
|
|
///
|
|
/// Permission is asked for when the first one goes on, not at launch: an app
|
|
/// that asks before it has anything to say is one people say no to.
|
|
private var _alertsSection: some View {
|
|
Section {
|
|
Toggle(isOn: $_alertStorage) {
|
|
Label("Storage warnings", systemImage: "externaldrive.badge.exclamationmark")
|
|
}
|
|
Toggle(isOn: $_alertFeedback) {
|
|
Label("New feedback", systemImage: "bubble.left.and.bubble.right")
|
|
}
|
|
Toggle(isOn: $_alertRequests) {
|
|
Label("New tweak requests", systemImage: "tray.and.arrow.down")
|
|
}
|
|
Toggle(isOn: $_alertAccess) {
|
|
Label("New device or country", systemImage: "lock.shield")
|
|
}
|
|
} header: {
|
|
Text("Notifications")
|
|
} footer: {
|
|
Text("Storage warns once at 80% of the configured limit. The device alert only has anything to say while private mode is on. These are local notifications, so FTStore Admin can only post them while it is open or while iOS grants it a background refresh, it is a nudge rather than a pager.")
|
|
}
|
|
.onChange(of: _alertStorage) { on in _armed(on) }
|
|
.onChange(of: _alertFeedback) { on in _armed(on) }
|
|
.onChange(of: _alertRequests) { on in _armed(on) }
|
|
.onChange(of: _alertAccess) { on in _armed(on) }
|
|
}
|
|
|
|
private func _armed(_ on: Bool) {
|
|
guard on else { return }
|
|
AdminAlertWatcher.requestAuthorization()
|
|
Task { await AdminAlertWatcher.shared.check(session: session) }
|
|
}
|
|
|
|
private var _securitySection: some View {
|
|
Section {
|
|
Toggle(isOn: $session.requireBiometrics) {
|
|
Label("Require Face ID / Passcode", systemImage: "faceid")
|
|
}
|
|
|
|
if session.requireBiometrics {
|
|
Button {
|
|
session.lock()
|
|
} label: {
|
|
Label("Lock FTStore Admin", systemImage: "lock")
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
_signingOut = true
|
|
Task {
|
|
await session.signOut()
|
|
_signingOut = false
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
|
if _signingOut {
|
|
Spacer()
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(_signingOut)
|
|
} header: {
|
|
Text("Security")
|
|
} footer: {
|
|
Text("Signing out revokes this device's session on the server. Installing and updating FTStore is FTStore Manager's job and never needs an account.")
|
|
}
|
|
}
|
|
|
|
private var _aboutSection: some View {
|
|
Section {
|
|
LabeledContent("Version", value: AdminConfig.appVersion)
|
|
LabeledContent("Server", value: session.serverDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
private func _reload() async {
|
|
await _status.load {
|
|
try await session.get("/api/dashboard/status", as: AdminEnvelope<AdminDashboardStatus>.self)
|
|
}
|
|
}
|
|
|
|
private func _toggleSystem(to enabled: Bool) {
|
|
_systemBusy = true
|
|
Task { @MainActor in
|
|
_ = try? await session.send(
|
|
.post,
|
|
"/api/system",
|
|
body: AdminRequest.Enabled(enabled: enabled),
|
|
as: AdminSystemState.self
|
|
)
|
|
await _reload()
|
|
_systemBusy = false
|
|
}
|
|
}
|
|
}
|