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.
704 lines
21 KiB
Swift
704 lines
21 KiB
Swift
//
|
|
// 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<AdminEnvelope<AdminAccessOverview>>()
|
|
|
|
@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<AdminAccessOverview>.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<AdminAccessOverview>.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<AdminAccessInviteLink> = try await session.send(
|
|
.post,
|
|
"/api/access/invites",
|
|
body: AdminRequest.AccessInviteNew(label: label),
|
|
as: AdminEnvelope<AdminAccessInviteLink>.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
|
|
}
|
|
}
|