Almost done with user management

This commit is contained in:
Philipp Heckel 2022-06-05 10:52:10 -04:00
parent 64e9763604
commit f6c9cc5ef3
5 changed files with 223 additions and 70 deletions

View file

@ -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()

View file

@ -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?

View file

@ -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 }

View file

@ -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) {

View file

@ -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 {