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.
539 lines
16 KiB
Swift
539 lines
16 KiB
Swift
//
|
|
// AdminFeedbackAuthorView.swift
|
|
// FTStore Admin
|
|
//
|
|
// Everything one person has written in FTStore. Mirrors /admin/users/<id>.
|
|
//
|
|
// The list this is pushed from answers "who is here?". This answers "what did
|
|
// they do?", which is the question actually being asked when an operator opens
|
|
// somebody's row: muting is a judgement about a pattern of posting, and making
|
|
// it without being able to see the posting was the gap.
|
|
//
|
|
// Everything here is on the public board already, so this is a rearrangement of
|
|
// what anyone can read rather than a new disclosure. What it adds is the join —
|
|
// these posts are all one install, which the board itself only says through a
|
|
// handle that can change.
|
|
//
|
|
// It shows what the app cannot: a report taken off the board, a review taken
|
|
// out of the store, a reply its author took back. That is what an operator view
|
|
// is for.
|
|
//
|
|
// The actions are ordered by how much each takes back. Muting stops them
|
|
// writing and touches nothing they wrote; signing out cuts off whoever is
|
|
// holding a session, which is the half of a stolen account a mute does not
|
|
// solve; renaming is for when the name is itself the problem; deleting closes
|
|
// the account and leaves everything written under it exactly where it is.
|
|
// Removing the posts too is done from each post, where whoever is doing it can
|
|
// see what they are removing.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct AdminFeedbackAuthorView: View {
|
|
@EnvironmentObject private var session: AdminSession
|
|
|
|
let installId: String
|
|
/// Shown while the detail loads, so the screen has a title straight away.
|
|
let handle: String
|
|
|
|
@StateObject private var _detail = AdminLoader<AdminFeedbackAuthorDetail>()
|
|
|
|
@State private var _busy = false
|
|
@State private var _banner: String?
|
|
@State private var _confirmingMute = false
|
|
@State private var _confirmingSignOut = false
|
|
@State private var _confirmingDelete = false
|
|
@State private var _renaming = false
|
|
@State private var _nextName = ""
|
|
/// The reset link, held only until this screen is left.
|
|
///
|
|
/// The server shows it once and stores a digest, so there is nowhere to fetch
|
|
/// it back from. Kept on screen rather than announced in a banner, so there
|
|
/// is time to copy it.
|
|
@State private var _resetURL: String?
|
|
|
|
private var _payload: AdminFeedbackAuthorDetail.Payload? { _detail.value?.data }
|
|
private var _author: AdminFeedbackAuthorRow? { _payload?.author }
|
|
private var _tickets: [AdminFeedbackAuthorDetail.Ticket] { _payload?.tickets ?? [] }
|
|
private var _replies: [AdminFeedbackAuthorDetail.Reply] { _payload?.comments ?? [] }
|
|
private var _requestReplies: [AdminFeedbackAuthorDetail.RequestReply] {
|
|
_payload?.requestComments ?? []
|
|
}
|
|
private var _reviews: [AdminFeedbackAuthorDetail.Review] { _payload?.reviews ?? [] }
|
|
|
|
var body: some View {
|
|
List {
|
|
if let banner = _banner {
|
|
Section {
|
|
Text(banner).font(.footnote).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if _detail.isLoading && !_detail.hasLoaded {
|
|
AdminLoadingRow()
|
|
} else if let error = _detail.errorMessage, !_detail.hasLoaded {
|
|
AdminErrorRow(message: error) { Task { await _reload() } }
|
|
} else if let author = _author {
|
|
_identitySection(author)
|
|
_moderationSection(author)
|
|
// Reviews first: they are what an operator most often opens this
|
|
// screen about now that the store has them, and they are the one
|
|
// list that carries a number as well as words.
|
|
_reviewsSection
|
|
_ticketsSection
|
|
_repliesSection
|
|
_requestRepliesSection
|
|
}
|
|
}
|
|
.navigationTitle(_author?.handle ?? handle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.refreshable { await _reload() }
|
|
.task { await _reload() }
|
|
.alert("Stop this account from posting?", isPresented: $_confirmingMute) {
|
|
Button("Mute", role: .destructive) { _setBlocked(true) }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Everything already written stays up. They can still read the store and the boards, they just can't write anything new.")
|
|
}
|
|
.alert("Sign every device out?", isPresented: $_confirmingSignOut) {
|
|
Button("Sign out everywhere", role: .destructive) { _signOutEverywhere() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Every phone signed in to this account is signed out. Nothing posted is touched, and they can sign back in with their password.")
|
|
}
|
|
.alert("Delete this account?", isPresented: $_confirmingDelete) {
|
|
Button("Delete the account", role: .destructive) { _deleteAccount() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("The name is released, the password, picture and backup go, and every device is signed out. What they wrote stays up, posted from now on as their anonymous handle. Muting them is untouched. Deleting the posts too is done from each post.")
|
|
}
|
|
.alert("Rename", isPresented: $_renaming) {
|
|
TextField("New handle", text: $_nextName)
|
|
Button("Save") { _rename() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("A rename follows them everywhere, including the @handle in posts other people already wrote about them. Leaving it empty releases the name for somebody else to take.")
|
|
}
|
|
}
|
|
|
|
// MARK: Who
|
|
|
|
@ViewBuilder
|
|
private func _identitySection(_ author: AdminFeedbackAuthorRow) -> some View {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(verbatim: author.handle)
|
|
.font(.headline)
|
|
if author.registered {
|
|
Image(systemName: "lock.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
if author.isDeleted {
|
|
Label("Deleted", systemImage: "person.crop.circle.badge.xmark")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
if author.blocked {
|
|
Label("Muted", systemImage: "hand.raised.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
// The id is the only stable identity and it is what the logs
|
|
// record, so it is on screen rather than behind the handle.
|
|
Text(verbatim: author.installId)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.tertiary)
|
|
|
|
if let note = author.blockedNote, !note.isEmpty {
|
|
Text(verbatim: note)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
|
|
} footer: {
|
|
Text("Everything below includes what the app cannot see: the posts taken down, the reviews taken out of the store, and the ones their author withdrew.")
|
|
}
|
|
}
|
|
|
|
// MARK: Moderation
|
|
|
|
@ViewBuilder
|
|
private func _moderationSection(_ author: AdminFeedbackAuthorRow) -> some View {
|
|
Section {
|
|
// Spelled out rather than inlined: Swift will not infer `ButtonRole?`
|
|
// from a ternary with `nil` on one side.
|
|
let role: ButtonRole? = author.blocked ? nil : .destructive
|
|
Button(role: role) {
|
|
if author.blocked { _setBlocked(false) } else { _confirmingMute = true }
|
|
} label: {
|
|
Label(
|
|
author.blocked ? "Let this account post again" : "Mute this account",
|
|
systemImage: author.blocked ? "checkmark.shield" : "hand.raised"
|
|
)
|
|
}
|
|
.disabled(_busy)
|
|
|
|
Button {
|
|
_nextName = author.displayName ?? ""
|
|
_renaming = true
|
|
} label: {
|
|
Label("Rename", systemImage: "pencil")
|
|
}
|
|
.disabled(_busy)
|
|
|
|
Button {
|
|
_makeResetLink()
|
|
} label: {
|
|
Label("Password reset link", systemImage: "key")
|
|
}
|
|
.disabled(_busy)
|
|
|
|
if let url = _resetURL {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Send them this link")
|
|
.font(.caption.weight(.semibold))
|
|
Text(verbatim: url)
|
|
.font(.caption2.monospaced())
|
|
.textSelection(.enabled)
|
|
Button {
|
|
UIPasteboard.general.string = url
|
|
_banner = .localized("Copied.")
|
|
} label: {
|
|
Label("Copy", systemImage: "doc.on.doc")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
Text("It works once, lasts a day, and signs every device out when it is used. Nothing here can show it again, so copy it now. Making another one kills this one.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
Button {
|
|
_confirmingSignOut = true
|
|
} label: {
|
|
Label("Sign out everywhere", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
.disabled(_busy || (author.sessions ?? 0) == 0)
|
|
|
|
// Admin-only server-side (`withAdmin`), and last for the same reason
|
|
// it is last everywhere else: the reversible answers come first.
|
|
Button(role: .destructive) {
|
|
_confirmingDelete = true
|
|
} label: {
|
|
Label("Delete account", systemImage: "trash")
|
|
}
|
|
.disabled(_busy || author.isDeleted)
|
|
} header: {
|
|
Text("Moderation")
|
|
} footer: {
|
|
if author.isDeleted {
|
|
Text("This account was deleted. Its handle and everything written under it are still here, which is what keeps those posts attributable to somebody rather than to nobody.")
|
|
} else {
|
|
Text("Deleting closes the account and leaves everything written under it where it is. Removing a post is done from that post, where you can see what goes with it.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: What
|
|
|
|
@ViewBuilder
|
|
private var _reviewsSection: some View {
|
|
if !_reviews.isEmpty {
|
|
Section("Reviews") {
|
|
ForEach(_reviews) { review in
|
|
NavigationLink {
|
|
AdminReviewDetailView(reviewId: review.id, title: review.headline) {
|
|
await _reload()
|
|
}
|
|
.environmentObject(session)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
if review.isThumb {
|
|
Image(systemName: review.works == true
|
|
? "hand.thumbsup.fill"
|
|
: "hand.thumbsdown.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(review.works == true ? Color.green : Color.red)
|
|
} else {
|
|
AdminStars(value: Double(review.rating ?? 0), size: 11)
|
|
}
|
|
Text(verbatim: review.headline)
|
|
.font(.subheadline.weight(.medium))
|
|
.strikethrough(review.isDeleted)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
// A thumb can carry nothing beyond itself, and the
|
|
// headline above has already stood in for whatever
|
|
// there was.
|
|
if let body = review.body, !body.isEmpty, !review.isThumb {
|
|
Text(body)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.strikethrough(review.isDeleted)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
_meta(
|
|
[
|
|
review.appName,
|
|
review.reportedBroken ? String.localized("Asked for a check") : nil,
|
|
review.answered ? String.localized("Answered") : nil,
|
|
review.hidden ? String.localized("Out of the store") : nil,
|
|
review.isDeleted ? String.localized("Withdrawn") : nil,
|
|
],
|
|
at: review.createdAt,
|
|
isRed: review.hidden
|
|
)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.opacity(review.hidden || review.isDeleted ? 0.5 : 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var _ticketsSection: some View {
|
|
if !_tickets.isEmpty {
|
|
Section("Reports") {
|
|
ForEach(_tickets) { ticket in
|
|
NavigationLink {
|
|
AdminFeedbackDetailView(ticketId: ticket.id, title: ticket.title) {
|
|
await _reload()
|
|
}
|
|
.environmentObject(session)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: ticket.symbol)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
Text(ticket.title)
|
|
.font(.subheadline)
|
|
.lineLimit(2)
|
|
}
|
|
_meta(
|
|
[
|
|
ticket.statusLabel,
|
|
"#\(ticket.id)",
|
|
ticket.hidden ? String.localized("Off the board") : nil,
|
|
],
|
|
at: ticket.createdAt,
|
|
isRed: ticket.hidden
|
|
)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var _repliesSection: some View {
|
|
if !_replies.isEmpty {
|
|
Section("Replies") {
|
|
ForEach(_replies) { reply in
|
|
NavigationLink {
|
|
AdminFeedbackDetailView(
|
|
ticketId: reply.ticketId,
|
|
title: reply.ticketTitle ?? "#\(reply.ticketId)"
|
|
) {
|
|
await _reload()
|
|
}
|
|
.environmentObject(session)
|
|
} label: {
|
|
_replyRow(
|
|
body: reply.body,
|
|
on: reply.ticketTitle ?? "#\(reply.ticketId)",
|
|
hidden: reply.hidden,
|
|
deleted: reply.isDeleted,
|
|
at: reply.createdAt
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var _requestRepliesSection: some View {
|
|
if !_requestReplies.isEmpty {
|
|
Section("On tweak requests") {
|
|
ForEach(_requestReplies) { reply in
|
|
_replyRow(
|
|
body: reply.body,
|
|
on: reply.tweakName ?? "#\(reply.requestId)",
|
|
hidden: reply.hidden,
|
|
deleted: reply.isDeleted,
|
|
at: reply.createdAt
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func _replyRow(
|
|
body: String?,
|
|
on subject: String,
|
|
hidden: Bool,
|
|
deleted: Bool,
|
|
at date: Date?
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(verbatim: body ?? "")
|
|
.font(.subheadline)
|
|
.lineLimit(3)
|
|
.strikethrough(deleted)
|
|
|
|
_meta(
|
|
[
|
|
subject,
|
|
hidden ? String.localized("Off the board") : nil,
|
|
deleted ? String.localized("Withdrawn") : nil,
|
|
],
|
|
at: date,
|
|
isRed: hidden
|
|
)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.opacity(hidden || deleted ? 0.5 : 1)
|
|
}
|
|
|
|
/// The grey line under a row: the facts that are true about it, then when.
|
|
@ViewBuilder
|
|
private func _meta(_ parts: [String?], at date: Date?, isRed: Bool) -> some View {
|
|
let text = (parts.compactMap { $0 } + [date.map(AdminFormat.relative)].compactMap { $0 })
|
|
.joined(separator: " · ")
|
|
|
|
Text(verbatim: text)
|
|
.font(.caption2)
|
|
.foregroundStyle(isRed ? Color.red : Color.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
private func _reload() async {
|
|
await _detail.load {
|
|
try await session.get(
|
|
"/api/feedback/authors/\(installId)",
|
|
as: AdminFeedbackAuthorDetail.self
|
|
)
|
|
}
|
|
}
|
|
|
|
private func _setBlocked(_ blocked: Bool) {
|
|
_run {
|
|
try await session.act(
|
|
.put,
|
|
"/api/feedback/authors",
|
|
body: AdminRequest.FeedbackAuthorUpdate(
|
|
installId: installId,
|
|
blocked: blocked
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Mint a one-time link that lets them set a new password.
|
|
///
|
|
/// Not through `_run`: that reloads and shows an acknowledgement, and this one
|
|
/// has a payload that has to survive on screen long enough to be copied.
|
|
private func _makeResetLink() {
|
|
_busy = true
|
|
_resetURL = nil
|
|
Task { @MainActor in
|
|
do {
|
|
let response = try await session.send(
|
|
.post,
|
|
"/api/feedback/authors/\(installId)/reset",
|
|
as: AdminEnvelope<AdminResetLink>.self
|
|
)
|
|
_resetURL = response.data?.url
|
|
_banner = response.message ?? .localized("Link made.")
|
|
} catch {
|
|
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
}
|
|
_busy = false
|
|
}
|
|
}
|
|
|
|
private func _signOutEverywhere() {
|
|
_run {
|
|
try await session.act(
|
|
.put,
|
|
"/api/feedback/authors",
|
|
body: AdminRequest.FeedbackAuthorUpdate(
|
|
installId: installId,
|
|
signOutEverywhere: true
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Rename them, with no PIN and no cooldown.
|
|
///
|
|
/// An empty field releases the name rather than being ignored, which is why
|
|
/// the server takes `displayName` as "" and not as an absent key: taking a
|
|
/// name away is a thing an operator reaches for, and it has to be sayable.
|
|
private func _rename() {
|
|
let name = _nextName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
_run {
|
|
try await session.act(
|
|
.put,
|
|
"/api/feedback/authors",
|
|
body: AdminRequest.FeedbackAuthorUpdate(
|
|
installId: installId,
|
|
displayName: name
|
|
)
|
|
)
|
|
} onSuccess: {
|
|
_nextName = ""
|
|
}
|
|
}
|
|
|
|
/// Close the account and leave everything written under it.
|
|
///
|
|
/// Reloads rather than dismissing: the row is still here afterwards, now
|
|
/// showing an anonymous handle, and an operator should see that rather than
|
|
/// be dropped back onto a list as if the person had evaporated.
|
|
private func _deleteAccount() {
|
|
_run {
|
|
try await session.act(.delete, "/api/feedback/authors/\(installId)")
|
|
}
|
|
}
|
|
|
|
private func _run(
|
|
_ operation: @escaping @MainActor () async throws -> AdminAck,
|
|
onSuccess: (@MainActor () -> Void)? = nil
|
|
) {
|
|
_busy = true
|
|
Task { @MainActor in
|
|
do {
|
|
let ack = try await operation()
|
|
_banner = ack.summary
|
|
onSuccess?()
|
|
} catch {
|
|
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
}
|
|
_busy = false
|
|
await _reload()
|
|
}
|
|
}
|
|
}
|