Compare commits
2 Commits
10fca0756d
...
39a0e35fd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a0e35fd2 | ||
|
|
77d93c4f8d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
111
FeelAloud/AccountBackend.swift
Normal file
111
FeelAloud/AccountBackend.swift
Normal 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?
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
91
backend/README.md
Normal 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
2103
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/package.json
Normal file
34
backend/package.json
Normal 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
33
backend/schema.sql
Normal 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
390
backend/src/server.ts
Normal 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
13
backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user