iOS: create and manage setup links like the web interface
Add peer and Issue new config offer the same choice as the web UI: show the config now or send a one-time setup link (1 h, 24 h or 7 d, PIN on by default). The link sheet shares the link through the iOS share sheet and shows the PIN, expiry and a QR code of the link. The peer page gets a setup link card with share, copy and revoke.
This commit is contained in:
@@ -97,4 +97,12 @@ final class API {
|
|||||||
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
|
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
|
||||||
try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
|
try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a peer or issues a config. The server answers with the config,
|
||||||
|
/// or with a setup link when the body asked for one.
|
||||||
|
func issue(_ path: String, _ body: [String: Any?]?) async throws -> IssueOutcome {
|
||||||
|
let d = try await data("POST", path, body: body)
|
||||||
|
if let l = try? Self.decoder.decode(LinkCreated.self, from: d) { return .link(l) }
|
||||||
|
return .config(try Self.decoder.decode(IssuedConfig.self, from: d))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ func fmtDate(_ date: Date?) -> String {
|
|||||||
return date.formatted(date: .abbreviated, time: .omitted)
|
return date.formatted(date: .abbreviated, time: .omitted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fmtStamp(_ date: Date) -> String {
|
||||||
|
date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
/// "35 min", "2 h 5 min", "3 days".
|
/// "35 min", "2 h 5 min", "3 days".
|
||||||
func fmtDuration(_ seconds: Int64) -> String {
|
func fmtDuration(_ seconds: Int64) -> String {
|
||||||
if seconds < 60 { return "under 1 min" }
|
if seconds < 60 { return "under 1 min" }
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ struct IssuedConfigContent: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Notice(text: issued.includesPrivateKey
|
Notice(text: "This is the only time the private key is shown. Scan or share it now: it is not stored on the server.")
|
||||||
? "This is the only time the private key is shown. Scan or share it now: it is not stored on the server."
|
|
||||||
: "The device keeps its own private key. Put it into the PrivateKey line.")
|
|
||||||
if let img = qrImage {
|
if let img = qrImage {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.interpolation(.none)
|
.interpolation(.none)
|
||||||
@@ -73,19 +71,3 @@ struct IssuedConfigContent: View {
|
|||||||
.background(Color.gwGround)
|
.background(Color.gwGround)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IssuedConfigContent in its own sheet, for re-issued configs.
|
|
||||||
struct IssuedConfigView: View {
|
|
||||||
let issued: IssuedConfig
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
IssuedConfigContent(issued: issued)
|
|
||||||
.navigationTitle("Config for \(issued.peer.name)")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
|
||||||
}
|
|
||||||
.interactiveDismissDisabled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -145,6 +145,53 @@ nonisolated struct IssuedConfig: Decodable, Identifiable {
|
|||||||
var id: String { peer.id + peer.publicKey }
|
var id: String { peer.id + peer.publicKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A setup link for the admin to send. It sets up a device once.
|
||||||
|
nonisolated struct SetupSecret: Decodable, Hashable {
|
||||||
|
let url: String
|
||||||
|
let path: String
|
||||||
|
let pin: String?
|
||||||
|
let expires: Date
|
||||||
|
let qr: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The answer to creating a peer or issuing a config with a setup link.
|
||||||
|
nonisolated struct LinkCreated: Decodable {
|
||||||
|
let peer: Peer
|
||||||
|
let setup: SetupSecret
|
||||||
|
let applyError: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A config shown now, or a setup link to send.
|
||||||
|
enum IssueOutcome: Identifiable {
|
||||||
|
case config(IssuedConfig)
|
||||||
|
case link(LinkCreated)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .config(let c): c.id
|
||||||
|
case .link(let l): l.setup.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var peer: Peer {
|
||||||
|
switch self {
|
||||||
|
case .config(let c): c.peer
|
||||||
|
case .link(let l): l.peer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyError: String {
|
||||||
|
switch self {
|
||||||
|
case .config(let c): c.applyError
|
||||||
|
case .link(let l): l.applyError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct PeerOnly: Decodable {
|
||||||
|
let peer: Peer
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated struct ClientDefaults: Codable, Equatable {
|
nonisolated struct ClientDefaults: Codable, Equatable {
|
||||||
var dns: [String]
|
var dns: [String]
|
||||||
var allowedIPs: [String]
|
var allowedIPs: [String]
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ struct PeerDetailView: View {
|
|||||||
@State private var range = "7d"
|
@State private var range = "7d"
|
||||||
@State private var points: [StatPoint] = []
|
@State private var points: [StatPoint] = []
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var issued: IssuedConfig?
|
@State private var issuing = false
|
||||||
@State private var confirmIssue = false
|
@State private var issueWithLink = false
|
||||||
|
@State private var showingLink = false
|
||||||
|
@State private var confirmRevoke = false
|
||||||
|
@State private var copiedLink = false
|
||||||
@State private var confirmDelete = false
|
@State private var confirmDelete = false
|
||||||
@State private var editing = false
|
@State private var editing = false
|
||||||
@State private var sessions: [ConnSession] = []
|
@State private var sessions: [ConnSession] = []
|
||||||
@@ -23,6 +26,7 @@ struct PeerDetailView: View {
|
|||||||
if let error { Notice(text: error, isError: true) }
|
if let error { Notice(text: error, isError: true) }
|
||||||
if let p = peer {
|
if let p = peer {
|
||||||
header(p)
|
header(p)
|
||||||
|
if let s = p.setup { setupLink(p, s) }
|
||||||
traffic
|
traffic
|
||||||
connection(p)
|
connection(p)
|
||||||
history.id("history")
|
history.id("history")
|
||||||
@@ -39,6 +43,8 @@ struct PeerDetailView: View {
|
|||||||
// Development: `-scrollToHistory YES` for screenshots of the history.
|
// Development: `-scrollToHistory YES` for screenshots of the history.
|
||||||
.task(id: sessions.count) {
|
.task(id: sessions.count) {
|
||||||
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
|
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
|
||||||
|
// `-showSetupLink YES` opens the pending setup link.
|
||||||
|
if UserDefaults.standard.bool(forKey: "showSetupLink"), peer?.setup != nil { showingLink = true }
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -61,12 +67,17 @@ struct PeerDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
|
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
|
||||||
}
|
}
|
||||||
.confirmationDialog("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) {
|
.confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) {
|
||||||
Button("Issue new config") { Task { await issue() } }
|
Button("Revoke link", role: .destructive) { Task { await revoke() } }
|
||||||
} message: {
|
} message: {
|
||||||
Text("New keys are created. The device that uses the current config stops working until it gets the new one.")
|
Text("The link stops working immediately.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $issuing, onDismiss: { Task { await load() } }) {
|
||||||
|
if let p = peer { IssueSheet(peer: p, startWithLink: issueWithLink) }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingLink) {
|
||||||
|
if let p = peer { SetupLinkSheet(peer: p) }
|
||||||
}
|
}
|
||||||
.sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
|
|
||||||
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
|
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
|
||||||
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
|
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
|
||||||
}
|
}
|
||||||
@@ -181,14 +192,10 @@ struct PeerDetailView: View {
|
|||||||
Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.")
|
Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(Color.gwText2)
|
.foregroundStyle(Color.gwText2)
|
||||||
Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
|
Button { issueWithLink = false; issuing = true } label: {
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
Label(p.publicKey.isEmpty ? "Issue config…" : "Issue new config…", systemImage: "qrcode")
|
||||||
if let s = p.setup {
|
|
||||||
Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface."
|
|
||||||
: "A setup link is waiting to be opened (until \(fmtDate(s.expires))). Issuing a config here replaces it.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(Color.gwWarnInk)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
|
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.gwText2)
|
.foregroundStyle(Color.gwText2)
|
||||||
@@ -251,11 +258,57 @@ struct PeerDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func issue() async {
|
private func setupLink(_ p: Peer, _ s: SetupInfo) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
SectionTitle(text: "Setup link")
|
||||||
|
Spacer()
|
||||||
|
Text(s.expired ? "Expired" : "Not opened yet").font(.footnote).foregroundStyle(Color.gwText2)
|
||||||
|
}
|
||||||
|
KV(key: s.expired ? "Expired" : "Expires", value: fmtStamp(s.expires))
|
||||||
|
KV(key: "PIN", value: s.pinRequired ? "Required · \(s.pinFails) of 5 wrong tries" : "Not required")
|
||||||
|
if !p.publicKey.isEmpty {
|
||||||
|
KV(key: "Current config", value: "Keeps working until the link is opened")
|
||||||
|
}
|
||||||
|
if s.expired {
|
||||||
|
Button("New link…") { issueWithLink = true; issuing = true }
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
Button("Remove") { Task { await revoke() } }
|
||||||
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
|
} else {
|
||||||
|
Button { showingLink = true } label: {
|
||||||
|
Label(s.pinRequired ? "Share link & PIN" : "Share link", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { Task { await copyLink() } } label: {
|
||||||
|
Label(copiedLink ? "Copied" : "Copy link", systemImage: copiedLink ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
|
Button("Revoke", role: .destructive) { confirmRevoke = true }
|
||||||
|
.buttonStyle(SecondaryButtonStyle(ink: .gwErrInk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyLink() async {
|
||||||
guard let api = session.api else { return }
|
guard let api = session.api else { return }
|
||||||
do {
|
do {
|
||||||
let body: [String: Any?]? = nil
|
let s: SetupSecret = try await api.get("/peers/\(peerID)/setup")
|
||||||
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
|
UIPasteboard.general.string = s.url
|
||||||
|
copiedLink = true
|
||||||
|
} catch {
|
||||||
|
session.alert = session.message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revoke() async {
|
||||||
|
guard let api = session.api else { return }
|
||||||
|
do {
|
||||||
|
let r: PeerOnly = try await api.send("DELETE", "/peers/\(peerID)/setup")
|
||||||
|
peer = r.peer
|
||||||
} catch {
|
} catch {
|
||||||
session.alert = session.message(for: error)
|
session.alert = session.message(for: error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,22 +110,23 @@ struct AddPeerView: View {
|
|||||||
@State private var ipv4 = ""
|
@State private var ipv4 = ""
|
||||||
@State private var overrides = PeerOverrides()
|
@State private var overrides = PeerOverrides()
|
||||||
@State private var psk = true
|
@State private var psk = true
|
||||||
|
@State private var handover = Handover()
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var issued: IssuedConfig?
|
@State private var issued: IssueOutcome?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if let issued {
|
if let issued {
|
||||||
IssuedConfigContent(issued: issued)
|
IssueOutcomeContent(outcome: issued)
|
||||||
} else if let server {
|
} else if let server {
|
||||||
form(server)
|
form(server)
|
||||||
} else {
|
} else {
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(issued == nil ? "Add peer" : "Config for \(issued?.peer.name ?? "")")
|
.navigationTitle(issued.map(outcomeTitle) ?? "Add peer")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if issued == nil {
|
if issued == nil {
|
||||||
@@ -166,9 +167,11 @@ struct AddPeerView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Keys")
|
Text("Keys")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The private key appears once in the config and QR code. It isn't stored.")
|
Text("The private key is never stored on the server.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HandoverSection(h: $handover)
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
Section { Text(error).foregroundStyle(Color.gwErrInk) }
|
Section { Text(error).foregroundStyle(Color.gwErrInk) }
|
||||||
}
|
}
|
||||||
@@ -187,7 +190,8 @@ struct AddPeerView: View {
|
|||||||
body["note"] = note.trimmingCharacters(in: .whitespaces)
|
body["note"] = note.trimmingCharacters(in: .whitespaces)
|
||||||
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
|
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
|
||||||
body["presharedKey"] = psk
|
body["presharedKey"] = psk
|
||||||
let r: IssuedConfig = try await api.send("POST", "/peers", body)
|
body.merge(handover.body) { _, new in new }
|
||||||
|
let r = try await api.issue("/peers", body)
|
||||||
session.reportApply(r.applyError)
|
session.reportApply(r.applyError)
|
||||||
issued = r
|
issued = r
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// "Show it here" or "Send a setup link", with the link's options. Used when
|
||||||
|
/// a peer is created and when its config is issued again.
|
||||||
|
struct Handover {
|
||||||
|
var link = false
|
||||||
|
var hours = 24
|
||||||
|
var pin = true
|
||||||
|
|
||||||
|
var body: [String: Any?] {
|
||||||
|
link ? ["delivery": "link", "linkHours": hours, "linkPIN": pin] : [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HandoverSection: View {
|
||||||
|
@Binding var h: Handover
|
||||||
|
var showHint = "QR code and .conf right after you tap Create. Best when the device is next to you."
|
||||||
|
var linkHint = "A one-time link you send to the device's owner. Keys are made when the link is opened and never stored."
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Hand over the config", selection: $h.link) {
|
||||||
|
Text("Show it here").tag(false)
|
||||||
|
Text("Send a setup link").tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
if h.link {
|
||||||
|
Picker("Link valid for", selection: $h.hours) {
|
||||||
|
Text("1 hour").tag(1)
|
||||||
|
Text("24 hours").tag(24)
|
||||||
|
Text("7 days").tag(168)
|
||||||
|
}
|
||||||
|
Toggle("Require a PIN", isOn: $h.pin)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Hand over the config")
|
||||||
|
} footer: {
|
||||||
|
Text(h.link ? linkHint + (h.pin ? " Send the PIN by another channel than the link." : "") : showHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrImage(_ dataURL: String?) -> UIImage? {
|
||||||
|
guard let s = dataURL, let comma = s.firstIndex(of: ","),
|
||||||
|
let data = Data(base64Encoded: String(s[s.index(after: comma)...])) else { return nil }
|
||||||
|
return UIImage(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a setup link to send: share, copy, PIN, QR code of the link.
|
||||||
|
struct SetupLinkContent: View {
|
||||||
|
let name: String
|
||||||
|
let setup: SetupSecret
|
||||||
|
@State private var copied: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Notice(text: setup.pin != nil
|
||||||
|
? "Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger."
|
||||||
|
: "Anyone with this link can set up this peer once. Send it only to the device's owner.")
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
KV(key: "Link", value: setup.url, mono: true)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let url = URL(string: setup.url) {
|
||||||
|
ShareLink(item: url, subject: Text("VPN setup"), message: Text("Your VPN setup link for \(name)")) {
|
||||||
|
Label("Share link", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
copyButton("Copy", value: setup.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
if let pin = setup.pin {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("PIN").font(.caption).foregroundStyle(Color.gwText2)
|
||||||
|
Text(pin).font(.mono(.title, weight: .semibold)).tracking(6).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
copyButton("Copy PIN", value: pin).frame(maxWidth: 150)
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
KV(key: "Valid until", value: fmtStamp(setup.expires))
|
||||||
|
KV(key: "Uses", value: "Once. Then the link stops working.")
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
if let img = qrImage(setup.qr) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
.padding(12)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.accessibilityLabel("QR code of the setup link for \(name)")
|
||||||
|
Text("The QR code holds only the link, not the config.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Color.gwText2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
Text("Until the link is used, you can share it again or revoke it on the peer's page.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Color.gwText2)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.background(Color.gwGround)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyButton(_ title: String, value: String) -> some View {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = value
|
||||||
|
copied = value
|
||||||
|
} label: {
|
||||||
|
Label(copied == value ? "Copied" : title, systemImage: copied == value ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of issuing: the config, or the setup link.
|
||||||
|
struct IssueOutcomeContent: View {
|
||||||
|
let outcome: IssueOutcome
|
||||||
|
var body: some View {
|
||||||
|
switch outcome {
|
||||||
|
case .config(let c): IssuedConfigContent(issued: c)
|
||||||
|
case .link(let l): SetupLinkContent(name: l.peer.name, setup: l.setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outcomeTitle(_ o: IssueOutcome) -> String {
|
||||||
|
switch o {
|
||||||
|
case .config(let c): "Config for \(c.peer.name)"
|
||||||
|
case .link(let l): "Setup link for \(l.peer.name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a config for an existing peer: choose how to hand it over, then
|
||||||
|
/// show the result in the same sheet.
|
||||||
|
struct IssueSheet: View {
|
||||||
|
let peer: Peer
|
||||||
|
var startWithLink = false
|
||||||
|
@Environment(AppSession.self) private var session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var h = Handover()
|
||||||
|
@State private var outcome: IssueOutcome?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if let outcome {
|
||||||
|
IssueOutcomeContent(outcome: outcome)
|
||||||
|
} else {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if !peer.publicKey.isEmpty {
|
||||||
|
Text("New keys are created. The device that uses the current config stops working once it is replaced.")
|
||||||
|
}
|
||||||
|
if peer.setup != nil {
|
||||||
|
Text("This replaces the current setup link.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Color.gwText2)
|
||||||
|
HandoverSection(h: $h,
|
||||||
|
showHint: "New keys now; QR code and .conf on this screen.",
|
||||||
|
linkHint: peer.publicKey.isEmpty ? "A one-time link you send to the device's owner."
|
||||||
|
: "The current config keeps working until the link is opened.")
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(Color.gwErrInk) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.groundBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(outcome.map(outcomeTitle) ?? (peer.publicKey.isEmpty ? "Issue config" : "Issue new config"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if outcome == nil {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Continue") { Task { await issue() } }.disabled(busy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled(outcome != nil)
|
||||||
|
.onAppear { h.link = startWithLink }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func issue() async {
|
||||||
|
guard let api = session.api else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let o = try await api.issue("/peers/\(peer.id)/issue-config", h.link ? h.body : nil)
|
||||||
|
session.reportApply(o.applyError)
|
||||||
|
outcome = o
|
||||||
|
} catch {
|
||||||
|
self.error = session.message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a pending setup link again and shows it.
|
||||||
|
struct SetupLinkSheet: View {
|
||||||
|
let peer: Peer
|
||||||
|
@Environment(AppSession.self) private var session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var setup: SetupSecret?
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if let setup {
|
||||||
|
SetupLinkContent(name: peer.name, setup: setup)
|
||||||
|
} else if let error {
|
||||||
|
ScrollView { Notice(text: error, isError: true).padding(16) }.background(Color.gwGround)
|
||||||
|
} else {
|
||||||
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Setup link for \(peer.name)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||||
|
.task {
|
||||||
|
guard let api = session.api else { return }
|
||||||
|
do { setup = try await api.get("/peers/\(peer.id)/setup") } catch { self.error = session.message(for: error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,11 +78,12 @@ struct PrimaryButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SecondaryButtonStyle: ButtonStyle {
|
struct SecondaryButtonStyle: ButtonStyle {
|
||||||
|
var ink = Color.gwText
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.body.weight(.medium))
|
.font(.body.weight(.medium))
|
||||||
.frame(maxWidth: .infinity, minHeight: 48)
|
.frame(maxWidth: .infinity, minHeight: 48)
|
||||||
.foregroundStyle(Color.gwText)
|
.foregroundStyle(ink)
|
||||||
.background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10))
|
.background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine))
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user