-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add login flow & session management #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,3 +3,5 @@ disabled_rules: | |
- trailing_comma | ||
type_name: | ||
allowed_symbols: "_" | ||
identifier_name: | ||
allowed_symbols: "_" |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<Scheme | ||
LastUpgradeVersion = "1610" | ||
version = "1.7"> | ||
<BuildAction | ||
parallelizeBuildables = "YES" | ||
buildImplicitDependencies = "YES" | ||
buildArchitectures = "Automatic"> | ||
<BuildActionEntries> | ||
<BuildActionEntry | ||
buildForTesting = "YES" | ||
buildForRunning = "YES" | ||
buildForProfiling = "YES" | ||
buildForArchiving = "YES" | ||
buildForAnalyzing = "YES"> | ||
<BuildableReference | ||
BuildableIdentifier = "primary" | ||
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF" | ||
BuildableName = "Coder Desktop.app" | ||
BlueprintName = "Coder Desktop" | ||
ReferencedContainer = "container:Coder Desktop.xcodeproj"> | ||
</BuildableReference> | ||
</BuildActionEntry> | ||
</BuildActionEntries> | ||
</BuildAction> | ||
<TestAction | ||
buildConfiguration = "Debug" | ||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | ||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | ||
shouldUseLaunchSchemeArgsEnv = "YES"> | ||
<TestPlans> | ||
<TestPlanReference | ||
reference = "container:Coder Desktop.xctestplan" | ||
default = "YES"> | ||
</TestPlanReference> | ||
</TestPlans> | ||
<Testables> | ||
<TestableReference | ||
skipped = "NO" | ||
parallelizable = "NO"> | ||
<BuildableReference | ||
BuildableIdentifier = "primary" | ||
BlueprintIdentifier = "9616790E2CFF100E00B2B6DF" | ||
BuildableName = "Coder DesktopTests.xctest" | ||
BlueprintName = "Coder DesktopTests" | ||
ReferencedContainer = "container:Coder Desktop.xcodeproj"> | ||
</BuildableReference> | ||
</TestableReference> | ||
<TestableReference | ||
skipped = "NO" | ||
parallelizable = "YES"> | ||
<BuildableReference | ||
BuildableIdentifier = "primary" | ||
BlueprintIdentifier = "961679182CFF100E00B2B6DF" | ||
BuildableName = "Coder DesktopUITests.xctest" | ||
BlueprintName = "Coder DesktopUITests" | ||
ReferencedContainer = "container:Coder Desktop.xcodeproj"> | ||
</BuildableReference> | ||
</TestableReference> | ||
</Testables> | ||
</TestAction> | ||
<LaunchAction | ||
buildConfiguration = "Debug" | ||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | ||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | ||
launchStyle = "0" | ||
useCustomWorkingDirectory = "NO" | ||
ignoresPersistentStateOnLaunch = "NO" | ||
debugDocumentVersioning = "YES" | ||
debugServiceExtension = "internal" | ||
allowLocationSimulation = "YES"> | ||
<BuildableProductRunnable | ||
runnableDebuggingMode = "0"> | ||
<BuildableReference | ||
BuildableIdentifier = "primary" | ||
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF" | ||
BuildableName = "Coder Desktop.app" | ||
BlueprintName = "Coder Desktop" | ||
ReferencedContainer = "container:Coder Desktop.xcodeproj"> | ||
</BuildableReference> | ||
</BuildableProductRunnable> | ||
</LaunchAction> | ||
<ProfileAction | ||
buildConfiguration = "Release" | ||
shouldUseLaunchSchemeArgsEnv = "YES" | ||
savedToolIdentifier = "" | ||
useCustomWorkingDirectory = "NO" | ||
debugDocumentVersioning = "YES"> | ||
<BuildableProductRunnable | ||
runnableDebuggingMode = "0"> | ||
<BuildableReference | ||
BuildableIdentifier = "primary" | ||
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF" | ||
BuildableName = "Coder Desktop.app" | ||
BlueprintName = "Coder Desktop" | ||
ReferencedContainer = "container:Coder Desktop.xcodeproj"> | ||
</BuildableReference> | ||
</BuildableProductRunnable> | ||
</ProfileAction> | ||
<AnalyzeAction | ||
buildConfiguration = "Debug"> | ||
</AnalyzeAction> | ||
<ArchiveAction | ||
buildConfiguration = "Release" | ||
revealArchiveInOrganizer = "YES"> | ||
</ArchiveAction> | ||
</Scheme> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
{ | ||
"configurations" : [ | ||
{ | ||
"id" : "BB7F7563-199E-4896-BCDE-1F751C24B71F", | ||
"name" : "Test Scheme Action", | ||
"options" : { | ||
|
||
} | ||
} | ||
], | ||
"defaultOptions" : { | ||
"targetForVariableExpansion" : { | ||
"containerPath" : "container:Coder Desktop.xcodeproj", | ||
"identifier" : "961678FB2CFF100D00B2B6DF", | ||
"name" : "Coder Desktop" | ||
} | ||
}, | ||
"testTargets" : [ | ||
{ | ||
"parallelizable" : true, | ||
"target" : { | ||
"containerPath" : "container:Coder Desktop.xcodeproj", | ||
"identifier" : "9616790E2CFF100E00B2B6DF", | ||
"name" : "Coder DesktopTests" | ||
} | ||
}, | ||
{ | ||
"parallelizable" : true, | ||
"target" : { | ||
"containerPath" : "container:Coder Desktop.xcodeproj", | ||
"identifier" : "961679182CFF100E00B2B6DF", | ||
"name" : "Coder DesktopUITests" | ||
} | ||
} | ||
], | ||
"version" : 1 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import SwiftUI | ||
|
||
enum About { | ||
private static var credits: NSAttributedString { | ||
let coder = NSMutableAttributedString( | ||
string: "Coder.com", | ||
attributes: [ | ||
.foregroundColor: NSColor.labelColor, | ||
.link: NSURL(string: "https://coder.com")!, | ||
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize), | ||
] | ||
) | ||
let separator = NSAttributedString( | ||
string: " | ", | ||
attributes: [ | ||
.foregroundColor: NSColor.labelColor, | ||
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize), | ||
] | ||
) | ||
let source = NSAttributedString( | ||
string: "GitHub", | ||
attributes: [ | ||
.foregroundColor: NSColor.labelColor, | ||
.link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!, | ||
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize), | ||
] | ||
) | ||
coder.append(separator) | ||
coder.append(source) | ||
return coder | ||
} | ||
|
||
static func open() { | ||
#if compiler(>=5.9) && canImport(AppKit) | ||
if #available(macOS 14, *) { | ||
NSApp.activate() | ||
} else { | ||
NSApp.activate(ignoringOtherApps: true) | ||
} | ||
#else | ||
NSApp.activate(ignoringOtherApps: true) | ||
#endif | ||
NSApp.orderFrontStandardAboutPanel(options: [ | ||
.credits: credits, | ||
]) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import SwiftUI | ||
|
||
struct PreviewClient: Client { | ||
init(url _: URL, token _: String? = nil) {} | ||
|
||
func user(_: String) async throws -> User { | ||
try await Task.sleep(for: .seconds(1)) | ||
return User( | ||
id: UUID(), | ||
username: "admin", | ||
avatar_url: "", | ||
name: "admin", | ||
email: "admin@coder.com", | ||
created_at: Date.now, | ||
updated_at: Date.now, | ||
last_seen_at: Date.now, | ||
status: "active", | ||
login_type: "none", | ||
theme_preference: "dark", | ||
organization_ids: [], | ||
roles: [] | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,62 @@ | ||||||
import Alamofire | ||||||
import Foundation | ||||||
|
||||||
protocol Client { | ||||||
init(url: URL, token: String?) | ||||||
func user(_ ident: String) async throws -> User | ||||||
} | ||||||
|
||||||
struct CoderClient: Client { | ||||||
public let url: URL | ||||||
public var token: String? | ||||||
|
||||||
static let decoder: JSONDecoder = { | ||||||
var dec = JSONDecoder() | ||||||
dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds | ||||||
return dec | ||||||
}() | ||||||
|
||||||
let encoder: JSONEncoder = { | ||||||
var enc = JSONEncoder() | ||||||
enc.dateEncodingStrategy = .iso8601withFractionalSeconds | ||||||
return enc | ||||||
}() | ||||||
|
||||||
func request<T: Encodable>( | ||||||
_ path: String, | ||||||
method: HTTPMethod, | ||||||
body: T | ||||||
) async -> DataResponse<Data, AFError> { | ||||||
let url = self.url.appendingPathComponent(path) | ||||||
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""] | ||||||
return await AF.request( | ||||||
url, | ||||||
method: method, | ||||||
parameters: body, | ||||||
encoder: JSONParameterEncoder.default, | ||||||
headers: headers | ||||||
).serializingData().response | ||||||
} | ||||||
|
||||||
func request( | ||||||
_ path: String, | ||||||
method: HTTPMethod | ||||||
) async -> DataResponse<Data, AFError> { | ||||||
let url = self.url.appendingPathComponent(path) | ||||||
let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""] | ||||||
return await AF.request( | ||||||
url, | ||||||
method: method, | ||||||
headers: headers | ||||||
).serializingData().response | ||||||
} | ||||||
} | ||||||
|
||||||
enum ClientError: Error { | ||||||
case unexpectedStatusCode | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This lets you store the status code |
||||||
case badResponse | ||||||
} | ||||||
|
||||||
enum Headers { | ||||||
ethanndickson marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
static let sessionToken = "Coder-Session-Token" | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import Foundation | ||
|
||
// Handling for ISO8601 Timestamps with fractional seconds | ||
// Directly from https://stackoverflow.com/questions/46458487/ | ||
|
||
extension ParseStrategy where Self == Date.ISO8601FormatStyle { | ||
static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) } | ||
} | ||
|
||
extension JSONDecoder.DateDecodingStrategy { | ||
static let iso8601withOptionalFractionalSeconds = custom { | ||
let string = try $0.singleValueContainer().decode(String.self) | ||
do { | ||
return try .init(string, strategy: .iso8601withFractionalSeconds) | ||
} catch { | ||
return try .init(string, strategy: .iso8601) | ||
} | ||
} | ||
} | ||
|
||
extension FormatStyle where Self == Date.ISO8601FormatStyle { | ||
static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) } | ||
} | ||
|
||
extension JSONEncoder.DateEncodingStrategy { | ||
static let iso8601withFractionalSeconds = custom { | ||
var container = $1.singleValueContainer() | ||
try container.encode($0.formatted(.iso8601withFractionalSeconds)) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import Foundation | ||
|
||
extension CoderClient { | ||
func user(_ ident: String) async throws -> User { | ||
let resp = await request("/api/v2/users/\(ident)", method: .get) | ||
guard let response = resp.response, response.statusCode == 200 else { | ||
throw ClientError.unexpectedStatusCode | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fine for now, but we should be parsing the generic error responses for debugging/troubleshooting e.g. https://github.com/coder/coder/blob/b39becba66f1c6ad9c8d73694b0d8779cf7467a2/codersdk/client.go#L495 |
||
} | ||
guard let data = resp.data else { | ||
throw ClientError.badResponse | ||
} | ||
return try CoderClient.decoder.decode(User.self, from: data) | ||
} | ||
} | ||
|
||
struct User: Decodable { | ||
let id: UUID | ||
let username: String | ||
let avatar_url: String | ||
let name: String | ||
let email: String | ||
let created_at: Date | ||
let updated_at: Date | ||
let last_seen_at: Date | ||
let status: String | ||
let login_type: String | ||
let theme_preference: String | ||
let organization_ids: [UUID] | ||
let roles: [Role] | ||
} | ||
|
||
struct Role: Decodable { | ||
let name: String | ||
let display_name: String | ||
let organization_id: UUID? | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,64 +1,73 @@ | ||
import KeychainAccess | ||
import Foundation | ||
import KeychainAccess | ||
|
||
protocol Session: ObservableObject { | ||
var hasSession: Bool { get } | ||
var sessionToken: String? { get } | ||
var baseAccessURL: URL? { get } | ||
var sessionToken: String? { get } | ||
|
||
func login(baseAccessURL: URL, sessionToken: String) | ||
func logout() | ||
func store(baseAccessURL: URL, sessionToken: String) | ||
func clear() | ||
} | ||
|
||
class SecureSession: ObservableObject { | ||
// Stored in UserDefaults | ||
@Published private(set) var hasSession: Bool { | ||
didSet { | ||
UserDefaults.standard.set(hasSession, forKey: "hasSession") | ||
UserDefaults.standard.set(hasSession, forKey: Keys.hasSession) | ||
} | ||
} | ||
@Published private(set) var sessionToken: String? { | ||
|
||
@Published private(set) var baseAccessURL: URL? { | ||
didSet { | ||
setValue(sessionToken, for: "sessionToken") | ||
UserDefaults.standard.set(baseAccessURL, forKey: Keys.baseAccessURL) | ||
} | ||
} | ||
@Published private(set) var baseAccessURL: URL? { | ||
|
||
// Stored in Keychain | ||
@Published private(set) var sessionToken: String? { | ||
didSet { | ||
setValue(baseAccessURL?.absoluteString, for: "baseAccessURL") | ||
keychainSet(sessionToken, for: Keys.sessionToken) | ||
} | ||
} | ||
|
||
private let keychain: Keychain | ||
|
||
public init() { | ||
keychain = Keychain(service: Bundle.main.bundleIdentifier!) | ||
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: "hasSession")) | ||
_hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: Keys.hasSession)) | ||
_baseAccessURL = Published(initialValue: UserDefaults.standard.url(forKey: Keys.baseAccessURL)) | ||
if hasSession { | ||
_sessionToken = Published(initialValue: getValue(for: "sessionToken")) | ||
_baseAccessURL = Published(initialValue: getValue(for: "baseAccessURL").flatMap(URL.init)) | ||
_sessionToken = Published(initialValue: keychainGet(for: Keys.sessionToken)) | ||
} | ||
} | ||
|
||
public func login(baseAccessURL: URL, sessionToken: String) { | ||
public func store(baseAccessURL: URL, sessionToken: String) { | ||
hasSession = true | ||
self.baseAccessURL = baseAccessURL | ||
self.sessionToken = sessionToken | ||
} | ||
|
||
// Called when the user logs out, or if we find out the token has expired | ||
public func logout() { | ||
public func clear() { | ||
hasSession = false | ||
sessionToken = nil | ||
baseAccessURL = nil | ||
} | ||
|
||
private func getValue(for key: String) -> String? { | ||
private func keychainGet(for key: String) -> String? { | ||
try? keychain.getString(key) | ||
} | ||
|
||
private func setValue(_ value: String?, for key: String) { | ||
private func keychainSet(_ value: String?, for key: String) { | ||
if let value = value { | ||
try? keychain.set(value, key: key) | ||
} else { | ||
try? keychain.remove(key) | ||
} | ||
} | ||
|
||
enum Keys { | ||
static let hasSession = "hasSession" | ||
static let baseAccessURL = "baseAccessURL" | ||
static let sessionToken = "sessionToken" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,4 +8,5 @@ enum Theme { | |
|
||
static let rectCornerRadius: CGFloat = 4 | ||
} | ||
static let defaultVisibleAgents = 5 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,23 +6,27 @@ struct Agents<VPN: VPNService, S: Session>: View { | |
@State private var viewAll = false | ||
private let defaultVisibleRows = 5 | ||
|
||
internal let inspection = Inspection<Self>() | ||
|
||
var body: some View { | ||
// Workspaces List | ||
if vpn.state == .connected { | ||
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows)) | ||
ForEach(visibleData, id: \.id) { workspace in | ||
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!) | ||
.padding(.horizontal, Theme.Size.trayMargin) | ||
} | ||
if vpn.agents.count > defaultVisibleRows { | ||
Toggle(isOn: $viewAll) { | ||
Text(viewAll ? "Show Less" : "Show All") | ||
.font(.headline) | ||
.foregroundColor(.gray) | ||
.padding(.horizontal, Theme.Size.trayInset) | ||
.padding(.top, 2) | ||
}.toggleStyle(.button).buttonStyle(.plain) | ||
Group { | ||
// Workspaces List | ||
if vpn.state == .connected { | ||
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows)) | ||
ForEach(visibleData, id: \.id) { workspace in | ||
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!) | ||
.padding(.horizontal, Theme.Size.trayMargin) | ||
} | ||
if vpn.agents.count > defaultVisibleRows { | ||
Toggle(isOn: $viewAll) { | ||
Text(viewAll ? "Show Less" : "Show All") | ||
.font(.headline) | ||
.foregroundColor(.gray) | ||
.padding(.horizontal, Theme.Size.trayInset) | ||
.padding(.top, 2) | ||
}.toggleStyle(.button).buttonStyle(.plain) | ||
} | ||
} | ||
} | ||
}.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I absolutely hate that you need to modify your UI code to get the tests to work, but you do. If nalexn/ViewInspector#231 is ever resolved you'd be able to just wrap the view with the inspector at test-time instead. |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
import SwiftUI | ||
|
||
struct LoginForm<C: Client, S: Session>: View { | ||
ethanndickson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@EnvironmentObject var session: S | ||
@Environment(\.dismiss) private var dismiss | ||
|
||
@State private var baseAccessURL: String = "" | ||
@State private var sessionToken: String = "" | ||
@State private var loginError: LoginError? | ||
@State private var currentPage: LoginPage = .serverURL | ||
@State private var loading: Bool = false | ||
@FocusState private var focusedField: LoginField? | ||
|
||
internal let inspection = Inspection<Self>() | ||
|
||
var body: some View { | ||
VStack { | ||
VStack { | ||
switch currentPage { | ||
case .serverURL: | ||
serverURLPage | ||
.transition(.move(edge: .leading)) | ||
.onAppear { | ||
DispatchQueue.main.async { | ||
focusedField = .baseAccessURL | ||
} | ||
} | ||
case .sessionToken: | ||
sessionTokenPage | ||
.transition(.move(edge: .trailing)) | ||
.onAppear { | ||
DispatchQueue.main.async { | ||
focusedField = .sessionToken | ||
} | ||
} | ||
} | ||
} | ||
.animation(.easeInOut, value: currentPage) | ||
.onAppear { | ||
loginError = nil | ||
baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL | ||
sessionToken = "" | ||
}.padding(.top, 35) | ||
VStack(alignment: .center) { | ||
if let loginError { | ||
Text("\(loginError.description)") | ||
.font(.headline) | ||
.foregroundColor(.red) | ||
.multilineTextAlignment(.center) | ||
} | ||
} | ||
.frame(height: 35) | ||
}.padding() | ||
.frame(width: 450, height: 220) | ||
.disabled(loading) | ||
.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector | ||
} | ||
|
||
internal func submit() async { | ||
loginError = nil | ||
guard sessionToken != "" else { | ||
loginError = .invalidToken | ||
return | ||
} | ||
guard let url = URL(string: baseAccessURL), url.scheme == "https" else { | ||
loginError = .invalidURL | ||
return | ||
} | ||
loading = true | ||
defer { loading = false} | ||
let client = C(url: url, token: sessionToken) | ||
do { | ||
_ = try await client.user("me") | ||
} catch { | ||
loginError = .failedAuth | ||
print("Set error") | ||
return | ||
} | ||
session.store(baseAccessURL: url, sessionToken: sessionToken) | ||
dismiss() | ||
} | ||
|
||
private var serverURLPage: some View { | ||
VStack(spacing: 15) { | ||
Text("Coder Desktop").font(.title).padding(.bottom, 15) | ||
VStack(alignment: .leading) { | ||
HStack(alignment: .firstTextBaseline) { | ||
Text("Server URL") | ||
Spacer() | ||
TextField("https://coder.example.com", text: $baseAccessURL) | ||
.textFieldStyle(RoundedBorderTextFieldStyle()) | ||
.disableAutocorrection(true) | ||
.frame(width: 290, alignment: .leading) | ||
.focused($focusedField, equals: .baseAccessURL) | ||
} | ||
} | ||
HStack { | ||
Button("Next", action: next) | ||
.buttonStyle(.borderedProminent) | ||
.keyboardShortcut(.defaultAction) | ||
} | ||
.padding(.top, 10) | ||
}.padding(.horizontal, 15) | ||
} | ||
|
||
private var sessionTokenPage: some View { | ||
VStack { | ||
VStack(alignment: .leading) { | ||
HStack(alignment: .firstTextBaseline) { | ||
Text("Server URL") | ||
Spacer() | ||
TextField("https://coder.example.com", text: $baseAccessURL) | ||
.textFieldStyle(RoundedBorderTextFieldStyle()) | ||
.disableAutocorrection(true) | ||
.frame(width: 290, alignment: .leading) | ||
.disabled(true) | ||
} | ||
HStack(alignment: .firstTextBaseline) { | ||
Text("Session Token") | ||
Spacer() | ||
SecureField("", text: $sessionToken) | ||
.textFieldStyle(RoundedBorderTextFieldStyle()) | ||
.disableAutocorrection(true) | ||
.frame(width: 290, alignment: .leading) | ||
.privacySensitive() | ||
.focused($focusedField, equals: .sessionToken) | ||
} | ||
Link( | ||
"Generate a token via the Web UI", | ||
destination: URL(string: baseAccessURL)!.appendingPathComponent("cli-auth") | ||
).font(.callout).foregroundColor(.blue).underline() | ||
}.padding() | ||
HStack { | ||
Button("Back", action: back) | ||
Button("Sign In") { | ||
Task { await submit() } | ||
} | ||
.buttonStyle(.borderedProminent) | ||
.keyboardShortcut(.defaultAction) | ||
}.padding(.top, 5) | ||
} | ||
} | ||
|
||
private func next() { | ||
loginError = nil | ||
guard let url = URL(string: baseAccessURL), url.scheme == "https" else { | ||
loginError = .invalidURL | ||
return | ||
} | ||
withAnimation { | ||
currentPage = .sessionToken | ||
focusedField = .sessionToken | ||
} | ||
} | ||
|
||
private func back() { | ||
withAnimation { | ||
loginError = nil | ||
currentPage = .serverURL | ||
focusedField = .baseAccessURL | ||
} | ||
} | ||
} | ||
|
||
enum LoginError { | ||
case invalidURL | ||
case invalidToken | ||
case failedAuth | ||
|
||
var description: String { | ||
switch self { | ||
case .invalidURL: | ||
return "Invalid URL" | ||
case .invalidToken: | ||
return "Invalid Session Token" | ||
case .failedAuth: | ||
return "Could not authenticate with Coder deployment" | ||
} | ||
} | ||
} | ||
|
||
enum LoginPage { | ||
case serverURL | ||
case sessionToken | ||
} | ||
|
||
enum LoginField: Hashable { | ||
case baseAccessURL | ||
case sessionToken | ||
} | ||
|
||
#Preview { | ||
LoginForm<PreviewClient, PreviewSession>() | ||
.environmentObject(PreviewSession()) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import Combine | ||
|
||
// This is required for inspecting stateful views | ||
internal final class Inspection<V> { | ||
let notice = PassthroughSubject<UInt, Never>() | ||
var callbacks = [UInt: (V) -> Void]() | ||
|
||
func visit(_ view: V, _ line: UInt) { | ||
if let callback = callbacks.removeValue(forKey: line) { | ||
callback(view) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import SwiftUI | ||
|
||
// Window IDs | ||
enum Windows: String { | ||
case login | ||
} | ||
|
||
extension OpenWindowAction { | ||
// Type-safe wrapper for opening windows that also focuses the new window | ||
func callAsFunction(id: Windows) { | ||
#if compiler(>=5.9) && canImport(AppKit) | ||
if #available(macOS 14, *) { | ||
NSApp.activate() | ||
} else { | ||
NSApp.activate(ignoringOtherApps: true) | ||
} | ||
#else | ||
NSApp.activate(ignoringOtherApps: true) | ||
#endif | ||
callAsFunction(id: id.rawValue) | ||
// The arranging behaviour is flakey without this | ||
NSApp.arrangeInFront(nil) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
@testable import Coder_Desktop | ||
import ViewInspector | ||
import Testing | ||
import SwiftUI | ||
|
||
@Suite(.timeLimit(.minutes(1))) | ||
struct LoginTests { | ||
let session: MockSession | ||
let sut: LoginForm<MockClient, MockSession> | ||
let view: any View | ||
|
||
init() { | ||
session = MockSession() | ||
sut = LoginForm<MockClient, MockSession>() | ||
view = sut.environmentObject(session) | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testInitialView() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
#expect(throws: Never.self) { try view.find(text: "Coder Desktop") } | ||
#expect(throws: Never.self) { try view.find(text: "Server URL") } | ||
#expect(throws: Never.self) { try view.find(button: "Next") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testInvalidServerURL() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("") | ||
try view.find(button: "Next").tap() | ||
#expect(throws: Never.self) { try view.find(text: "Invalid URL") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testValidServerURL() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("https://coder.example.com") | ||
try view.find(button: "Next").tap() | ||
|
||
#expect(throws: Never.self) { try view.find(text: "Session Token") } | ||
#expect(throws: Never.self) { try view.find(ViewType.SecureField.self) } | ||
#expect(throws: Never.self) { try view.find(button: "Sign In") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testBackButton() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("https://coder.example.com") | ||
try view.find(button: "Next").tap() | ||
try view.find(button: "Back").tap() | ||
|
||
#expect(throws: Never.self) { try view.find(text: "Coder Desktop") } | ||
#expect(throws: Never.self) { try view.find(button: "Next") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testInvalidSessionToken() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("https://coder.example.com") | ||
try view.find(button: "Next").tap() | ||
try view.find(ViewType.SecureField.self).setInput("") | ||
try await view.actualView().submit() | ||
#expect(throws: Never.self) { try view.find(text: "Invalid Session Token") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testFailedAuthentication() async throws { | ||
let login = LoginForm<MockErrorClient, MockSession>() | ||
|
||
try await ViewHosting.host(login.environmentObject(session)) { _ in | ||
try await login.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("https://coder.example.com") | ||
try view.find(button: "Next").tap() | ||
#expect(throws: Never.self) { try view.find(text: "Session Token") } | ||
try view.find(ViewType.SecureField.self).setInput("valid-token") | ||
try await view.actualView().submit() | ||
#expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") } | ||
} | ||
} | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testSuccessfulLogin() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
try view.find(ViewType.TextField.self).setInput("https://coder.example.com") | ||
try view.find(button: "Next").tap() | ||
try view.find(ViewType.SecureField.self).setInput("valid-token") | ||
try view.find(button: "Sign In").tap() | ||
|
||
#expect(session.hasSession) | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,107 +1,126 @@ | ||
@testable import Coder_Desktop | ||
import Testing | ||
import ViewInspector | ||
import XCTest | ||
import SwiftUI | ||
|
||
final class VPNMenuTests: XCTestCase { | ||
func testVPNLoggedOut() throws { | ||
let vpn = MockVPNService() | ||
let session = MockSession() | ||
session.hasSession = false | ||
let view = VPNMenu(vpn: vpn, session: session) | ||
let toggle = try view.inspect().find(ViewType.Toggle.self) | ||
@Suite(.timeLimit(.minutes(1))) | ||
struct VPNMenuTests { | ||
let vpn: MockVPNService | ||
let session: MockSession | ||
let sut: VPNMenu<MockVPNService, MockSession> | ||
let view: any View | ||
|
||
XCTAssertTrue(toggle.isDisabled()) | ||
XCTAssertNoThrow(try view.inspect().find(text: "Login to use CoderVPN")) | ||
XCTAssertNoThrow(try view.inspect().find(button: "Login")) | ||
init() { | ||
vpn = MockVPNService() | ||
session = MockSession() | ||
sut = VPNMenu<MockVPNService, MockSession>() | ||
view = sut.environmentObject(vpn).environmentObject(session) | ||
} | ||
|
||
func testStartStopCalled() throws { | ||
let vpn = MockVPNService() | ||
let session = MockSession() | ||
let view = VPNMenu(vpn: vpn, session: session) | ||
let toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertFalse(try toggle.isOn()) | ||
|
||
var e = expectation(description: "start is called") | ||
vpn.onStart = { | ||
vpn.state = .connected | ||
e.fulfill() | ||
@Test | ||
@MainActor | ||
func testVPNLoggedOut() async throws { | ||
session.hasSession = false | ||
|
||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
let toggle = try view.find(ViewType.Toggle.self) | ||
#expect(toggle.isDisabled()) | ||
#expect(throws: Never.self) { try view.find(text: "Sign in to use CoderVPN") } | ||
#expect(throws: Never.self) { try view.find(button: "Sign In") } | ||
} | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
XCTAssertTrue(try toggle.isOn()) | ||
|
||
e = expectation(description: "stop is called") | ||
vpn.onStop = { | ||
vpn.state = .disabled | ||
e.fulfill() | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testStartStopCalled() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
var toggle = try view.find(ViewType.Toggle.self) | ||
#expect(try !toggle.isOn()) | ||
|
||
vpn.onStart = { | ||
vpn.state = .connected | ||
} | ||
await vpn.start() | ||
|
||
toggle = try view.find(ViewType.Toggle.self) | ||
#expect(try toggle.isOn()) | ||
|
||
vpn.onStop = { | ||
vpn.state = .disabled | ||
} | ||
await vpn.stop() | ||
#expect(try !toggle.isOn()) | ||
} | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
} | ||
|
||
func testVPNDisabledWhileConnecting() throws { | ||
let vpn = MockVPNService() | ||
let session = MockSession() | ||
@Test | ||
@MainActor | ||
func testVPNDisabledWhileConnecting() async throws { | ||
vpn.state = .disabled | ||
let view = VPNMenu(vpn: vpn, session: session) | ||
var toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertFalse(try toggle.isOn()) | ||
|
||
let e = expectation(description: "start is called") | ||
vpn.onStart = { | ||
e.fulfill() | ||
|
||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
var toggle = try view.find(ViewType.Toggle.self) | ||
#expect(try !toggle.isOn()) | ||
|
||
vpn.onStart = { | ||
vpn.state = .connecting | ||
} | ||
await vpn.start() | ||
|
||
toggle = try view.find(ViewType.Toggle.self) | ||
#expect(toggle.isDisabled()) | ||
} | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
|
||
toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertTrue(toggle.isDisabled()) | ||
} | ||
func testVPNDisabledWhileDisconnecting() throws { | ||
let vpn = MockVPNService() | ||
let session = MockSession() | ||
|
||
@Test | ||
@MainActor | ||
func testVPNDisabledWhileDisconnecting() async throws { | ||
vpn.state = .disabled | ||
let view = VPNMenu(vpn: vpn, session: session) | ||
var toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertFalse(try toggle.isOn()) | ||
|
||
var e = expectation(description: "start is called") | ||
vpn.onStart = { | ||
e.fulfill() | ||
vpn.state = .connected | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
|
||
e = expectation(description: "stop is called") | ||
vpn.onStop = { | ||
e.fulfill() | ||
|
||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
var toggle = try view.find(ViewType.Toggle.self) | ||
#expect(try !toggle.isOn()) | ||
|
||
vpn.onStart = { | ||
vpn.state = .connected | ||
} | ||
await vpn.start() | ||
#expect(try toggle.isOn()) | ||
|
||
vpn.onStop = { | ||
vpn.state = .disconnecting | ||
} | ||
await vpn.stop() | ||
|
||
toggle = try view.find(ViewType.Toggle.self) | ||
#expect(toggle.isDisabled()) | ||
} | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
|
||
toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertTrue(toggle.isDisabled()) | ||
} | ||
|
||
func testOffWhenFailed() throws { | ||
let vpn = MockVPNService() | ||
let session = MockSession() | ||
let view = VPNMenu(vpn: vpn, session: session) | ||
let toggle = try view.inspect().find(ViewType.Toggle.self) | ||
XCTAssertFalse(try toggle.isOn()) | ||
|
||
let e = expectation(description: "toggle is off") | ||
vpn.onStart = { | ||
vpn.state = .failed(.exampleError) | ||
e.fulfill() | ||
|
||
@Test | ||
@MainActor | ||
func testOffWhenFailed() async throws { | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
let toggle = try view.find(ViewType.Toggle.self) | ||
#expect(try !toggle.isOn()) | ||
|
||
vpn.onStart = { | ||
vpn.state = .failed(.exampleError) | ||
} | ||
await vpn.start() | ||
|
||
#expect(try !toggle.isOn()) | ||
#expect(!toggle.isDisabled()) | ||
} | ||
} | ||
try toggle.tap() | ||
wait(for: [e], timeout: 1.0) | ||
XCTAssertFalse(try toggle.isOn()) | ||
XCTAssertFalse(toggle.isDisabled()) | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,48 +1,84 @@ | ||
@testable import Coder_Desktop | ||
import ViewInspector | ||
import XCTest | ||
import Testing | ||
import SwiftUI | ||
|
||
final class VPNStateTests: XCTestCase { | ||
@Suite(.timeLimit(.minutes(1))) | ||
struct VPNStateTests { | ||
let vpn: MockVPNService | ||
let sut: VPNState<MockVPNService> | ||
let view: any View | ||
|
||
func testDisabledState() throws { | ||
let vpn = MockVPNService() | ||
init() { | ||
vpn = MockVPNService() | ||
sut = VPNState<MockVPNService>() | ||
view = sut.environmentObject(vpn) | ||
} | ||
|
||
@Test | ||
@MainActor | ||
func testDisabledState() async throws { | ||
vpn.state = .disabled | ||
let view = VPNState<MockVPNService>().environmentObject(vpn) | ||
_ = try view.inspect().find(text: "Enable CoderVPN to see agents") | ||
|
||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
#expect(throws: Never.self) { | ||
try view.find(text: "Enable CoderVPN to see agents") | ||
} | ||
} | ||
} | ||
} | ||
|
||
func testConnectingState() throws { | ||
let vpn = MockVPNService() | ||
@Test | ||
@MainActor | ||
func testConnectingState() async throws { | ||
vpn.state = .connecting | ||
let view = VPNState<MockVPNService>().environmentObject(vpn) | ||
|
||
let progressView = try view.inspect().find(ViewType.ProgressView.self) | ||
XCTAssertEqual(try progressView.labelView().text().string(), "Starting CoderVPN...") | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
let progressView = try view.find(ViewType.ProgressView.self) | ||
#expect(try progressView.labelView().text().string() == "Starting CoderVPN...") | ||
} | ||
} | ||
} | ||
|
||
func testDisconnectingState() throws { | ||
let vpn = MockVPNService() | ||
@Test | ||
@MainActor | ||
func testDisconnectingState() async throws { | ||
vpn.state = .disconnecting | ||
let view = VPNState<MockVPNService>().environmentObject(vpn) | ||
|
||
let progressView = try view.inspect().find(ViewType.ProgressView.self) | ||
XCTAssertEqual(try progressView.labelView().text().string(), "Stopping CoderVPN...") | ||
try await ViewHosting.host(view) { _ in | ||
try await sut.inspection.inspect { view in | ||
let progressView = try view.find(ViewType.ProgressView.self) | ||
#expect(try progressView.labelView().text().string() == "Stopping CoderVPN...") | ||
} | ||
} | ||
} | ||
|
||
func testFailedState() throws { | ||
let vpn = MockVPNService() | ||
@Test | ||
@MainActor | ||
func testFailedState() async throws { | ||
vpn.state = .failed(.exampleError) | ||
let view = VPNState<MockVPNService>().environmentObject(vpn) | ||
|
||
let text = try view.inspect().find(ViewType.Text.self) | ||
XCTAssertEqual(try text.string(), VPNServiceError.exampleError.description) | ||
try await ViewHosting.host(view.environmentObject(vpn)) { _ in | ||
try await sut.inspection.inspect { view in | ||
let text = try view.find(ViewType.Text.self) | ||
#expect(try text.string() == VPNServiceError.exampleError.description) | ||
} | ||
} | ||
} | ||
|
||
func testDefaultState() throws { | ||
let vpn = MockVPNService() | ||
@Test | ||
@MainActor | ||
func testDefaultState() async throws { | ||
vpn.state = .connected | ||
let view = VPNState<MockVPNService>().environmentObject(vpn) | ||
|
||
XCTAssertThrowsError(try view.inspect().find(ViewType.Text.self)) | ||
try await ViewHosting.host(view.environmentObject(vpn)) { _ in | ||
try await sut.inspection.inspect { view in | ||
#expect(throws: (any Error).self) { | ||
_ = try view.find(ViewType.Text.self) | ||
} | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.