diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
| commit | 213710bf5bd6413c747bf126db50816ef5de5a6e (patch) | |
| tree | 912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/Accounts | |
| parent | f466b21dc02f78c984ba6748e703f6780a7a0db4 (diff) | |
| parent | 6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff) | |
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/Accounts')
| -rw-r--r-- | Hotline/macOS/Accounts/AccountDetailsView.swift | 275 | ||||
| -rw-r--r-- | Hotline/macOS/Accounts/AccountManagerView.swift | 217 |
2 files changed, 492 insertions, 0 deletions
diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift new file mode 100644 index 0000000..8514873 --- /dev/null +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -0,0 +1,275 @@ +import SwiftUI + +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + +enum AccountDetailsError: Error { + case noLogin + case failedToSave + + var alertTitle: String { + switch self { + case .noLogin: "A login is required" + case .failedToSave: "Failed to save account" + } + } + + var alertMessage: String { + switch self { + case .noLogin: "Users with accounts are required to have a login. Please add one and try again." + case .failedToSave: "An error occurred while saving this account. Please try again." + } + } +} + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + @State private var alertTitle: String = "" + @State private var alertMessage: String = "" + @State private var alertShown: Bool = false + + var body: some View { + self.detailsView + .alert(self.alertTitle, isPresented: self.$alertShown, actions: { + if #available(macOS 26.0, *) { + Button("OK", role: .confirm) { + self.alertShown = false + } + } + else { + Button("OK") { + self.alertShown = false + } + } + + }, message: { + Text(self.alertMessage) + }) + .onAppear { + // Display a placeholder for accounts that have been saved to the server + // because we don't have the account password on hand to display. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER + } + } + .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button { + self.dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + } + catch let error as AccountDetailsError { + self.alertTitle = error.alertTitle + self.alertMessage = error.alertMessage + self.alertShown = true + } + } + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) + } + } + } + + private func save() async throws { + guard !self.account.login.isBlank else { + throw AccountDetailsError.noLogin + } + + self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines) + self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines) + + self.saving = true + defer { self.saving = false } + + // We create a name var here so we don't see the default account name + // flash in the UI while saving. + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME + } + + do { + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + + } else { + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) + } + } + catch { + throw AccountDetailsError.failedToSave + } + + self.account.persisted = true + self.account.name = accountName + self.saved?(self.account) + } + + private var isEditable: Bool { + self.model.access?.contains(.canModifyUsers) == true + } + + private var detailsView: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + .disabled(!self.isEditable) + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + .sectionActions { + HStack { + Spacer() + Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + } + .disabled(!self.isEditable) + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) +// .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) +// .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) +// .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) +// .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) +// .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) +// .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) +// .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) +// .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) +// .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) +// .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) +// .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) +// .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) +// .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) +// .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) +// .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) +// .disabled(model.access?.contains(.canMakeAliases) == false) + } + .disabled(!self.isEditable) + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) +// .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) +// .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) +// .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) +// .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) +// .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) +// .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) +// .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + .disabled(!self.isEditable) + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) +// .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) +// .disabled(model.access?.contains(.canBroadcast) == false) + } + .disabled(!self.isEditable) + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) +// .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) +// .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) +// .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) +// .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) +// .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) +// .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) +// .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + .disabled(!self.isEditable) + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) +// .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) +// .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) +// .disabled(model.access?.contains(.canSendChat) == false) + } + .disabled(!self.isEditable) + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) +// .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) +// .disabled(model.access?.contains(.canSkipAgreement) == false) + } + .disabled(!self.isEditable) + } + .formStyle(.grouped) + } +} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift new file mode 100644 index 0000000..d288196 --- /dev/null +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -0,0 +1,217 @@ +import SwiftUI + +let DEFAULT_ACCOUNT_NAME = "Untitled Account" + +struct AccountManagerView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var accounts: [HotlineAccount] = [] + @State private var selection: HotlineAccount? + @State private var loading: Bool = true + + @State private var creatorShown: Bool = false + @State private var deleteConfirm: Bool = false + @State private var accountToEdit: HotlineAccount? = nil + @State private var accountToDelete: HotlineAccount? = nil + + private func newAccount() { + self.creatorShown = true + } + + private func editAccount(_ account: HotlineAccount) { + // Always get the latest version from the array to avoid stale data + if let currentAccount = self.accounts.first(where: { $0.id == account.id }) { + self.accountToEdit = currentAccount + } + } + + private func deleteAccount(_ account: HotlineAccount) { + self.accountToDelete = account + self.deleteConfirm = true + } + + var body: some View { + VStack(spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text("Accounts") + .font(.headline) + + Spacer() + + HStack { + Button { + self.newAccount() + } label: { + Image(systemName: "plus") + .padding(4) + } + .buttonBorderShape(.circle) + .help("New Account") + + Button { + if let account = self.selection { + self.editAccount(account) + } + } label: { + Image(systemName: "pencil") + .padding(4) + } + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Edit Account") + + Button { + if let account = self.selection { + self.deleteAccount(account) + } + } label: { + Image(systemName: "trash") + .padding(4) + } + .tint(.hotlineRed) + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Delete Account") + } + } + .padding(.horizontal, 16) + .padding(.top, 24) + + self.accountList +// .overlay { +// if self.loading { +// ProgressView() +// .progressViewStyle(.linear) +// .controlSize(.extraLarge) +// .frame(width: 100) +// } +// } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if self.loading { + do { + self.accounts = try await self.model.getAccounts() + } + catch { + self.dismiss() + } + + self.loading = false + } + } + .toolbar { + if self.loading { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + self.dismiss() + } label: { + Text("OK") + } + } + } + } + + private var accountList: some View { + List(self.accounts, id: \.self, selection: self.$selection) { account in + HStack(spacing: 5) { + Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User") + .frame(width: 16, height: 16) + .opacity((account.access.rawValue == 0) ? 0.5 : 1.0) + Text(account.name) + .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary)) + + Spacer() + + Text(account.login) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .contextMenu(forSelectionType: HotlineAccount.self) { items in + Button { + if let item = items.first { + self.editAccount(item) + } + } label: { + Label("Edit Account...", systemImage: "pencil") + } + .disabled(items.isEmpty) + + Divider() + + Button(role: .destructive) { + if let item = items.first { + self.deleteAccount(item) + } + } label: { + Label("Delete Account...", systemImage: "trash") + } + .disabled(items.isEmpty) + } primaryAction: { items in + if let account = items.first { + self.editAccount(account) + } + } + .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: { + Button("Delete", role: .destructive) { + guard let account = self.accountToDelete else { + return + } + + self.accountToDelete = nil + + Task { + self.selection = nil + + if account.persisted { + try await self.model.deleteUser(login: account.login) + } + + self.accounts = self.accounts.filter { $0.id != account.id } + self.deleteConfirm = false + } + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(item: self.$accountToEdit) { account in + AccountDetailsView(account: account) { editedAccount in + if let i = self.accounts.firstIndex(of: editedAccount) { + self.accounts.remove(at: i) + self.accounts.insert(editedAccount, at: i) + } + self.accounts.sort { $0.name < $1.name } + self.selection = editedAccount + self.accountToEdit = nil + } + .id(account.id) + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + .sheet(isPresented: self.$creatorShown) { + AccountDetailsView { newAccount in + self.accounts.append(newAccount) + self.accounts.sort { $0.name < $1.name } + self.selection = newAccount + } + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + } +} + + |