mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-09-16 09:20:56 +00:00
Tweaks.
This commit is contained in:
parent
b949d846c1
commit
cd76bb95ec
@ -52,6 +52,7 @@
|
|||||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||||
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
|
||||||
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */; };
|
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */; };
|
||||||
|
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */; };
|
||||||
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
|
||||||
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
|
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -142,6 +143,7 @@
|
|||||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
|
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
|
||||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
|
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
|
||||||
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStatusView.swift; sourceTree = "<group>"; };
|
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStatusView.swift; sourceTree = "<group>"; };
|
||||||
|
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorStyle.swift; sourceTree = "<group>"; };
|
||||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
|
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
|
||||||
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
|
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -259,6 +261,7 @@
|
|||||||
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
|
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
|
||||||
50AE96FF2E5C1A420018C710 /* ConfigurationView.swift */,
|
50AE96FF2E5C1A420018C710 /* ConfigurationView.swift */,
|
||||||
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
|
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
|
||||||
|
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -436,6 +439,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */,
|
||||||
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
|
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
|
||||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||||
|
@ -15,17 +15,21 @@ struct LaunchAgentController {
|
|||||||
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
|
||||||
// and start new?
|
// and start new?
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
return await MainActor.run {
|
let result = await MainActor.run {
|
||||||
setEnabled(true)
|
setEnabled(true)
|
||||||
}
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func uninstall() async -> Bool {
|
func uninstall() async -> Bool {
|
||||||
logger.debug("Uninstalling agent")
|
logger.debug("Uninstalling agent")
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
return await MainActor.run {
|
let result = await MainActor.run {
|
||||||
setEnabled(false)
|
setEnabled(false)
|
||||||
}
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func forceLaunch() async -> Bool {
|
func forceLaunch() async -> Bool {
|
||||||
@ -36,6 +40,7 @@ struct LaunchAgentController {
|
|||||||
do {
|
do {
|
||||||
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
|
||||||
logger.debug("Agent force launched")
|
logger.debug("Agent force launched")
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error force launching \(error.localizedDescription)")
|
logger.error("Error force launching \(error.localizedDescription)")
|
||||||
|
@ -3,81 +3,63 @@ import SwiftUI
|
|||||||
struct AgentStatusView: View {
|
struct AgentStatusView: View {
|
||||||
|
|
||||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||||
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if agentStatusChecker.running {
|
if agentStatusChecker.running {
|
||||||
Form {
|
AgentRunningView()
|
||||||
Section {
|
} else {
|
||||||
if let process = agentStatusChecker.process {
|
AgentNotRunningView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct AgentRunningView: View {
|
||||||
|
|
||||||
|
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||||
|
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if let process = agentStatusChecker.process {
|
||||||
|
AgentInformationView(
|
||||||
|
title: "Secret Agent Location",
|
||||||
|
value: process.bundleURL!.path(),
|
||||||
|
actions: [.revealInFinder],
|
||||||
|
)
|
||||||
|
AgentInformationView(
|
||||||
|
title: "Socket Path",
|
||||||
|
value: socketPath,
|
||||||
|
actions: [.copy],
|
||||||
|
)
|
||||||
|
AgentInformationView(
|
||||||
|
title: "Version",
|
||||||
|
value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String
|
||||||
|
)
|
||||||
|
if let launchDate = process.launchDate {
|
||||||
AgentInformationView(
|
AgentInformationView(
|
||||||
title: "Secret Agent Location",
|
title: "Running Since",
|
||||||
value: process.bundleURL!.path(),
|
value: launchDate.formatted()
|
||||||
actions: [.revealInFinder],
|
|
||||||
)
|
)
|
||||||
AgentInformationView(
|
|
||||||
title: "Socket Path",
|
|
||||||
value: socketPath,
|
|
||||||
actions: [.copy],
|
|
||||||
)
|
|
||||||
AgentInformationView(
|
|
||||||
title: "Version",
|
|
||||||
value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String
|
|
||||||
)
|
|
||||||
if let launchDate = process.launchDate {
|
|
||||||
AgentInformationView(
|
|
||||||
title: "Running Since",
|
|
||||||
value: launchDate.formatted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} header: {
|
}
|
||||||
Text(.agentRunningNoticeDetailTitle)
|
} header: {
|
||||||
.font(.headline)
|
Text(.agentRunningNoticeDetailTitle)
|
||||||
.padding(.top)
|
.font(.headline)
|
||||||
} footer: {
|
.padding(.top)
|
||||||
VStack(alignment: .leading) {
|
} footer: {
|
||||||
Text(.agentRunningNoticeDetailDescription)
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
Text(.agentRunningNoticeDetailDescription)
|
||||||
Spacer()
|
HStack {
|
||||||
Menu("Restart Agent") {
|
Spacer()
|
||||||
Button("Disable Agent") {
|
Menu("Restart Agent") {
|
||||||
Task {
|
Button("Disable Agent") {
|
||||||
await LaunchAgentController()
|
|
||||||
.uninstall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} primaryAction: {
|
|
||||||
Task {
|
Task {
|
||||||
let controller = LaunchAgentController()
|
_ = await LaunchAgentController()
|
||||||
let installed = await controller.install()
|
.uninstall()
|
||||||
if !installed {
|
|
||||||
_ = await controller.forceLaunch()
|
|
||||||
}
|
|
||||||
agentStatusChecker.check()
|
agentStatusChecker.check()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} primaryAction: {
|
||||||
}
|
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
.frame(width: 400)
|
|
||||||
} else {
|
|
||||||
Form {
|
|
||||||
Section {
|
|
||||||
} header: {
|
|
||||||
Text(.agentNotRunningNoticeTitle)
|
|
||||||
.font(.headline)
|
|
||||||
.padding(.top)
|
|
||||||
} footer: {
|
|
||||||
Text(.agentNotRunningNoticeDetailDescription)
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button("Start Agent") {
|
|
||||||
Task {
|
Task {
|
||||||
let controller = LaunchAgentController()
|
let controller = LaunchAgentController()
|
||||||
let installed = await controller.install()
|
let installed = await controller.install()
|
||||||
@ -87,14 +69,77 @@ struct AgentStatusView: View {
|
|||||||
agentStatusChecker.check()
|
agentStatusChecker.check()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.primary()
|
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
|
||||||
}
|
}
|
||||||
|
.padding(.vertical)
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
.frame(width: 400)
|
|
||||||
}
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AgentNotRunningView: View {
|
||||||
|
|
||||||
|
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||||
|
@State var triedRestart = false
|
||||||
|
@State var loading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
} header: {
|
||||||
|
Text(.agentNotRunningNoticeTitle)
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.top)
|
||||||
|
} footer: {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(.agentNotRunningNoticeDetailDescription)
|
||||||
|
HStack {
|
||||||
|
if !triedRestart {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
guard !loading else { return }
|
||||||
|
loading = true
|
||||||
|
Task {
|
||||||
|
let controller = LaunchAgentController()
|
||||||
|
let installed = await controller.install()
|
||||||
|
if !installed {
|
||||||
|
_ = await controller.forceLaunch()
|
||||||
|
}
|
||||||
|
agentStatusChecker.check()
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
if !agentStatusChecker.running {
|
||||||
|
triedRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if !loading {
|
||||||
|
Text("Start Agent")
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text("Starting Agent")
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.primary()
|
||||||
|
} else {
|
||||||
|
Text("Secretive was unable to get SecretAgent to launch. Please try restarting your Mac, and if that doesn't work, file an issue on GitHub.")
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(width: 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -110,11 +110,11 @@ extension ContentView {
|
|||||||
})
|
})
|
||||||
.sheet(isPresented: $showingCreation) {
|
.sheet(isPresented: $showingCreation) {
|
||||||
if let modifiable = storeList.modifiableStore {
|
if let modifiable = storeList.modifiableStore {
|
||||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
CreateSecretView(store: modifiable, showing: $showingCreation) { created in
|
||||||
.onDisappear {
|
if let created {
|
||||||
guard let newest = modifiable.secrets.last else { return }
|
activeSecret = created
|
||||||
activeSecret = newest
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,14 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
|
|
||||||
@State var store: StoreType
|
@State var store: StoreType
|
||||||
@Binding var showing: Bool
|
@Binding var showing: Bool
|
||||||
|
var createdSecret: (AnySecret?) -> Void
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var keyAttribution = ""
|
@State private var keyAttribution = ""
|
||||||
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
||||||
@State private var keyType: KeyType?
|
@State private var keyType: KeyType?
|
||||||
@State var advanced = false
|
@State var advanced = false
|
||||||
|
@State var errorText: String?
|
||||||
|
|
||||||
private var authenticationOptions: [AuthenticationRequirement] {
|
private var authenticationOptions: [AuthenticationRequirement] {
|
||||||
if advanced || authenticationRequirement == .biometryCurrent {
|
if advanced || authenticationRequirement == .biometryCurrent {
|
||||||
@ -94,6 +96,13 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let errorText {
|
||||||
|
Section {
|
||||||
|
} footer: {
|
||||||
|
Text(verbatim: errorText)
|
||||||
|
.errorStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
||||||
@ -118,20 +127,25 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
func save() {
|
func save() {
|
||||||
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||||
Task {
|
Task {
|
||||||
try! await store.create(
|
do {
|
||||||
name: name,
|
let new = try await store.create(
|
||||||
attributes: .init(
|
name: name,
|
||||||
keyType: keyType!,
|
attributes: .init(
|
||||||
authentication: authenticationRequirement,
|
keyType: keyType!,
|
||||||
publicKeyAttribution: attribution
|
authentication: authenticationRequirement,
|
||||||
|
publicKeyAttribution: attribution
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
createdSecret(AnySecret(new))
|
||||||
showing = false
|
showing = false
|
||||||
|
} catch {
|
||||||
|
errorText = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true)) { _ in }
|
||||||
}
|
}
|
||||||
|
@ -28,8 +28,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
|||||||
TextField(secret.name, text: $confirmedSecretName)
|
TextField(secret.name, text: $confirmedSecretName)
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(verbatim: errorText)
|
Text(verbatim: errorText)
|
||||||
.foregroundStyle(.red)
|
.errorStyle()
|
||||||
.font(.callout)
|
|
||||||
}
|
}
|
||||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||||
.disabled(confirmedSecretName != secret.name)
|
.disabled(confirmedSecretName != secret.name)
|
||||||
|
@ -30,11 +30,11 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
} footer: {
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(verbatim: errorText)
|
Text(verbatim: errorText)
|
||||||
.foregroundStyle(.red)
|
.errorStyle()
|
||||||
.font(.callout)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
|
19
Sources/Secretive/Views/ErrorStyle.swift
Normal file
19
Sources/Secretive/Views/ErrorStyle.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ErrorStyleModifier: ViewModifier {
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
func errorStyle() -> some View {
|
||||||
|
modifier(ErrorStyleModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user