feat: Implement persistent logout on 401 errors and hide TabBar in detail views
- Add AppViewModel to manage app-level state and handle 401 responses - Implement automatic logout when API returns 401 Unauthorized - Add persistent logout state using existing hasFinishedSetup flag - Move NavigationStack outside TabView to enable automatic TabBar hiding - Update API classes to send UnauthorizedAPIResponse notifications - TabBar now hides automatically when navigating to detail views
This commit is contained in:
parent
660f271982
commit
953ff5da8d
@ -48,6 +48,9 @@
|
||||
},
|
||||
"%lld min" : {
|
||||
|
||||
},
|
||||
"%lld minutes" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
|
||||
@ -99,6 +102,12 @@
|
||||
},
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||
|
||||
},
|
||||
"Automatic sync" : {
|
||||
|
||||
},
|
||||
"Automatically mark articles as read" : {
|
||||
|
||||
},
|
||||
"Available tags" : {
|
||||
|
||||
@ -111,6 +120,9 @@
|
||||
},
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
||||
|
||||
},
|
||||
"Clear cache" : {
|
||||
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
@ -120,6 +132,9 @@
|
||||
},
|
||||
"Critical" : {
|
||||
|
||||
},
|
||||
"Data Management" : {
|
||||
|
||||
},
|
||||
"Debug" : {
|
||||
|
||||
@ -258,6 +273,9 @@
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
},
|
||||
"Open external links in in-app Safari" : {
|
||||
|
||||
},
|
||||
"Optional: Custom title" : {
|
||||
|
||||
@ -301,12 +319,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reading Settings" : {
|
||||
|
||||
},
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
|
||||
},
|
||||
"Reset settings" : {
|
||||
|
||||
},
|
||||
"Reset to Defaults" : {
|
||||
|
||||
@ -316,6 +340,9 @@
|
||||
},
|
||||
"Resume listening" : {
|
||||
|
||||
},
|
||||
"Safari Reader Mode" : {
|
||||
|
||||
},
|
||||
"Save bookmark" : {
|
||||
|
||||
@ -364,6 +391,12 @@
|
||||
},
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Sync interval" : {
|
||||
|
||||
},
|
||||
"Sync Settings" : {
|
||||
|
||||
},
|
||||
"Syncing with server..." : {
|
||||
|
||||
|
||||
@ -39,6 +39,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
@ -87,6 +92,11 @@ class SimpleAPI {
|
||||
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
if httpResponse.statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
||||
}
|
||||
}
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
|
||||
41
documentation/401.md
Normal file
41
documentation/401.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Feature: Persistentes Logout bei 401 Unauthorized
|
||||
|
||||
## Problemstellung
|
||||
Wenn eine API-Anfrage mit einem `401 Unauthorized`-Response fehlschlägt, bedeutet dies, dass der aktuell gespeicherte Token oder die Session des Nutzers ungültig ist (z. B. durch manuelles Löschen des Tokens im Backend oder andere Ursachen).
|
||||
In diesem Zustand darf der User nicht weiter mit einer scheinbar gültigen Session in der App interagieren.
|
||||
|
||||
## Ziel
|
||||
Die App soll den Nutzer in einem solchen Fall automatisch vollständig **ausloggen** und auf den **Setup-/Login-Screen** umleiten.
|
||||
Dies muss **persistiert** sein, d. h. auch nach App-Neustart darf der Nutzer nicht eingeloggt zurückkehren, solange keine erfolgreiche neue Anmeldung durchgeführt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Anforderungen
|
||||
|
||||
1. **Erkennen von ungültigem Token**
|
||||
- Jede API-Antwort mit `401 Unauthorized` löst den Logout-Prozess aus.
|
||||
- Optional: Kontext beachten (z. B. ob der Request ein Refresh-Token war).
|
||||
|
||||
2. **Logout-Mechanismus**
|
||||
- Alle gespeicherten Zugangsdaten (Access Token, Refresh Token, User-Daten im Keychain/Storage) werden gelöscht.
|
||||
- UI-State wird in den "nicht eingeloggten" Zustand zurückversetzt.
|
||||
- Persistenter "loggedOut"-State wird gesetzt (z. B. in `UserDefaults` oder einer App-State-DB).
|
||||
|
||||
3. **Persistenz**
|
||||
- Falls der Nutzer die App neu startet, startet er im Setup-/Login-Screen und nicht in einem alten Session-Kontext.
|
||||
|
||||
4. **Wiederanmeldung**
|
||||
- Sobald der Nutzer sich neu einloggt und erfolgreich ein Access Token erhält:
|
||||
- wird der persistente "loggedOut"-State zurückgesetzt
|
||||
- die App verhält sich wieder wie gewohnt im eingeloggten Zustand.
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Use Case
|
||||
- User ist eingeloggt in die App.
|
||||
- Im Backend wird manuell der Token gelöscht oder die Session invalidiert.
|
||||
- Nächster API-Call → API gibt `401 Unauthorized` zurück.
|
||||
- App erkennt ungültigen Zustand, **löscht alle Tokens und Session-Daten** und leitet den User sofort auf den **Setup-/Login-Screen** um.
|
||||
- Auch nach einem App-Neustart startet der User weiterhin im Setup-Screen.
|
||||
- Sobald der User sich erfolgreich einloggt, gelten alle API-Calls wieder normal.
|
||||
|
||||
18
documentation/tabbar.md
Normal file
18
documentation/tabbar.md
Normal file
@ -0,0 +1,18 @@
|
||||
## Feature: TabView nur in Root-Screens sichtbar, nicht in Detail-Views
|
||||
|
||||
### Beschreibung
|
||||
Die App verwendet aktuell eine `TabView` (bzw. einen Tab Controller), die global sichtbar ist.
|
||||
Der Workflow funktioniert grundsätzlich, allerdings wird die `TabView` auch dann angezeigt, wenn ein Benutzer von einem Tab in eine **Detailansicht** (z. B. Artikel-Detail, Item-Detail) navigiert.
|
||||
|
||||
Ziel ist es, dass die `TabView` **nur in den Root-Views** sichtbar ist.
|
||||
Beim Öffnen einer **Detail-Ansicht** soll die `TabView` automatisch ausgeblendet werden, damit dort alternativ eine eigene **Bottom Toolbar** angezeigt werden kann.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- `TabView` ist standardmäßig in den Haupt-Tabs sichtbar.
|
||||
- Navigiert der Nutzer in eine Detail-Ansicht (z. B. Rom Detail), wird die `TabView` ausgeblendet.
|
||||
- In den Detail-Ansichten kann ein eigener `Toolbar` oder eine Custom Bottom Bar sichtbar sein - ist aber kein teil von diesem task.
|
||||
- Navigation zurück zur Root-View blendet die `TabView` wieder ein.
|
||||
|
||||
# Technischer hinweis
|
||||
|
||||
To hide TabBar when we jumps towards next screen we just have to place NavigationView to the right place. Makesure Embed TabView inside NavigationView so creating unique Navigationview for both tabs.
|
||||
@ -41,6 +41,14 @@ class API: PAPI {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUnauthorizedResponse(_ statusCode: Int) {
|
||||
if statusCode == 401 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeJSONRequestWithHeaders<T: Codable>(
|
||||
endpoint: String,
|
||||
@ -74,6 +82,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -114,6 +123,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -146,6 +156,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -181,6 +192,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -342,6 +354,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
@ -379,6 +392,7 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
handleUnauthorizedResponse(httpResponse.statusCode)
|
||||
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
71
readeck/UI/AppViewModel.swift
Normal file
71
readeck/UI/AppViewModel.swift
Normal file
@ -0,0 +1,71 @@
|
||||
//
|
||||
// AppViewModel.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 27.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class AppViewModel: ObservableObject {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private let logoutUseCase: LogoutUseCase
|
||||
|
||||
@Published var hasFinishedSetup: Bool = true
|
||||
|
||||
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
|
||||
self.logoutUseCase = logoutUseCase
|
||||
setupNotificationObservers()
|
||||
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotificationObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("UnauthorizedAPIResponse"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task {
|
||||
await self?.handleUnauthorizedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("SetupStatusChanged"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.loadSetupStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleUnauthorizedResponse() async {
|
||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||
|
||||
do {
|
||||
// Führe den Logout durch
|
||||
try await logoutUseCase.execute()
|
||||
|
||||
// Update UI state
|
||||
loadSetupStatus()
|
||||
|
||||
print("AppViewModel: User successfully logged out due to 401 error")
|
||||
} catch {
|
||||
print("AppViewModel: Error during logout: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadSetupStatus() {
|
||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@ -18,12 +18,14 @@ struct PhoneTabView: View {
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
mainTabsContent
|
||||
moreTabContent
|
||||
NavigationStack {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
mainTabsContent
|
||||
moreTabContent
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,23 +36,19 @@ struct PhoneTabView: View {
|
||||
@ViewBuilder
|
||||
private var mainTabsContent: some View {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
NavigationStack {
|
||||
tabView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
tabView(for: tab)
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabContent: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
moreTabsList
|
||||
moreTabsFooter
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
moreTabsList
|
||||
moreTabsFooter
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
@ -71,6 +69,7 @@ struct PhoneTabView: View {
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
|
||||
@ -10,13 +10,13 @@ import netfox
|
||||
|
||||
@main
|
||||
struct readeckApp: App {
|
||||
@State private var hasFinishedSetup = true
|
||||
@StateObject private var appViewModel = AppViewModel()
|
||||
@StateObject private var appSettings = AppSettings()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if hasFinishedSetup {
|
||||
if appViewModel.hasFinishedSetup {
|
||||
MainTabView()
|
||||
} else {
|
||||
SettingsServerView()
|
||||
@ -32,25 +32,19 @@ struct readeckApp: App {
|
||||
// Initialize server connectivity monitoring
|
||||
_ = ServerConnectivity.shared
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
await loadAppSettings()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
await loadAppSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSetupStatus() async {
|
||||
private func loadAppSettings() async {
|
||||
let settingsRepository = SettingsRepository()
|
||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||
let settings = try? await settingsRepository.loadSettings()
|
||||
await MainActor.run {
|
||||
appSettings.settings = settings
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user