Almost done with user management
This commit is contained in:
parent
64e9763604
commit
f6c9cc5ef3
5 changed files with 223 additions and 70 deletions
|
|
@ -81,6 +81,11 @@ class Store: ObservableObject {
|
|||
return try? context.fetch(fetchRequest).first
|
||||
}
|
||||
|
||||
func delete(user: User) {
|
||||
context.delete(user)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func delete(subscription: Subscription) {
|
||||
context.delete(subscription)
|
||||
try? context.save()
|
||||
|
|
|
|||
|
|
@ -64,23 +64,32 @@ class ApiService {
|
|||
}.resume()
|
||||
}
|
||||
|
||||
func checkAuth(baseUrl: String, topic: String, user: BasicUser?, completionHandler: @escaping(AuthCheckResponse?, Error?) -> Void) {
|
||||
func checkAuth(baseUrl: String, topic: String, user: BasicUser?, completionHandler: @escaping(AuthResult) -> Void) {
|
||||
guard let url = URL(string: topicAuthUrl(baseUrl: baseUrl, topic: topic)) else { return }
|
||||
let request = newRequest(url: url, user: user)
|
||||
Log.d(tag, "Checking auth for \(url) with user \(user?.username ?? "anonymous")")
|
||||
URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
Log.e(self.tag, "Error checking auth: \(error)")
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
if let data = data {
|
||||
completionHandler(.Error(error.localizedDescription))
|
||||
} else if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
||||
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
completionHandler(.Unauthorized)
|
||||
} else {
|
||||
completionHandler(.Error("Unexpected response from server: \(httpResponse.statusCode)"))
|
||||
}
|
||||
} else if let data = data {
|
||||
do {
|
||||
let result = try JSONDecoder().decode(AuthCheckResponse.self, from: data)
|
||||
Log.d(self.tag, "Auth result: \(result)")
|
||||
completionHandler(result, nil)
|
||||
if result.success == true {
|
||||
completionHandler(.Success)
|
||||
} else {
|
||||
completionHandler(.Error("Unexpected response from server"))
|
||||
}
|
||||
} catch {
|
||||
Log.e(self.tag, "Error handling auth response: \(error)")
|
||||
completionHandler(nil, error)
|
||||
completionHandler(.Error("Unexpected response from server. Is this a ntfy server?"))
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
|
|
@ -128,6 +137,12 @@ struct BasicUser {
|
|||
}
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
case Success
|
||||
case Unauthorized
|
||||
case Error(String)
|
||||
}
|
||||
|
||||
struct AuthCheckResponse: Codable {
|
||||
let success: Bool?
|
||||
let code: Int?
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ func topicUrl(baseUrl: String, topic: String) -> String {
|
|||
}
|
||||
|
||||
func topicShortUrl(baseUrl: String, topic: String) -> String {
|
||||
return topicUrl(baseUrl: baseUrl, topic: topic)
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
return shortUrl(url: topicUrl(baseUrl: baseUrl, topic: topic))
|
||||
}
|
||||
|
||||
func topicAuthUrl(baseUrl: String, topic: String) -> String {
|
||||
|
|
@ -21,6 +19,12 @@ func topicHash(baseUrl: String, topic: String) -> String {
|
|||
return digest.compactMap { String(format: "%02x", $0)}.joined()
|
||||
}
|
||||
|
||||
func shortUrl(url: String) -> String {
|
||||
return url
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
}
|
||||
|
||||
func parseAllTags(_ tags: String?) -> [String] {
|
||||
return (tags?.components(separatedBy: ",") ?? [])
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ struct SettingsView: View {
|
|||
Text("Manage users")
|
||||
}
|
||||
}*/
|
||||
Section(header: Text("Users")) {
|
||||
Section(
|
||||
header: Text("Users"),
|
||||
footer: Text("To access read-protected topics, you may add or edit users here. All topics for a given server will use the same user.")
|
||||
) {
|
||||
UsersView()
|
||||
}
|
||||
Section(header: Text("About")) {
|
||||
|
|
@ -20,7 +23,7 @@ struct SettingsView: View {
|
|||
Text("Version")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("ntfy 1.1")
|
||||
Text("ntfy \(Config.version) (\(Config.build))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +61,7 @@ struct UsersView: View {
|
|||
Image(systemName: "plus")
|
||||
Text("Add user")
|
||||
}
|
||||
.padding(.all, 4)
|
||||
.onTapGesture {
|
||||
showDialog = true
|
||||
}
|
||||
|
|
@ -65,20 +69,20 @@ struct UsersView: View {
|
|||
.sheet(isPresented: $showDialog) {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(footer:
|
||||
Text("You can add a user here. All topics for the given server will use this user.")
|
||||
Section(
|
||||
footer: (selectedUser == nil)
|
||||
? Text("You can add a user here. All topics for the given server will use this user.")
|
||||
: Text("Edit the username or password for \(shortUrl(url: baseUrl)) here. This user is used for all topics of this server. Leave the password blank to leave it unchanged.")
|
||||
) {
|
||||
if selectedUser == nil {
|
||||
TextField("Service URL, e.g. https://ntfy.example.com", text: $baseUrl)
|
||||
TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
TextField("Username", text: $username)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
TextField("Password", text: $password)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
}
|
||||
.navigationTitle(selectedUser == nil ? "Add user" : "Edit user")
|
||||
|
|
@ -101,6 +105,10 @@ struct UsersView: View {
|
|||
}
|
||||
|
||||
private func saveAction() {
|
||||
var password = password
|
||||
if let user = selectedUser, password == "" {
|
||||
password = user.password ?? "?" // If password is blank, leave unchanged
|
||||
}
|
||||
store.saveUser(baseUrl: baseUrl, username: username, password: password)
|
||||
resetAndHide()
|
||||
}
|
||||
|
|
@ -110,22 +118,52 @@ struct UsersView: View {
|
|||
}
|
||||
|
||||
private func isValid() -> Bool {
|
||||
return true // FIXME: validate
|
||||
if selectedUser == nil { // New user
|
||||
if baseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
|
||||
return false
|
||||
} else if username.isEmpty || password.isEmpty {
|
||||
return false
|
||||
}
|
||||
} else { // Existing user
|
||||
if username.isEmpty {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func resetAndHide() {
|
||||
selectedUser = nil
|
||||
baseUrl = ""
|
||||
username = ""
|
||||
password = ""
|
||||
showDialog = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
// Hide first and then reset, otherwise we'll see the text fields change
|
||||
selectedUser = nil
|
||||
baseUrl = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserRowView: View {
|
||||
@EnvironmentObject private var store: Store
|
||||
@ObservedObject var user: User
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
userRow
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
store.delete(user: user)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.circle")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userRow
|
||||
}
|
||||
}
|
||||
|
||||
private var userRow: some View {
|
||||
HStack {
|
||||
Image(systemName: "person.fill")
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ struct SubscriptionAddView: View {
|
|||
@State private var showLogin: Bool = false
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
|
||||
@State private var loading = false
|
||||
@State private var addError: String?
|
||||
@State private var loginError: String?
|
||||
|
||||
|
||||
private var subscriptionManager: SubscriptionManager {
|
||||
return SubscriptionManager(store: store)
|
||||
|
|
@ -20,10 +25,27 @@ struct SubscriptionAddView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
// This is a little weird, but it works. The nagivation link for the login view
|
||||
// is rendered in the backgroun (it's hidden), abd we toggle it manually.
|
||||
// If anyone has a better way to do a two-page layout let me know.
|
||||
|
||||
addView
|
||||
.background(Group {
|
||||
NavigationLink(
|
||||
destination: loginView,
|
||||
isActive: $showLogin
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private var addView: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Form {
|
||||
Section(
|
||||
footer:
|
||||
Text("Topics are not password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
|
||||
footer: Text("Topics are not password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
|
||||
) {
|
||||
TextField("Topic name, e.g. phil_alerts", text: $topic)
|
||||
.disableAutocapitalization()
|
||||
|
|
@ -35,50 +57,56 @@ struct SubscriptionAddView: View {
|
|||
) {
|
||||
Toggle("Use another server", isOn: $useAnother)
|
||||
if useAnother {
|
||||
TextField("Server URL, e.g. https://ntfy.example.com", text: $baseUrl)
|
||||
TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add subscription")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: cancelAction) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: subscribeOrShowLoginAction) {
|
||||
Text("Subscribe")
|
||||
}
|
||||
.disabled(!isValid())
|
||||
if let error = addError {
|
||||
ErrorView(error: error)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add subscription")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: cancelAction) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
.background(Group {
|
||||
NavigationLink(
|
||||
destination: loginView,
|
||||
isActive: $showLogin
|
||||
) {
|
||||
EmptyView()
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: subscribeOrShowLoginAction) {
|
||||
VStack {
|
||||
if loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
} else {
|
||||
Text("Subscribe")
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
}
|
||||
})
|
||||
.disabled(!isAddViewValid())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loginView: some View {
|
||||
Form {
|
||||
Section(
|
||||
footer:
|
||||
Text("This topic requires that you login with username and password. The user will be stored on your device, and will be re-used for other topics.")
|
||||
) {
|
||||
TextField("Username", text: $username)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
TextField("Password", text: $password)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Form {
|
||||
Section(
|
||||
footer: Text("This topic requires that you login with username and password. The user will be stored on your device, and will be re-used for other topics.")
|
||||
) {
|
||||
TextField("Username", text: $username)
|
||||
.disableAutocapitalization()
|
||||
.disableAutocorrection(true)
|
||||
SecureField("Password", text: $password)
|
||||
}
|
||||
}
|
||||
if let error = loginError {
|
||||
ErrorView(error: error)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Login required")
|
||||
|
|
@ -86,9 +114,14 @@ struct SubscriptionAddView: View {
|
|||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: subscribeWithUserAction) {
|
||||
Text("Subscribe")
|
||||
if loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
} else {
|
||||
Text("Subscribe")
|
||||
}
|
||||
}
|
||||
.disabled(!isValid())
|
||||
.disabled(!isLoginViewValid())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,53 +130,111 @@ struct SubscriptionAddView: View {
|
|||
return topic.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private func isValid() -> Bool {
|
||||
private func isAddViewValid() -> Bool {
|
||||
if sanitizedTopic.isEmpty {
|
||||
return false
|
||||
} else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil {
|
||||
return false
|
||||
} else if selectedBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
|
||||
return false
|
||||
} else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func isLoginViewValid() -> Bool {
|
||||
if username.isEmpty || password.isEmpty {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func subscribeOrShowLoginAction() {
|
||||
loading = true
|
||||
addError = nil
|
||||
let user = store.getUser(baseUrl: selectedBaseUrl)?.toBasicUser()
|
||||
ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { (response, error) in
|
||||
if response?.success == true {
|
||||
ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
|
||||
switch result {
|
||||
case .Success:
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
|
||||
resetAndHide()
|
||||
}
|
||||
isShowing = false
|
||||
} else {
|
||||
showLogin = true
|
||||
// Do not reset "loading", because resetAndHide() will do that after everything is done
|
||||
case .Unauthorized:
|
||||
if let user = user {
|
||||
addError = "User \(user.username) is not authorized to read this topic"
|
||||
} else {
|
||||
addError = nil // Reset
|
||||
showLogin = true
|
||||
}
|
||||
loading = false
|
||||
case .Error(let err):
|
||||
addError = err
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribeWithUserAction() {
|
||||
loading = true
|
||||
loginError = nil
|
||||
let user = BasicUser(username: username, password: password)
|
||||
ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { (response, error) in
|
||||
if response?.success == true {
|
||||
ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
|
||||
switch result {
|
||||
case .Success:
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
store.saveUser(baseUrl: selectedBaseUrl, username: username, password: password)
|
||||
subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
|
||||
resetAndHide()
|
||||
}
|
||||
isShowing = false
|
||||
} else {
|
||||
showLogin = true
|
||||
// Do not reset "loading", because resetAndHide() will do that after everything is done
|
||||
case .Unauthorized:
|
||||
loginError = "Invalid credentials, or user \(username) is not authorized to read this topic"
|
||||
loading = false
|
||||
case .Error(let err):
|
||||
loginError = err
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelAction() {
|
||||
isShowing = false
|
||||
resetAndHide()
|
||||
}
|
||||
|
||||
private var selectedBaseUrl: String {
|
||||
return (useAnother) ? baseUrl : Config.appBaseUrl
|
||||
}
|
||||
|
||||
private func resetAndHide() {
|
||||
isShowing = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
// Hide first and then reset, otherwise we'll see the text fields change
|
||||
addError = nil
|
||||
loginError = nil
|
||||
loading = false
|
||||
baseUrl = ""
|
||||
topic = ""
|
||||
useAnother = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorView: View {
|
||||
var error: String
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.title2)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding([.leading, .trailing], 20)
|
||||
.padding([.top, .bottom], 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionAddView_Previews: PreviewProvider {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue