Initial Commit
This commit is contained in:
306
Calendarr iOS/Views/ProfileView.swift
Normal file
306
Calendarr iOS/Views/ProfileView.swift
Normal file
@@ -0,0 +1,306 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
let api: CalendarrAPI
|
||||
@State private var profile: UserProfile?
|
||||
@State private var isLoading = true
|
||||
|
||||
@State private var newEmail = ""
|
||||
@State private var currentPW = ""
|
||||
@State private var newPW = ""
|
||||
@State private var confirmPW = ""
|
||||
|
||||
@State private var toast = ""
|
||||
@State private var showToast = false
|
||||
|
||||
@State private var show2FASetup = false
|
||||
@State private var show2FADisable = false
|
||||
@State private var totpQR = ""
|
||||
@State private var totpSecret = ""
|
||||
@State private var totpCode = ""
|
||||
@State private var disablePW = ""
|
||||
@State private var isSaving2FA = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Lade Profil…")
|
||||
} else if let profile {
|
||||
Form {
|
||||
kontoSection(profile: profile)
|
||||
passwordSection
|
||||
twoFASection(profile: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profil")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.overlay(alignment: .bottom) {
|
||||
if showToast {
|
||||
Text(toast)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.padding(.bottom, 20)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showToast)
|
||||
.sheet(isPresented: $show2FASetup) {
|
||||
TwoFASetupSheet(
|
||||
qrURL: totpQR,
|
||||
secret: totpSecret,
|
||||
code: $totpCode,
|
||||
isSaving: isSaving2FA
|
||||
) {
|
||||
Task { await enable2FA() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show2FADisable) {
|
||||
TwoFADisableSheet(password: $disablePW) {
|
||||
Task { await disable2FA() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
func kontoSection(profile: UserProfile) -> some View {
|
||||
Section("Konto") {
|
||||
HStack {
|
||||
Text("Benutzername")
|
||||
Spacer()
|
||||
Text(profile.username)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("Rolle")
|
||||
Spacer()
|
||||
Text(profile.isAdmin ? "Administrator" : "Benutzer")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("E-Mail")
|
||||
Spacer()
|
||||
TextField("Keine E-Mail", text: $newEmail)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
Button("E-Mail speichern") {
|
||||
Task { await saveEmail() }
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
var passwordSection: some View {
|
||||
Section("Passwort ändern") {
|
||||
SecureField("Aktuelles Passwort", text: $currentPW)
|
||||
SecureField("Neues Passwort", text: $newPW)
|
||||
SecureField("Neues Passwort wiederholen", text: $confirmPW)
|
||||
Button("Passwort ändern") {
|
||||
Task { await changePassword() }
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func twoFASection(profile: UserProfile) -> some View {
|
||||
Section("Zwei-Faktor-Authentifizierung") {
|
||||
if profile.totpEnabled {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("2FA ist aktiviert")
|
||||
}
|
||||
Button("2FA deaktivieren") {
|
||||
show2FADisable = true
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "shield")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("2FA ist deaktiviert")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button("2FA einrichten") {
|
||||
Task { await setup2FA() }
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
if let p = try? await api.getProfile() {
|
||||
profile = p
|
||||
newEmail = p.email ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func saveEmail() async {
|
||||
do {
|
||||
try await api.updateEmail(newEmail)
|
||||
showNotice("E-Mail gespeichert")
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func changePassword() async {
|
||||
guard newPW == confirmPW else {
|
||||
showNotice("Passwörter stimmen nicht überein")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await api.changePassword(current: currentPW, new: newPW)
|
||||
currentPW = ""; newPW = ""; confirmPW = ""
|
||||
showNotice("Passwort geändert")
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func setup2FA() async {
|
||||
do {
|
||||
let result = try await api.setup2FA()
|
||||
totpSecret = result.secret
|
||||
totpQR = result.qrUrl
|
||||
totpCode = ""
|
||||
show2FASetup = true
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func enable2FA() async {
|
||||
isSaving2FA = true
|
||||
do {
|
||||
try await api.enable2FA(code: totpCode)
|
||||
show2FASetup = false
|
||||
showNotice("2FA aktiviert")
|
||||
await load()
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
isSaving2FA = false
|
||||
}
|
||||
|
||||
private func disable2FA() async {
|
||||
do {
|
||||
try await api.disable2FA(password: disablePW)
|
||||
show2FADisable = false
|
||||
showNotice("2FA deaktiviert")
|
||||
await load()
|
||||
} catch {
|
||||
showNotice(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func showNotice(_ msg: String) {
|
||||
toast = msg
|
||||
withAnimation { showToast = true }
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
withAnimation { showToast = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TwoFASetupSheet: View {
|
||||
let qrURL: String
|
||||
let secret: String
|
||||
@Binding var code: String
|
||||
let isSaving: Bool
|
||||
let onEnable: () -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).")
|
||||
.font(.body)
|
||||
}
|
||||
Section("QR-Code / Manueller Schlüssel") {
|
||||
if let url = URL(string: qrURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
default:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
HStack {
|
||||
Text(secret)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = secret
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
Section("Bestätigung") {
|
||||
TextField("6-stelliger Code", text: $code)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
.navigationTitle("2FA einrichten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Aktivieren") { onEnable() }
|
||||
.bold()
|
||||
.disabled(code.count < 6 || isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TwoFADisableSheet: View {
|
||||
@Binding var password: String
|
||||
let onDisable: () -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Passwort zum Deaktivieren") {
|
||||
SecureField("Passwort", text: $password)
|
||||
}
|
||||
}
|
||||
.navigationTitle("2FA deaktivieren")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Deaktivieren") { onDisable() }
|
||||
.bold()
|
||||
.foregroundStyle(.red)
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user