// // AdminAccessView.swift // FTStore Admin // // Private mode, from the phone. The dashboard's half is // `web/src/components/access/access-panel.tsx` and this is the same five // things over the same API: the switch, the invitations, the accounts, the // devices, the countries. When a control moves there it moves here. // // The presentation is a `Form` rather than the dashboard's grid of cards, // which is the one asymmetry the two consoles are allowed. Everything else has // to agree, including what the copy says: an operator reading one of them and // then the other should not have to work out whether they mean the same thing. // // Nothing on this screen is a credential except for one moment. An invitation // link exists in the clear only in the response that mints it, so `_freshLink` // is shown in a sheet the operator dismisses by hand rather than in a row that // could scroll away before it was read. // import SwiftUI import UIKit struct AdminAccessView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _overview = AdminLoader>() @State private var _busy = false @State private var _error: String? @State private var _note = "" @State private var _maxDevices = "" @State private var _inviteDays = "" @State private var _newInviteLabel = "" /// A link the server has just minted. See the note at the top: this is the /// only place it will ever exist outside the database. @State private var _freshLink: AdminAccessInviteLink? @State private var _confirmSignOutAll = false private var _data: AdminAccessOverview? { _overview.value?.data } // MARK: Body var body: some View { Form { if let error = _error { Section { Text(error).font(.footnote).foregroundStyle(.red) } } if _overview.isLoading && !_overview.hasLoaded { AdminLoadingRow() } else if let message = _overview.errorMessage, !_overview.hasLoaded { AdminErrorRow(message: message) { Task { await _reload() } } } else if let data = _data { _switchSection(data) _shapeSection(data) _invitesSection(data) _accountsSection(data) _devicesSection(data) _countriesSection(data) } } .navigationTitle("Access") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .toolbar { ToolbarItem(placement: .confirmationAction) { if _busy { ProgressView() } } } .sheet(item: $_freshLink) { link in AdminAccessInviteSheet(invite: link) } .alert("Sign every device out?", isPresented: $_confirmSignOutAll) { Button("Cancel", role: .cancel) {} Button("Sign them all out", role: .destructive) { Task { await _signOutEverything() } } } message: { Text("Everyone signs in again, on every browser and every phone, including yours. Signing one device out is usually the smaller answer, this is the one for when you do not know whose password got out.") } } // MARK: The switch @ViewBuilder private func _switchSection(_ data: AdminAccessOverview) -> some View { Section { if data.stranded { Label { Text("Private mode is on and nobody can get in. A restored backup lands here, because a backup carries the switch and never the accounts. Invite somebody below and it opens.") .font(.footnote) } icon: { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } Toggle(isOn: Binding( get: { data.enabled }, set: { next in Task { await _saveSettings(.init(enabled: next)) } } )) { Label("Ask everybody to sign in", systemImage: data.isSealed ? "lock.fill" : "lock.open") } // A switch that cannot be turned on is better than one that turns on // and closes the site to everybody, the operator included. .disabled(_busy || (!data.canClose && !data.enabled)) } header: { Text("Private mode") } footer: { if data.canClose { Text("Nothing this server publishes answers a stranger: not the site, not the store in the app. Everybody signs in with their own account, so a leak can be traced to one person and closed without touching anybody else. While it is on, an account is something you hand out.") } else { Text("Invite somebody first. With nobody, nobody could get in, including you on a browser that is not already signed in to the dashboard.") } } } // MARK: Limits @ViewBuilder private func _shapeSection(_ data: AdminAccessOverview) -> some View { Section { LabeledContent("Devices per account") { HStack(spacing: 8) { TextField("0", text: $_maxDevices) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) Button("Save") { Task { await _saveSettings(.init(maxDevices: Int(_maxDevices) ?? 0)) } } .buttonStyle(.borderless) .disabled(_busy || Int(_maxDevices) == data.maxDevices || Int(_maxDevices) == nil) } } LabeledContent("An invitation lasts (days)") { HStack(spacing: 8) { TextField("365", text: $_inviteDays) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) Button("Save") { Task { await _saveSettings(.init(inviteDays: Int(_inviteDays) ?? 365)) } } .buttonStyle(.borderless) .disabled(_busy || Int(_inviteDays) == data.inviteDays || Int(_inviteDays) == nil) } } LabeledContent("Message on the sign-in screen") { HStack(spacing: 8) { TextField("Optional", text: $_note) .multilineTextAlignment(.trailing) Button("Save") { Task { await _saveSettings(.init(note: _note)) } } .buttonStyle(.borderless) .disabled(_busy || _note == data.note) } } } header: { Text("Limits") } footer: { Text("0 devices means no limit. With one set, signing in on one more device signs out the one that has been idle longest, so nobody is ever turned away for owning a phone and a laptop.") } } // MARK: Invitations @ViewBuilder private func _invitesSection(_ data: AdminAccessOverview) -> some View { Section { HStack(spacing: 8) { TextField("Who is it for?", text: $_newInviteLabel) .textInputAutocapitalization(.words) Button("Invite") { Task { await _createInvite() } } .buttonStyle(.borderless) .disabled(_busy || _newInviteLabel.trimmingCharacters(in: .whitespaces).isEmpty) } if data.invites.isEmpty { Text("No invitations yet.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(data.invites) { invite in _inviteRow(invite) } } } header: { HStack { Text("Invitations") Spacer() Text("\(data.openInvites)").foregroundStyle(.secondary) } } footer: { // The second sentence is here because the other switch is called the // same thing. Two populations, two screens, and nothing used to name // either. Text("One link, one account. Send it to the person it is named after, and when they open it they pick a name and a password. Withdrawing one only stops a link nobody has used yet, because once it has been, there is an account instead. These are accounts for the store and the boards; accounts that run this server are under Operators.") } } @ViewBuilder private func _inviteRow(_ invite: AdminAccessInvite) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(invite.label).font(.subheadline.weight(.medium)) Text(invite.state.label) .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.secondary.opacity(0.15), in: Capsule()) } Text(_inviteDetail(invite)) .font(.caption) .foregroundStyle(.secondary) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { Task { await _deleteInvite(invite) } } label: { Label("Delete", systemImage: "trash") } // Only a link nobody has used yet can be stopped. A spent one is a // person with an account, and the server says so rather than // pretending the row still controls anything. if invite.state == .open || invite.state == .revoked { Button { Task { await _setInviteRevoked(invite, revoked: invite.state == .open) } } label: { Label( invite.state == .revoked ? "Allow again" : "Withdraw", systemImage: invite.state == .revoked ? "checkmark.circle" : "hand.raised" ) } .tint(.orange) } } } private func _inviteDetail(_ invite: AdminAccessInvite) -> String { var parts: [String] = [] if let handle = invite.usedBy { parts.append(.localized("became %@", handle)) } else if let expiresAt = invite.expiresAt { parts.append(.localized("expires %@", AdminFormat.relative(expiresAt))) } parts.append(.localized("made %@", AdminFormat.relative(invite.createdAt))) return parts.joined(separator: " · ") } // MARK: Accounts @ViewBuilder private func _accountsSection(_ data: AdminAccessOverview) -> some View { Section { if data.accounts.isEmpty { Text("Nobody has an account yet.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(data.accounts) { account in VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(account.handle).font(.subheadline.weight(.medium)) if account.blocked { Text("Muted") .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.red.opacity(0.15), in: Capsule()) } } Text(_accountDetail(account)) .font(.caption) .foregroundStyle(.secondary) } } } } header: { HStack { Text("Accounts") Spacer() Text("\(data.accountCount)").foregroundStyle(.secondary) } } footer: { Text("Everybody who can sign in. This is the same account they post and keep a backup under, so muting or removing one is done on the feedback board's own screen for it, where the things they wrote are also in view.") } } private func _accountDetail(_ account: AdminAccessAccount) -> String { var parts = [ account.devices == 1 ? String.localized("1 device") : String.localized("%lld devices", account.devices) ] parts.append( account.lastSeenAt == nil ? .localized("never seen") : .localized("seen %@", AdminFormat.relative(account.lastSeenAt)) ) return parts.joined(separator: " · ") } // MARK: Devices @ViewBuilder private func _devicesSection(_ data: AdminAccessOverview) -> some View { Section { if data.devices.isEmpty { Text("Nobody is signed in.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(data.devices) { device in NavigationLink { AdminAccessDeviceView(device: device) { await _reload() } } label: { _deviceRow(device) } } Button(role: .destructive) { _confirmSignOutAll = true } label: { Label("Sign every device out", systemImage: "person.crop.circle.badge.xmark") } .disabled(_busy) } } header: { HStack { Text("Devices") Spacer() Text("\(data.activeDevices)") .foregroundStyle(data.limitReached ? .red : .secondary) } } footer: { Text("Every browser and phone currently signed in, and whose it is. Whatever you do not recognise is the question worth asking.") } } @ViewBuilder private func _deviceRow(_ device: AdminAccessDevice) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Image(systemName: device.isApp ? "iphone" : "safari") .font(.caption) .foregroundStyle(.secondary) Text(device.account) .font(.subheadline.weight(.medium)) .lineLimit(1) } Text(_deviceDetail(device)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } private func _deviceDetail(_ device: AdminAccessDevice) -> String { var parts: [String] = [] if let country = device.country { parts.append("\(AdminCountry.flag(country)) \(AdminCountry.name(country))") } else { parts.append(.localized("Country unknown")) } parts.append(device.isApp ? "FTStore" : device.fallbackName) parts.append(String.localized("seen %@", AdminFormat.relative(device.lastSeenAt ?? device.createdAt))) return parts.joined(separator: " · ") } // MARK: Countries @ViewBuilder private func _countriesSection(_ data: AdminAccessOverview) -> some View { Section { if data.countries.isEmpty { Text("Nothing counted yet.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(data.countries) { row in HStack(spacing: 10) { Text(AdminCountry.flag(row.country)) Text(AdminCountry.name(row.country)) .font(.subheadline) Spacer() Text(AdminFormat.count(row.opened)) .font(.subheadline.monospacedDigit()) if row.refused > 0 { Text(String.localized("%lld turned away", row.refused)) .font(.caption) .foregroundStyle(.orange) } } } } } header: { Text("Where it is being opened") } footer: { Text("The last 30 days, by country. The server keeps counters and no addresses, so this says how much and from where, never who or what they looked at. Countries come from the proxy in front of the server, and anything it cannot place is Unknown.") } } // MARK: Loading @MainActor private func _reload() async { await _overview.load { try await session.get("/api/access", as: AdminEnvelope.self) } guard let data = _data else { return } _note = data.note _maxDevices = String(data.maxDevices) _inviteDays = String(data.inviteDays) } // MARK: Saving @MainActor private func _saveSettings(_ body: AdminRequest.AccessSettings) async { _busy = true _error = nil do { _ = try await session.send( .put, "/api/access", body: body, as: AdminEnvelope.self ) } catch { _error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } @MainActor private func _createInvite() async { let label = _newInviteLabel.trimmingCharacters(in: .whitespaces) guard !label.isEmpty else { return } _busy = true _error = nil do { let response: AdminEnvelope = try await session.send( .post, "/api/access/invites", body: AdminRequest.AccessInviteNew(label: label), as: AdminEnvelope.self ) if let minted = response.data { _freshLink = AdminAccessInviteLink( id: minted.id, label: label, link: minted.link, expiresAt: minted.expiresAt ) } _newInviteLabel = "" } catch { _error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } @MainActor private func _setInviteRevoked(_ invite: AdminAccessInvite, revoked: Bool) async { await _act { try await session.act( .put, "/api/access/invites", body: AdminRequest.AccessInviteChange(id: invite.id, revoked: revoked) ) } } @MainActor private func _deleteInvite(_ invite: AdminAccessInvite) async { await _act { try await session.act(.delete, "/api/access/invites", query: ["id": String(invite.id)]) } } @MainActor private func _signOutEverything() async { await _act { try await session.act(.delete, "/api/access/devices", query: ["all": "1"]) } } @MainActor private func _act(_ operation: @escaping @MainActor () async throws -> AdminAck) async { _busy = true _error = nil do { _ = try await operation() } catch { _error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } // MARK: - One device /// What one signed-in device is, and how to sign it out. /// /// A row in the list is a handle and one line of detail, which is what an /// operator scans; this is what they open when one of them is the row they /// cannot place. Splitting it that way is also what keeps the list's swipe /// actions to the one destructive thing rather than three. /// /// There is nothing to name here. A device says what it is when it signs in and /// the account says whose it is, which is the pair an operator was renaming rows /// to reconstruct back when a pass said neither. struct AdminAccessDeviceView: View { @EnvironmentObject private var session: AdminSession @Environment(\.dismiss) private var _dismiss let device: AdminAccessDevice let onChange: @MainActor () async -> Void @State private var _busy = false @State private var _error: String? var body: some View { Form { if let error = _error { Section { Text(error).font(.footnote).foregroundStyle(.red) } } Section("Details") { LabeledContent("Account", value: device.account) LabeledContent("Kind", value: device.isApp ? "FTStore" : .localized("Browser")) LabeledContent("Device", value: device.device ?? .localized("Unknown")) LabeledContent("Country") { if let country = device.country { Text("\(AdminCountry.flag(country)) \(AdminCountry.name(country))") } else { Text("Unknown").foregroundStyle(.secondary) } } LabeledContent("Signed in", value: AdminFormat.relative(device.createdAt)) LabeledContent("Last seen", value: AdminFormat.relative(device.lastSeenAt)) LabeledContent("Asked again", value: AdminFormat.relative(device.expiresAt)) } Section { Button(role: .destructive) { Task { await _signOut() } } label: { Label("Sign this device out", systemImage: "xmark.circle") } .disabled(_busy) } footer: { Text("It has to sign in again next time. Everything it downloaded already stays where it is, and the account itself is untouched.") } } .navigationTitle(device.account) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { if _busy { ProgressView() } } } } @MainActor private func _signOut() async { _busy = true _error = nil do { _ = try await session.act( .delete, "/api/access/devices", query: ["id": device.id] ) await onChange() _dismiss() } catch { _error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false } } // MARK: - The one look at a link /// A sheet rather than a row, because this is the only time the link exists. /// /// The server keeps a digest, so nothing can show it again. A row that scrolled /// away, or a toast that timed out, would both be ways to lose an invitation the /// moment it was made. struct AdminAccessInviteSheet: View { @Environment(\.dismiss) private var _dismiss let invite: AdminAccessInviteLink @State private var _copied = false var body: some View { NavigationStack { VStack(spacing: 18) { Image(systemName: "envelope.fill") .font(.largeTitle) .foregroundStyle(AdminTheme.brand) Text(invite.link) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .multilineTextAlignment(.center) .padding(.vertical, 12) .padding(.horizontal, 10) .frame(maxWidth: .infinity) .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) Text("Only a fingerprint of it is stored, so this is the only time it can be read. If it gets lost, make a new one.") .font(.footnote) .multilineTextAlignment(.center) .foregroundStyle(.secondary) HStack(spacing: 10) { Button { UIPasteboard.general.string = invite.link _copied = true } label: { Label( _copied ? "Copied" : "Copy", systemImage: _copied ? "checkmark" : "doc.on.doc" ) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) // Sending it is the whole point of it existing, and a share // sheet is how somebody actually gets it into a message. ShareLink(item: invite.link) { Label("Share", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } Spacer() } .padding(20) .navigationTitle(invite.label ?? .localized("New invitation")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { _dismiss() } } } } } } // MARK: - Countries /// Turning a two-letter code into a flag and a name. /// /// The name comes from iOS, so the list is the platform's rather than a table /// in this app that goes stale, and it arrives in the operator's own language /// for free. The flag is arithmetic on the two letters: regional indicator /// symbols are laid out so that `A` is the first of them. /// /// The web dashboard does the same thing in `lib/private-access/countries.ts`. enum AdminCountry { /// What the counters store when the proxy supplied no country. static let unknown = "ZZ" static func flag(_ code: String) -> String { let upper = code.uppercased() guard upper.count == 2, upper != unknown, upper.allSatisfy({ $0.isLetter }) else { return "🌐" } var result = "" for scalar in upper.unicodeScalars { guard let indicator = UnicodeScalar(127_397 + scalar.value) else { return "🌐" } result.unicodeScalars.append(indicator) } return result } static func name(_ code: String) -> String { let upper = code.uppercased() guard upper != unknown else { return .localized("Unknown") } return Locale.current.localizedString(forRegionCode: upper) ?? upper } }