Compare commits

..

2 Commits

Author SHA1 Message Date
MrDiderot
39a0e35fd2 Add OAuth backend scaffold 2026-07-22 22:54:03 +02:00
MrDiderot
77d93c4f8d Add account sign-in structure 2026-07-22 22:27:47 +02:00
14 changed files with 3148 additions and 42 deletions

5
.gitignore vendored
View File

@@ -20,6 +20,10 @@ DerivedData/
.swiftpm/ .swiftpm/
Package.resolved Package.resolved
# Node backend
node_modules/
backend/dist/
# Local configuration and secrets # Local configuration and secrets
.env .env
.env.* .env.*
@@ -30,4 +34,3 @@ Package.resolved
# Temporary exports # Temporary exports
exports/ exports/
*.tmp *.tmp

View File

@@ -32,6 +32,7 @@
E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.framework */; }; E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.framework */; };
E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44230110492003F93AA /* SwiftUI.framework */; }; E296A44330110492003F93AA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44230110492003F93AA /* SwiftUI.framework */; };
E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E296A43E30110491003F93AA /* MomentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E296A45430110492003F93AA /* MomentExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E296A43E30110491003F93AA /* MomentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A465301164BE003F93AA /* AccountBackend.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -86,6 +87,7 @@
E296A44030110491003F93AA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E296A44030110491003F93AA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
E296A44230110492003F93AA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; E296A44230110492003F93AA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
E296A45E30110993003F93AA /* MomentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MomentExtension.entitlements; sourceTree = "<group>"; }; E296A45E30110993003F93AA /* MomentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MomentExtension.entitlements; sourceTree = "<group>"; };
E296A465301164BE003F93AA /* AccountBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBackend.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -158,6 +160,7 @@
E296A4343010C110003F93AA /* TherapySupportView.swift */, E296A4343010C110003F93AA /* TherapySupportView.swift */,
E296A4363010C14D003F93AA /* AppLockView.swift */, E296A4363010C14D003F93AA /* AppLockView.swift */,
E296A4383010FDD6003F93AA /* AppTheme.swift */, E296A4383010FDD6003F93AA /* AppTheme.swift */,
E296A465301164BE003F93AA /* AccountBackend.swift */,
); );
path = FeelAloud; path = FeelAloud;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -303,6 +306,7 @@
A10000000000000000000008 /* HomeView.swift in Sources */, A10000000000000000000008 /* HomeView.swift in Sources */,
E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */, E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */,
A10000000000000000000009 /* EntryFlowView.swift in Sources */, A10000000000000000000009 /* EntryFlowView.swift in Sources */,
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */,
A1000000000000000000000A /* HistoryView.swift in Sources */, A1000000000000000000000A /* HistoryView.swift in Sources */,
A1000000000000000000000B /* SettingsView.swift in Sources */, A1000000000000000000000B /* SettingsView.swift in Sources */,
A1000000000000000000000D /* MoodSnapshot.swift in Sources */, A1000000000000000000000D /* MoodSnapshot.swift in Sources */,

View File

@@ -0,0 +1,111 @@
import Foundation
enum AccountBackendStore {
static let backendURLKey = "authBackendURL"
static let sessionTokenKey = "authSessionToken"
static let remoteUserIDKey = "authRemoteUserID"
static var backendURL: URL? {
guard let value = UserDefaults.standard.string(forKey: backendURLKey),
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
return URL(string: value)
}
static func save(session: AccountSession) {
UserDefaults.standard.set(session.token, forKey: sessionTokenKey)
UserDefaults.standard.set(session.user.id, forKey: remoteUserIDKey)
UserDefaults.standard.set(session.user.email ?? "", forKey: UserProfileStore.accountEmailKey)
UserDefaults.standard.set(session.user.displayName, forKey: UserProfileStore.displayNameKey)
}
static func clearSession() {
UserDefaults.standard.removeObject(forKey: sessionTokenKey)
UserDefaults.standard.removeObject(forKey: remoteUserIDKey)
}
}
struct AccountBackendClient {
enum BackendError: LocalizedError {
case missingBackendURL
case invalidURL
case invalidResponse
case server(String)
var errorDescription: String? {
switch self {
case .missingBackendURL:
"Backend-URL ist noch nicht konfiguriert."
case .invalidURL:
"Backend-URL ist ungültig."
case .invalidResponse:
"Backend hat keine gültige Antwort geliefert."
case .server(let message):
message
}
}
}
func signInWithApple(identityToken: String, authorizationCode: String?, email: String?, displayName: String?) async throws -> AccountSession {
try await post(
path: "/auth/apple",
body: AppleSignInRequest(
identityToken: identityToken,
authorizationCode: authorizationCode,
email: email,
displayName: displayName
)
)
}
private func post<RequestBody: Encodable, ResponseBody: Decodable>(path: String, body: RequestBody) async throws -> ResponseBody {
guard let baseURL = AccountBackendStore.backendURL else {
throw BackendError.missingBackendURL
}
guard let url = URL(string: path, relativeTo: baseURL) else {
throw BackendError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw BackendError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
let error = try? JSONDecoder().decode(BackendErrorResponse.self, from: data)
throw BackendError.server(error?.message ?? error?.error ?? "Backend-Fehler: HTTP \(httpResponse.statusCode)")
}
return try JSONDecoder().decode(ResponseBody.self, from: data)
}
}
private struct AppleSignInRequest: Encodable {
let identityToken: String
let authorizationCode: String?
let email: String?
let displayName: String?
}
struct AccountSession: Decodable {
let token: String
let user: AccountUser
}
struct AccountUser: Decodable {
let id: String
let email: String?
let displayName: String
let avatarURL: String?
}
private struct BackendErrorResponse: Decodable {
let error: String?
let message: String?
}

View File

@@ -59,12 +59,12 @@ enum AppColorProfile: String, CaseIterable, Identifiable {
} }
static func stored(_ rawValue: String) -> AppColorProfile { static func stored(_ rawValue: String) -> AppColorProfile {
AppColorProfile(rawValue: rawValue) ?? .lavender AppColorProfile(rawValue: rawValue) ?? .sage
} }
} }
private struct AppColorProfileKey: EnvironmentKey { private struct AppColorProfileKey: EnvironmentKey {
static let defaultValue: AppColorProfile = .lavender static let defaultValue: AppColorProfile = .sage
} }
extension EnvironmentValues { extension EnvironmentValues {

View File

@@ -1,4 +1,5 @@
import AppIntents import AppIntents
import AuthenticationServices
import PhotosUI import PhotosUI
import SwiftData import SwiftData
import SwiftUI import SwiftUI
@@ -12,8 +13,8 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot] @Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
@AppStorage("appLockEnabled") private var appLockEnabled = false @AppStorage("appLockEnabled") private var appLockEnabled = false
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue @AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
@AppStorage("forceLightAppearance") private var forceLightAppearance = false @AppStorage("forceLightAppearance") private var forceLightAppearance = true
@AppStorage("lastPresentedFollowUpSnapshotID") private var lastPresentedFollowUpSnapshotID = "" @AppStorage("lastPresentedFollowUpSnapshotID") private var lastPresentedFollowUpSnapshotID = ""
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
@State private var isUnlocked = false @State private var isUnlocked = false
@@ -188,6 +189,9 @@ enum UserProfileStore {
static let onboardingCompletedKey = "hasCompletedOnboarding" static let onboardingCompletedKey = "hasCompletedOnboarding"
static let displayNameKey = "userDisplayName" static let displayNameKey = "userDisplayName"
static let profileImageDataKey = "userProfileImageData" static let profileImageDataKey = "userProfileImageData"
static let accountProviderKey = "accountProvider"
static let accountEmailKey = "accountEmail"
static let appleUserIDKey = "appleUserID"
static func saveProfile(displayName: String, imageData: Data?) { static func saveProfile(displayName: String, imageData: Data?) {
UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey) UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey)
@@ -196,12 +200,50 @@ enum UserProfileStore {
UserDefaults.standard.set(imageData, forKey: profileImageDataKey) UserDefaults.standard.set(imageData, forKey: profileImageDataKey)
} }
} }
static func saveAppleAccount(userID: String, email: String?, fullName: PersonNameComponents?) {
UserDefaults.standard.set(AccountProvider.apple.rawValue, forKey: accountProviderKey)
UserDefaults.standard.set(userID, forKey: appleUserIDKey)
if let email, !email.isEmpty {
UserDefaults.standard.set(email, forKey: accountEmailKey)
}
let formatter = PersonNameComponentsFormatter()
let name = fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? ""
if !name.isEmpty {
UserDefaults.standard.set(name, forKey: displayNameKey)
}
}
static func clearAccount() {
UserDefaults.standard.removeObject(forKey: accountProviderKey)
UserDefaults.standard.removeObject(forKey: accountEmailKey)
UserDefaults.standard.removeObject(forKey: appleUserIDKey)
}
}
enum AccountProvider: String {
case local
case apple
case google
case email
var displayName: String {
switch self {
case .local: "Lokales Profil"
case .apple: "Apple Account"
case .google: "Google Account"
case .email: "E-Mail und Passwort"
}
}
} }
private struct OnboardingView: View { private struct OnboardingView: View {
@Environment(\.appColorProfile) private var colorProfile @Environment(\.appColorProfile) private var colorProfile
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
@AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = "" @AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = ""
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
@State private var page = 0 @State private var page = 0
@State private var displayName = "" @State private var displayName = ""
@@ -209,6 +251,7 @@ private struct OnboardingView: View {
@State private var profileImageData: Data? @State private var profileImageData: Data?
@State private var customCategories: [DiaryCategory] = [] @State private var customCategories: [DiaryCategory] = []
@State private var newCategoryName = "" @State private var newCategoryName = ""
@State private var accountMessage: String?
var body: some View { var body: some View {
ZStack { ZStack {
@@ -245,6 +288,11 @@ private struct OnboardingView: View {
.onChange(of: selectedPhoto) { _, item in .onChange(of: selectedPhoto) { _, item in
Task { await loadSelectedPhoto(item) } Task { await loadSelectedPhoto(item) }
} }
.alert("Account", isPresented: accountMessageBinding) {
Button("OK", role: .cancel) { }
} message: {
Text(accountMessage ?? "")
}
} }
private var progressDots: some View { private var progressDots: some View {
@@ -286,14 +334,61 @@ private struct OnboardingView: View {
} }
private var snapshotPage: some View { private var snapshotPage: some View {
onboardingPage( VStack(spacing: 20) {
symbol: "face.smiling", onboardingHeader(
title: "Kurze Momentaufnahme", symbol: "face.smiling",
text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget.", title: "Kurze Momentaufnahme",
rows: MoodRating.allCases.map { rating in text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget."
OnboardingRow(symbol: rating.symbol, title: rating.rawValue, text: "Ein schneller Check-in für deinen Verlauf.") )
VStack(spacing: 0) {
ForEach(Array(snapshotScaleItems.enumerated()), id: \.element.id) { index, item in
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 14) {
Image(systemName: item.rating.symbol)
.font(.title2.weight(.semibold))
.foregroundStyle(item.rating.color)
.frame(width: 44, height: 44)
.background(item.rating.color.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(item.rating.rawValue)
.font(.subheadline.weight(.semibold))
Text(item.description)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
.layoutPriority(1)
Spacer(minLength: 0)
}
.padding(.vertical, 9)
if index < snapshotScaleItems.count - 1 {
Divider()
.padding(.leading, 58)
}
}
}
} }
) .padding(.horizontal, 14)
.padding(.vertical, 4)
.background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18))
Spacer(minLength: 0)
}
}
private var snapshotScaleItems: [OnboardingSnapshotScaleItem] {
[
OnboardingSnapshotScaleItem(rating: .veryGood, description: "Sehr stabil, leicht und kraftvoll."),
OnboardingSnapshotScaleItem(rating: .good, description: "Ruhig, stabil oder zuversichtlich."),
OnboardingSnapshotScaleItem(rating: .middle, description: "Durchwachsen oder schwer einzuordnen."),
OnboardingSnapshotScaleItem(rating: .bad, description: "Spürbar schwer oder angespannt."),
OnboardingSnapshotScaleItem(rating: .veryBad, description: "Sehr belastet, erschöpft oder instabil.")
]
} }
private var profilePage: some View { private var profilePage: some View {
@@ -393,15 +488,41 @@ private struct OnboardingView: View {
private var accountPage: some View { private var accountPage: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
onboardingHeader( onboardingHeader(
symbol: "icloud.and.arrow.up.fill", symbol: "person.crop.circle.badge.checkmark",
title: "Zugang sichern", title: "Account sichern",
text: "Zum Abschluss wird dein lokales Profil gespeichert. Premium-Käufe laufen später über deine Apple-ID, und deine Daten können über iCloud synchronisiert werden, sobald die iCloud-Funktion aktiv ist." text: "Melde dich an, damit Premium später über den App Store wiederhergestellt werden kann. Google und E-Mail brauchen noch ein Backend, Apple ist in iOS nativ vorgesehen."
) )
VStack(alignment: .leading, spacing: 14) { VStack(spacing: 12) {
accountRow(symbol: "person.crop.circle.badge.checkmark", title: "Profil", text: displayName.isEmpty ? "Ohne Namen fortfahren" : displayName) SignInWithAppleButton(.signUp) { request in
accountRow(symbol: "icloud", title: "Datenzugang", text: "Über deinen Apple-Account und iCloud absichern") request.requestedScopes = [.fullName, .email]
accountRow(symbol: "checkmark.seal", title: "Premium", text: "Käufe können über die Apple-ID wiederhergestellt werden") } onCompletion: { result in
handleAppleSignIn(result)
}
.signInWithAppleButtonStyle(.black)
.frame(height: 48)
Button {
accountMessage = "Google Login braucht einen OAuth Client und ein Backend wie Firebase, Supabase oder einen eigenen Server. Sobald du dich für eines davon entscheidest, kann ich die echte Anmeldung anbinden."
} label: {
Label("Mit Google anmelden", systemImage: "g.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
accountMessage = "E-Mail und Passwort darf ich nicht lokal faken. Dafür braucht FeelAloud einen Auth-Server, der Passwörter sicher hasht, Reset-Mails verschickt und Sessions verwaltet."
} label: {
Label("Mit E-Mail registrieren", systemImage: "envelope")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
accountRow(
symbol: accountProviderRaw == AccountProvider.apple.rawValue ? "checkmark.seal.fill" : "info.circle",
title: "Aktueller Zugang",
text: AccountProvider(rawValue: accountProviderRaw)?.displayName ?? AccountProvider.local.displayName
)
} }
.padding(16) .padding(16)
.background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
@@ -415,6 +536,13 @@ private struct OnboardingView: View {
} }
} }
private var accountMessageBinding: Binding<Bool> {
Binding(
get: { accountMessage != nil },
set: { if !$0 { accountMessage = nil } }
)
}
private var navigationControls: some View { private var navigationControls: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if page > 0 { if page > 0 {
@@ -443,29 +571,38 @@ private struct OnboardingView: View {
VStack(spacing: 20) { VStack(spacing: 20) {
onboardingHeader(symbol: symbol, title: title, text: text) onboardingHeader(symbol: symbol, title: title, text: text)
VStack(spacing: 12) { VStack(spacing: 0) {
ForEach(rows) { row in ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in
HStack(spacing: 12) { VStack(spacing: 0) {
Image(systemName: row.symbol) HStack(alignment: .top, spacing: 12) {
.font(.headline) Image(systemName: row.symbol)
.foregroundStyle(colorProfile.accent) .font(.subheadline.weight(.semibold))
.frame(width: 34, height: 34) .foregroundStyle(colorProfile.accent)
.background(colorProfile.softBackground, in: RoundedRectangle(cornerRadius: 10)) .frame(width: 28, height: 28)
.background(colorProfile.accent.opacity(0.10), in: Circle())
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 3) {
Text(row.title) Text(row.title)
.font(.headline) .font(.subheadline.weight(.semibold))
Text(row.text) Text(row.text)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
Spacer(minLength: 0)
} }
.padding(.vertical, 11)
Spacer(minLength: 0) if index < rows.count - 1 {
Divider()
.padding(.leading, 40)
}
} }
.padding(12)
.background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 14))
} }
} }
.padding(.horizontal, 14)
.padding(.vertical, 4)
.background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18))
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -547,6 +684,70 @@ private struct OnboardingView: View {
hasCompletedOnboarding = true hasCompletedOnboarding = true
} }
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
accountMessage = "Apple hat keine gültigen Accountdaten zurückgegeben."
return
}
guard let identityTokenData = credential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
accountMessage = "Apple hat kein Identity Token zurückgegeben."
return
}
UserProfileStore.saveAppleAccount(
userID: credential.user,
email: credential.email,
fullName: credential.fullName
)
accountProviderRaw = AccountProvider.apple.rawValue
if displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let formatter = PersonNameComponentsFormatter()
displayName = credential.fullName.map { formatter.string(from: $0) } ?? ""
}
Task {
await syncAppleAccountWithBackend(
identityToken: identityToken,
authorizationCode: credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) },
email: credential.email,
fullName: credential.fullName
)
}
case .failure(let error):
accountMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)"
}
}
@MainActor
private func syncAppleAccountWithBackend(
identityToken: String,
authorizationCode: String?,
email: String?,
fullName: PersonNameComponents?
) async {
guard AccountBackendStore.backendURL != nil else {
accountMessage = "Apple Account wurde lokal verknüpft. Sobald die Backend-URL gesetzt ist, kann die Server-Synchronisation aktiviert werden."
return
}
do {
let formatter = PersonNameComponentsFormatter()
let session = try await AccountBackendClient().signInWithApple(
identityToken: identityToken,
authorizationCode: authorizationCode,
email: email,
displayName: fullName.map { formatter.string(from: $0) }
)
AccountBackendStore.save(session: session)
accountMessage = "Apple Account wurde mit dem Backend synchronisiert."
} catch {
accountMessage = "Apple Account wurde lokal verknüpft, aber das Backend konnte nicht synchronisieren: \(error.localizedDescription)"
}
}
private func loadSelectedPhoto(_ item: PhotosPickerItem?) async { private func loadSelectedPhoto(_ item: PhotosPickerItem?) async {
guard let item, guard let item,
let data = try? await item.loadTransferable(type: Data.self) else { let data = try? await item.loadTransferable(type: Data.self) else {
@@ -573,6 +774,13 @@ private struct OnboardingRow: Identifiable {
let text: String let text: String
} }
private struct OnboardingSnapshotScaleItem: Identifiable {
let rating: MoodRating
let description: String
var id: String { rating.id }
}
private struct OnboardingProfileImage: View { private struct OnboardingProfileImage: View {
let imageData: Data? let imageData: Data?
let accent: Color let accent: Color

View File

@@ -1,4 +1,5 @@
import AppIntents import AppIntents
import AuthenticationServices
import FoundationModels import FoundationModels
import SwiftData import SwiftData
import SwiftUI import SwiftUI
@@ -15,14 +16,17 @@ struct SettingsView: View {
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false @AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
@AppStorage(UserProfileStore.displayNameKey) private var displayName = "" @AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
@AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data() @AppStorage(UserProfileStore.profileImageDataKey) private var profileImageData = Data()
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
@AppStorage(UserProfileStore.accountEmailKey) private var accountEmail = ""
@AppStorage(AccountBackendStore.backendURLKey) private var backendURL = ""
@AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false @AppStorage("snapshotRemindersEnabled") private var remindersEnabled = false
@AppStorage("snapshotReminderCount") private var reminderCount = 3 @AppStorage("snapshotReminderCount") private var reminderCount = 3
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60 @AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
@AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60 @AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60
@AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60 @AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60
@AppStorage("appLockEnabled") private var appLockEnabled = false @AppStorage("appLockEnabled") private var appLockEnabled = false
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue @AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
@AppStorage("forceLightAppearance") private var forceLightAppearance = false @AppStorage("forceLightAppearance") private var forceLightAppearance = true
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false @AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false @AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false
@@ -198,7 +202,39 @@ struct SettingsView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
LabeledContent("Account", value: "Apple-ID / iCloud") LabeledContent("Account", value: currentAccountProvider.displayName)
if !accountEmail.isEmpty {
LabeledContent("E-Mail", value: accountEmail)
}
}
Section("Anmelden") {
TextField("Backend-URL", text: $backendURL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handleAppleSignIn(result)
}
.signInWithAppleButtonStyle(.black)
.frame(height: 46)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Button {
profileMessage = "Das Backend unterstützt Google ID-Token bereits über /auth/google. In der iOS-App fehlt dafür noch die GoogleSignIn SDK-Konfiguration mit deiner Google Client ID."
} label: {
Label("Mit Google anmelden", systemImage: "g.circle")
}
Button {
profileMessage = "Das Backend unterstützt E-Mail/Passwort bereits. Als nächster Schritt braucht die App noch eine eigene Maske für Registrierung, Login und Passwortwechsel gegen die Backend-Endpoints."
} label: {
Label("Mit E-Mail anmelden", systemImage: "envelope")
}
} }
Section("Premium") { Section("Premium") {
@@ -226,7 +262,7 @@ struct SettingsView: View {
Section("Zugang") { Section("Zugang") {
Button { Button {
profileMessage = "FeelAloud verwendet aktuell kein eigenes Passwort. Der Zugang läuft über dein Gerät, Face ID und später über deine Apple-ID/iCloud. Ein App-Passwort kann erst mit einem echten Login-System ergänzt werden." profileMessage = "Passwort ändern ist nur für E-Mail-Accounts sinnvoll. Dafür braucht FeelAloud zuerst einen echten Auth-Server mit sicherer Passwortspeicherung und Reset-Mail-Funktion."
} label: { } label: {
Label("Passwort ändern", systemImage: "key") Label("Passwort ändern", systemImage: "key")
} }
@@ -495,6 +531,10 @@ struct SettingsView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
} }
private var currentAccountProvider: AccountProvider {
AccountProvider(rawValue: accountProviderRaw) ?? .local
}
private var profileAvatar: some View { private var profileAvatar: some View {
Group { Group {
#if canImport(UIKit) #if canImport(UIKit)
@@ -726,8 +766,84 @@ struct SettingsView: View {
private func logoutProfile() { private func logoutProfile() {
displayName = "" displayName = ""
profileImageData = Data() profileImageData = Data()
accountEmail = ""
accountProviderRaw = AccountProvider.local.rawValue
UserProfileStore.clearAccount()
hasCompletedOnboarding = false hasCompletedOnboarding = false
} }
private func handleAppleSignIn(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
profileMessage = "Apple hat keine gültigen Accountdaten zurückgegeben."
return
}
guard let identityTokenData = credential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
profileMessage = "Apple hat kein Identity Token zurückgegeben."
return
}
UserProfileStore.saveAppleAccount(
userID: credential.user,
email: credential.email,
fullName: credential.fullName
)
accountProviderRaw = AccountProvider.apple.rawValue
if let email = credential.email, !email.isEmpty {
accountEmail = email
}
let formatter = PersonNameComponentsFormatter()
let appleName = credential.fullName.map { formatter.string(from: $0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? ""
if !appleName.isEmpty {
displayName = appleName
}
Task {
await syncAppleAccountWithBackend(
identityToken: identityToken,
authorizationCode: credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) },
email: credential.email,
fullName: credential.fullName
)
}
case .failure(let error):
profileMessage = "Apple Login konnte nicht abgeschlossen werden: \(error.localizedDescription)"
}
}
@MainActor
private func syncAppleAccountWithBackend(
identityToken: String,
authorizationCode: String?,
email: String?,
fullName: PersonNameComponents?
) async {
guard AccountBackendStore.backendURL != nil else {
profileMessage = "Apple Account wurde lokal verknüpft. Trage eine Backend-URL ein, um ihn serverseitig zu synchronisieren."
return
}
do {
let formatter = PersonNameComponentsFormatter()
let session = try await AccountBackendClient().signInWithApple(
identityToken: identityToken,
authorizationCode: authorizationCode,
email: email,
displayName: fullName.map { formatter.string(from: $0) }
)
AccountBackendStore.save(session: session)
accountProviderRaw = AccountProvider.apple.rawValue
accountEmail = session.user.email ?? accountEmail
displayName = session.user.displayName
profileMessage = "Apple Account wurde mit dem Backend synchronisiert."
} catch {
profileMessage = "Apple Account wurde lokal verknüpft, aber das Backend konnte nicht synchronisieren: \(error.localizedDescription)"
}
}
} }
private struct SettingsExportFile: Identifiable { private struct SettingsExportFile: Identifiable {

View File

@@ -2,7 +2,7 @@ import AppIntents
import Foundation import Foundation
import WidgetKit import WidgetKit
enum MomentRatingValue: String, AppEnum { enum MomentRatingValue: String, AppEnum, Codable, Hashable, Sendable {
case veryGood = "Sehr gut" case veryGood = "Sehr gut"
case good = "Gut" case good = "Gut"
case middle = "Mittel" case middle = "Mittel"

View File

@@ -65,7 +65,7 @@ struct MomentEntryView: View {
} }
HStack(spacing: 7) { HStack(spacing: 7) {
ForEach(ratings, id: \.self) { rating in ForEach(ratings, id: \.rawValue) { rating in
Button(intent: TrackMomentIntent(rating: rating)) { Button(intent: TrackMomentIntent(rating: rating)) {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Text(rating.emoji) Text(rating.emoji)

91
backend/README.md Normal file
View File

@@ -0,0 +1,91 @@
# FeelAloud Backend
Backend for real accounts:
- E-Mail and password registration/login
- Sign in with Apple ID-token verification
- Google ID-token verification
- JWT session tokens for the app
- Premium entitlement storage prepared for App Store Server verification
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create PostgreSQL database and enable UUID generation:
```sql
create extension if not exists pgcrypto;
```
3. Copy config:
```bash
cp .env.example .env
```
4. Fill `.env`:
- `JWT_SECRET`: at least 32 random characters
- `APPLE_CLIENT_ID`: app bundle id, currently `de.feelaloud`
- `GOOGLE_CLIENT_IDS`: Google OAuth iOS/Web client IDs, comma-separated
5. Initialize DB:
```bash
npm run db:init
```
6. Start local API:
```bash
npm run dev
```
## Endpoints
- `GET /health`
- `POST /auth/email/register`
- `POST /auth/email/login`
- `POST /auth/email/change-password`
- `POST /auth/apple`
- `POST /auth/google`
- `GET /me`
- `GET /premium/status`
- `POST /premium/app-store/transaction`
## iOS Configuration
In the app, set the backend URL in Settings -> Profil once that UI is wired, or set `authBackendURL` in `UserDefaults` during testing:
```swift
UserDefaults.standard.set("http://localhost:8080", forKey: "authBackendURL")
```
For local device testing use your Mac's LAN IP instead of `localhost`.
## Required Apple/Google Console Setup
Apple:
- Paid Apple Developer account
- App capability: Sign in with Apple
- Bundle ID: `de.feelaloud`
- For server/web flows: Services ID and private key if you later exchange authorization codes server-side
Google:
- Google Cloud project
- OAuth consent screen
- iOS OAuth client for bundle ID `de.feelaloud`
- Add the resulting client ID to `GOOGLE_CLIENT_IDS`
Premium:
- Digital premium features must stay on StoreKit/In-App Purchase.
- PayPal is not the payment rail for iOS digital premium unlocks.
- Server-side transaction verification can be completed with App Store Server API once App Store Connect keys are available.

2103
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
backend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "feelaloud-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"db:init": "psql \"$DATABASE_URL\" -f schema.sql"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"google-auth-library": "^10.9.0",
"helmet": "^8.0.0",
"jose": "^5.9.6",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

33
backend/schema.sql Normal file
View File

@@ -0,0 +1,33 @@
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text unique,
display_name text not null default '',
avatar_url text,
password_hash text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists identities (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
provider text not null check (provider in ('apple', 'google', 'email')),
provider_subject text not null,
email text,
created_at timestamptz not null default now(),
unique(provider, provider_subject)
);
create table if not exists premium_entitlements (
user_id uuid primary key references users(id) on delete cascade,
source text not null default 'app_store',
product_id text not null,
active boolean not null default false,
expires_at timestamptz,
updated_at timestamptz not null default now()
);
create index if not exists identities_user_id_idx on identities(user_id);
create index if not exists premium_entitlements_active_idx on premium_entitlements(active);

390
backend/src/server.ts Normal file
View File

@@ -0,0 +1,390 @@
import 'dotenv/config';
import bcrypt from 'bcryptjs';
import cors from 'cors';
import express from 'express';
import { OAuth2Client } from 'google-auth-library';
import helmet from 'helmet';
import jwt from 'jsonwebtoken';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import pg from 'pg';
import { z } from 'zod';
const { Pool } = pg;
const config = {
port: Number(process.env.PORT ?? 8080),
databaseURL: required('DATABASE_URL'),
jwtSecret: required('JWT_SECRET'),
jwtExpiresIn: (process.env.JWT_EXPIRES_IN ?? '30d') as jwt.SignOptions['expiresIn'],
appleClientID: required('APPLE_CLIENT_ID'),
googleClientIDs: (process.env.GOOGLE_CLIENT_IDS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
corsOrigin: process.env.CORS_ORIGIN ?? '*'
};
const pool = new Pool({ connectionString: config.databaseURL });
const googleClient = new OAuth2Client();
const appleJWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
const app = express();
app.use(helmet());
app.use(cors({ origin: config.corsOrigin === '*' ? true : config.corsOrigin }));
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => {
response.json({ ok: true });
});
app.post('/auth/email/register', asyncHandler(async (request, response) => {
const input = emailAuthSchema.extend({
displayName: z.string().trim().max(120).optional()
}).parse(request.body);
const passwordHash = await bcrypt.hash(input.password, 12);
const client = await pool.connect();
try {
await client.query('begin');
const user = await createEmailUser(client, input.email, passwordHash, input.displayName ?? '');
await client.query('commit');
response.json(await sessionResponse(user.id));
} catch (error) {
await client.query('rollback');
if (isUniqueViolation(error)) {
response.status(409).json({ error: 'email_already_registered' });
return;
}
throw error;
} finally {
client.release();
}
}));
app.post('/auth/email/login', asyncHandler(async (request, response) => {
const input = emailAuthSchema.parse(request.body);
const { rows } = await pool.query<UserRow>(
'select * from users where lower(email) = lower($1) and password_hash is not null limit 1',
[input.email]
);
const user = rows[0];
if (!user || !user.password_hash || !(await bcrypt.compare(input.password, user.password_hash))) {
response.status(401).json({ error: 'invalid_credentials' });
return;
}
response.json(await sessionResponse(user.id));
}));
app.post('/auth/email/change-password', requireAuth, asyncHandler(async (request, response) => {
const input = z.object({
currentPassword: z.string().min(8),
newPassword: z.string().min(10).max(200)
}).parse(request.body);
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [request.userID]);
const user = rows[0];
if (!user?.password_hash || !(await bcrypt.compare(input.currentPassword, user.password_hash))) {
response.status(401).json({ error: 'invalid_current_password' });
return;
}
const passwordHash = await bcrypt.hash(input.newPassword, 12);
await pool.query(
'update users set password_hash = $1, updated_at = now() where id = $2',
[passwordHash, request.userID]
);
response.json({ ok: true });
}));
app.post('/auth/apple', asyncHandler(async (request, response) => {
const input = appleAuthSchema.parse(request.body);
const payload = await verifyAppleIdentityToken(input.identityToken);
const subject = String(payload.sub ?? '');
if (!subject) {
response.status(401).json({ error: 'invalid_apple_subject' });
return;
}
const email = typeof payload.email === 'string' ? payload.email : input.email;
const user = await upsertOAuthUser({
provider: 'apple',
providerSubject: subject,
email,
displayName: input.displayName ?? ''
});
response.json(await sessionResponse(user.id));
}));
app.post('/auth/google', asyncHandler(async (request, response) => {
const input = googleAuthSchema.parse(request.body);
if (config.googleClientIDs.length === 0) {
response.status(503).json({ error: 'google_not_configured' });
return;
}
const ticket = await googleClient.verifyIdToken({
idToken: input.idToken,
audience: config.googleClientIDs
});
const payload = ticket.getPayload();
const subject = payload?.sub;
if (!subject) {
response.status(401).json({ error: 'invalid_google_subject' });
return;
}
const user = await upsertOAuthUser({
provider: 'google',
providerSubject: subject,
email: payload.email,
displayName: payload.name ?? ''
});
response.json(await sessionResponse(user.id));
}));
app.get('/me', requireAuth, asyncHandler(async (request, response) => {
response.json({ user: await publicUser(authenticatedUserID(request)) });
}));
app.get('/premium/status', requireAuth, asyncHandler(async (request, response) => {
const { rows } = await pool.query<PremiumRow>(
'select * from premium_entitlements where user_id = $1 limit 1',
[authenticatedUserID(request)]
);
response.json({ premium: entitlementIsActive(rows[0]), entitlement: rows[0] ?? null });
}));
app.post('/premium/app-store/transaction', requireAuth, asyncHandler(async (_request, response) => {
response.status(501).json({
error: 'app_store_server_verification_not_configured',
message: 'StoreKit on-device verification is active in the app. Server-side App Store verification needs App Store Connect API keys and transaction JWS forwarding.'
});
}));
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
if (error instanceof z.ZodError) {
response.status(400).json({ error: 'validation_failed', details: error.flatten() });
return;
}
console.error(error);
response.status(500).json({ error: 'internal_server_error' });
});
app.listen(config.port, () => {
console.log(`FeelAloud backend listening on :${config.port}`);
});
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable ${name}`);
}
return value;
}
const emailAuthSchema = z.object({
email: z.string().trim().email().max(320),
password: z.string().min(8).max(200)
});
const appleAuthSchema = z.object({
identityToken: z.string().min(20),
authorizationCode: z.string().optional(),
email: z.string().email().optional(),
displayName: z.string().trim().max(120).optional()
});
const googleAuthSchema = z.object({
idToken: z.string().min(20)
});
async function verifyAppleIdentityToken(identityToken: string) {
const { payload } = await jwtVerify(identityToken, appleJWKS, {
issuer: 'https://appleid.apple.com',
audience: config.appleClientID
});
return payload;
}
async function createEmailUser(client: pg.PoolClient, email: string, passwordHash: string, displayName: string): Promise<UserRow> {
const insertedUser = await client.query<UserRow>(
`insert into users (email, display_name, password_hash)
values ($1, $2, $3)
returning *`,
[email, displayName, passwordHash]
);
const user = insertedUser.rows[0];
await client.query(
`insert into identities (user_id, provider, provider_subject, email)
values ($1, 'email', $2, $2)`,
[user.id, email]
);
return user;
}
async function upsertOAuthUser(input: OAuthUserInput): Promise<UserRow> {
const client = await pool.connect();
try {
await client.query('begin');
const identity = await client.query<{ user_id: string }>(
'select user_id from identities where provider = $1 and provider_subject = $2 limit 1',
[input.provider, input.providerSubject]
);
if (identity.rows[0]) {
const existingUser = await client.query<UserRow>(
'select * from users where id = $1 limit 1',
[identity.rows[0].user_id]
);
const user = existingUser.rows[0];
if (!user) {
throw new Error('User not found for identity');
}
await client.query('commit');
return user;
}
const existingByEmail = input.email
? await client.query<UserRow>('select * from users where lower(email) = lower($1) limit 1', [input.email])
: { rows: [] as UserRow[] };
const user = existingByEmail.rows[0] ?? (await client.query<UserRow>(
`insert into users (email, display_name)
values ($1, $2)
returning *`,
[input.email ?? null, input.displayName]
)).rows[0];
if (input.displayName && !user.display_name) {
await client.query('update users set display_name = $1, updated_at = now() where id = $2', [input.displayName, user.id]);
user.display_name = input.displayName;
}
await client.query(
`insert into identities (user_id, provider, provider_subject, email)
values ($1, $2, $3, $4)`,
[user.id, input.provider, input.providerSubject, input.email ?? null]
);
await client.query('commit');
return user;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
async function sessionResponse(userID: string) {
return {
token: jwt.sign({ sub: userID }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }),
user: await publicUser(userID)
};
}
async function publicUser(userID: string): Promise<PublicUser> {
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [userID]);
const user = rows[0];
if (!user) {
throw new Error('User not found');
}
return {
id: user.id,
email: user.email,
displayName: user.display_name,
avatarURL: user.avatar_url
};
}
function entitlementIsActive(row?: PremiumRow): boolean {
if (!row || !row.active) {
return false;
}
if (!row.expires_at) {
return true;
}
return new Date(row.expires_at).getTime() > Date.now();
}
function requireAuth(request: express.Request, response: express.Response, next: express.NextFunction) {
const header = request.header('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) {
response.status(401).json({ error: 'missing_token' });
return;
}
try {
const payload = jwt.verify(token, config.jwtSecret);
if (typeof payload !== 'object' || typeof payload.sub !== 'string') {
response.status(401).json({ error: 'invalid_token' });
return;
}
request.userID = payload.sub;
next();
} catch {
response.status(401).json({ error: 'invalid_token' });
}
}
function authenticatedUserID(request: express.Request): string {
if (!request.userID) {
throw new Error('Authenticated route reached without user id');
}
return request.userID;
}
function asyncHandler(handler: express.RequestHandler): express.RequestHandler {
return (request, response, next) => {
Promise.resolve(handler(request, response, next)).catch(next);
};
}
function isUniqueViolation(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
}
type Provider = 'apple' | 'google' | 'email';
interface OAuthUserInput {
provider: Provider;
providerSubject: string;
email?: string;
displayName: string;
}
interface UserRow {
id: string;
email: string | null;
display_name: string;
avatar_url: string | null;
password_hash: string | null;
}
interface PublicUser {
id: string;
email: string | null;
displayName: string;
avatarURL: string | null;
}
interface PremiumRow {
user_id: string;
source: string;
product_id: string;
active: boolean;
expires_at: string | null;
}
declare global {
namespace Express {
interface Request {
userID?: string;
}
}
}

13
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}