Files
onyx/Onyx/WidgetOptionsView.swift
Scarriffle fde3e864c0 Zwei-Faktor-Anmeldung bei Calendarr
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>
2026-08-14 13:44:26 +02:00

382 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}
}