Deine Zugangsdaten stimmten. Der Fehler lag im gewählten Endpunkt:
auth_router.py:81 if user.totp_enabled:
raise HTTPException(401, detail="2fa_required")
/api/auth/token nimmt nur ein Formular mit Benutzername und Passwort entgegen
und weist Konten mit zweitem Faktor grundsätzlich ab — mit demselben 401 wie
ein falsches Passwort. Deshalb stand da „Benutzername oder Passwort stimmen
nicht", obwohl beides stimmte.
Angemeldet wird jetzt über /api/auth/login: der Weg nimmt den Code entgegen
und kennt remember_me. Das ist hier keine Bequemlichkeit, sondern die
Bedingung — mit zweitem Faktor kann Onyx sich nicht selbst neu anmelden, den
Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
remember_me hält hundertachtzig.
Damit wandert auch das Token in den Schlüsselbund. Bei einem Konto mit
zweitem Faktor ist es das einzige, womit Onyx noch hineinkommt; ginge es beim
Beenden verloren, müsste man sich bei jedem Start neu anmelden.
Die drei Fälle sind jetzt auseinandergehalten und einzeln beschriftet:
falsches Passwort, fehlender Code, falscher Code. Der Unterschied ist der
zwischen „Passwort neu tippen" und „App aufmachen" — und er steckt beim
Server nur im Begleittext eines 401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
14 KiB
Swift
382 lines
14 KiB
Swift
import SwiftUI
|
||
import OnyxDesign
|
||
import CalendarProvider
|
||
import WeatherProvider
|
||
import ShelfProvider
|
||
|
||
/// Die Stellschrauben der einzelnen Widgets, an einem Ort.
|
||
///
|
||
/// Vorher hieß der Bereich „Quellen" und enthielt nur die Kalenderquelle. Das
|
||
/// war die Sicht des Programmierers: für den Nutzer ist die Quelle eine
|
||
/// Einstellung **des Kalenderwidgets** unter mehreren — welche Kalender
|
||
/// mitzählen, gehört genauso dazu, und beim Wetter der Ort. Ein Abschnitt je
|
||
/// Widget ist die Ordnung, die man sucht.
|
||
struct WidgetOptionsView: View {
|
||
let calendarModel: CalendarModel
|
||
let weatherModel: WeatherModel
|
||
let shelfStore: ShelfStore?
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("options.calendar") {
|
||
CalendarOptions(model: calendarModel)
|
||
}
|
||
|
||
Section("options.weather") {
|
||
WeatherOptions(model: weatherModel)
|
||
}
|
||
|
||
if let shelfStore {
|
||
Section("options.shelf") {
|
||
ShelfOptions(store: shelfStore)
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||
.scrollContentBackground(.hidden)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// MARK: - Kalender
|
||
|
||
private struct CalendarOptions: View {
|
||
@Bindable var model: CalendarModel
|
||
@State private var calendarrState: CalendarSourceState?
|
||
|
||
var body: some View {
|
||
Picker(selection: $model.kind) {
|
||
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||
Text(kind.localizedName).tag(kind)
|
||
}
|
||
} label: {
|
||
Text("options.calendar.source")
|
||
}
|
||
.pickerStyle(.menu)
|
||
|
||
Picker(selection: $model.showsMonthGrid) {
|
||
Text("options.calendar.agenda").tag(false)
|
||
Text("options.calendar.month").tag(true)
|
||
} label: {
|
||
Text("options.calendar.display")
|
||
}
|
||
.pickerStyle(.menu)
|
||
|
||
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
|
||
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
|
||
// suchen muss, ohne zu wissen, dass es ihn gibt.
|
||
if model.availableCalendars.isEmpty {
|
||
Text("options.calendar.noneFound")
|
||
.font(.callout).foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(model.availableCalendars) { calendar in
|
||
Toggle(isOn: Binding(
|
||
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
|
||
set: { shown in
|
||
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
|
||
else { model.hiddenCalendarIDs.insert(calendar.id) }
|
||
})) {
|
||
Label {
|
||
Text(calendar.title)
|
||
} icon: {
|
||
Circle()
|
||
.fill(Color(hex: calendar.colorHex) ?? .secondary)
|
||
.frame(width: 9, height: 9)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if model.kind != .appleCalendar {
|
||
CalendarrServerSettings(model: model)
|
||
CalendarrStatus(state: calendarrState,
|
||
isLoading: model.isLoading,
|
||
onRefresh: { model.refresh() })
|
||
.task(id: model.kind) {
|
||
guard model.kind != .appleCalendar else { return }
|
||
let now = Date()
|
||
calendarrState = await CalendarrSource().load(
|
||
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||
end: now.addingTimeInterval(60 * 86_400)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
|
||
///
|
||
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den
|
||
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
|
||
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
|
||
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen.
|
||
private struct CalendarrStatus: View {
|
||
let state: CalendarSourceState?
|
||
let isLoading: Bool
|
||
let onRefresh: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
message
|
||
Spacer(minLength: 8)
|
||
if case .ready(let window) = state {
|
||
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
reloadButton
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var message: some View {
|
||
switch state {
|
||
case .ready(let window):
|
||
Text("options.calendar.calendarrEvents \(window.events.count)")
|
||
case .neverWritten:
|
||
Text("calendar.source.status.neverWritten").font(.callout).foregroundStyle(.secondary)
|
||
case .loggedOut:
|
||
Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary)
|
||
case .incompatible:
|
||
Text("calendar.source.status.incompatible").font(.callout)
|
||
.foregroundStyle(Onyx.Color.warning)
|
||
case .unreadable(let reason):
|
||
Text(reason).font(.callout).foregroundStyle(Onyx.Color.warning)
|
||
case .denied:
|
||
Text("calendar.source.status.denied").font(.callout)
|
||
.foregroundStyle(Onyx.Color.warning)
|
||
case .none:
|
||
Text("calendar.source.status.loading").font(.callout).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// Feste Breite, damit die Zeile beim Laden nicht zuckt: der Kreisel ist
|
||
/// nicht so breit wie der Pfeil.
|
||
private var reloadButton: some View {
|
||
Button(action: onRefresh) {
|
||
Group {
|
||
if isLoading {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
}
|
||
.frame(width: 16, height: 16)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.disabled(isLoading)
|
||
.help(Text("options.calendar.reload"))
|
||
}
|
||
}
|
||
|
||
// MARK: - Wetter
|
||
|
||
private struct WeatherOptions: View {
|
||
let model: WeatherModel
|
||
@State private var query = ""
|
||
@State private var searching = false
|
||
@State private var notFound = false
|
||
|
||
private var usesCurrentLocation: Bool { model.place.isCurrent }
|
||
|
||
var body: some View {
|
||
Toggle(isOn: Binding(
|
||
get: { usesCurrentLocation },
|
||
set: { model.place = $0 ? .current : model.place })) {
|
||
Text("options.weather.automatic")
|
||
}
|
||
.disabled(usesCurrentLocation)
|
||
|
||
if usesCurrentLocation {
|
||
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
|
||
// steht hier, statt dass das Widget stumm leer bleibt.
|
||
if model.locationIsDenied {
|
||
Text("options.weather.needsLocation")
|
||
.font(.callout).foregroundStyle(Onyx.Color.warning)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
} else if case .fixed(let name, _, _) = model.place {
|
||
LabeledContent {
|
||
Text(name).foregroundStyle(.secondary)
|
||
} label: {
|
||
Text("options.weather.place")
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
TextField("options.weather.search", text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onSubmit { Task { await search() } }
|
||
if searching {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Button("options.weather.set") { Task { await search() } }
|
||
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
|
||
if notFound {
|
||
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
|
||
}
|
||
}
|
||
|
||
private func search() async {
|
||
searching = true
|
||
notFound = false
|
||
defer { searching = false }
|
||
if let found = await model.selectPlace(named: query) {
|
||
query = ""
|
||
_ = found
|
||
} else {
|
||
notFound = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Ablage
|
||
|
||
private struct ShelfOptions: View {
|
||
@Bindable var store: ShelfStore
|
||
|
||
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||
|
||
var body: some View {
|
||
Picker(selection: $store.autoCleanupDays) {
|
||
ForEach(Self.choices, id: \.self) { days in
|
||
if let days {
|
||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||
} else {
|
||
Text("settings.shelf.never").tag(Int?.none)
|
||
}
|
||
}
|
||
} label: {
|
||
Text("settings.shelf.cleanup")
|
||
}
|
||
|
||
HStack {
|
||
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension Color {
|
||
/// Die Farbe eines Kalenders, wie die Quelle sie liefert.
|
||
init?(hex: String?) {
|
||
guard let hex, hex.hasPrefix("#"), hex.count == 7,
|
||
let value = Int(hex.dropFirst(), radix: 16) else { return nil }
|
||
self.init(red: Double((value >> 16) & 0xFF) / 255,
|
||
green: Double((value >> 8) & 0xFF) / 255,
|
||
blue: Double(value & 0xFF) / 255)
|
||
}
|
||
}
|
||
|
||
/// Die Anmeldung am Calendarr-Server.
|
||
///
|
||
/// Der Schnappschuss im gemeinsamen Ordner ist nur so aktuell wie der letzte
|
||
/// Start der Calendarr-App. Wer die Zugangsdaten hier einträgt, holt Onyx die
|
||
/// Termine selbst — und dann ist es egal, ob Calendarr überhaupt läuft.
|
||
///
|
||
/// Adresse und Benutzername stehen in den Einstellungen, das Passwort im
|
||
/// Schlüsselbund.
|
||
private struct CalendarrServerSettings: View {
|
||
|
||
let model: CalendarModel
|
||
|
||
@State private var credentials = CalendarrCredentials()
|
||
@State private var server = ""
|
||
@State private var username = ""
|
||
@State private var password = ""
|
||
@State private var totpCode = ""
|
||
@State private var isChecking = false
|
||
@State private var failure: String?
|
||
|
||
private var account: CalendarrAccount? { credentials.account }
|
||
|
||
var body: some View {
|
||
if let account {
|
||
LabeledContent {
|
||
HStack(spacing: 10) {
|
||
Text(account.username).foregroundStyle(.secondary)
|
||
Button {
|
||
credentials.clear()
|
||
model.calendarrAccountChanged()
|
||
} label: {
|
||
Text("options.calendar.server.disconnect")
|
||
}
|
||
}
|
||
} label: {
|
||
Label {
|
||
Text(account.server.host() ?? account.server.absoluteString)
|
||
} icon: {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.foregroundStyle(Onyx.Color.positive)
|
||
}
|
||
}
|
||
} else {
|
||
// Drei Felder und ein Knopf. Geprüft wird beim Verbinden, nicht
|
||
// erst beim nächsten Laden: ein Tippfehler, den man am Folgetag
|
||
// bemerkt, ist ein vermeidbarer.
|
||
TextField(text: $server) { Text("options.calendar.server.address") }
|
||
.textContentType(.URL)
|
||
TextField(text: $username) { Text("options.calendar.server.username") }
|
||
.textContentType(.username)
|
||
SecureField(text: $password) { Text("options.calendar.server.password") }
|
||
.textContentType(.password)
|
||
// Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld
|
||
// wäre es für alle anderen eine Frage, die sie nicht beantworten
|
||
// können.
|
||
TextField(text: $totpCode) { Text("options.calendar.server.totp") }
|
||
.textContentType(.oneTimeCode)
|
||
|
||
HStack {
|
||
if let failure {
|
||
Text(failure).font(.callout).foregroundStyle(Onyx.Color.warning)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
} else {
|
||
Text("options.calendar.server.hint")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Spacer(minLength: 12)
|
||
Button(action: connect) {
|
||
if isChecking {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Text("options.calendar.server.connect")
|
||
}
|
||
}
|
||
.disabled(isChecking || server.isEmpty || username.isEmpty || password.isEmpty)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func connect() {
|
||
guard let url = CalendarrLiveDecoder.server(from: server) else {
|
||
failure = CalendarrLiveError.badServer.errorDescription
|
||
return
|
||
}
|
||
isChecking = true
|
||
failure = nil
|
||
Task {
|
||
defer { isChecking = false }
|
||
switch await CalendarrLiveSource.check(server: url, username: username,
|
||
password: password,
|
||
totpCode: totpCode) {
|
||
case .success(let token):
|
||
credentials.save(server: url, username: username,
|
||
password: password, token: token)
|
||
// Passwort und Code nicht im Formular stehen lassen — beide
|
||
// liegen jetzt dort, wo sie hingehören.
|
||
password = ""
|
||
totpCode = ""
|
||
model.calendarrAccountChanged()
|
||
case .failure(let error):
|
||
failure = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
}
|