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:
Ilyas Hallak 2025-08-27 22:04:37 +02:00
parent 660f271982
commit 953ff5da8d
8 changed files with 208 additions and 28 deletions

View File

@ -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..." : {

View File

@ -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
View 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
View 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.

View File

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

View 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)
}
}

View File

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

View File

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