Compare commits
2 Commits
10fca0756d
...
39a0e35fd2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a0e35fd2 | ||
|
|
77d93c4f8d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,6 +20,10 @@ DerivedData/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
|
||||
# Node backend
|
||||
node_modules/
|
||||
backend/dist/
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
@@ -30,4 +34,3 @@ Package.resolved
|
||||
# Temporary exports
|
||||
exports/
|
||||
*.tmp
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
E296A44130110492003F93AA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296A44030110491003F93AA /* WidgetKit.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, ); }; };
|
||||
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = E296A465301164BE003F93AA /* AccountBackend.swift */; };
|
||||
/* End PBXBuildFile 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; };
|
||||
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>"; };
|
||||
E296A465301164BE003F93AA /* AccountBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBackend.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -158,6 +160,7 @@
|
||||
E296A4343010C110003F93AA /* TherapySupportView.swift */,
|
||||
E296A4363010C14D003F93AA /* AppLockView.swift */,
|
||||
E296A4383010FDD6003F93AA /* AppTheme.swift */,
|
||||
E296A465301164BE003F93AA /* AccountBackend.swift */,
|
||||
);
|
||||
path = FeelAloud;
|
||||
sourceTree = "<group>";
|
||||
@@ -303,6 +306,7 @@
|
||||
A10000000000000000000008 /* HomeView.swift in Sources */,
|
||||
E296A4393010FDD6003F93AA /* AppTheme.swift in Sources */,
|
||||
A10000000000000000000009 /* EntryFlowView.swift in Sources */,
|
||||
E296A466301164BE003F93AA /* AccountBackend.swift in Sources */,
|
||||
A1000000000000000000000A /* HistoryView.swift in Sources */,
|
||||
A1000000000000000000000B /* SettingsView.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 {
|
||||
AppColorProfile(rawValue: rawValue) ?? .lavender
|
||||
AppColorProfile(rawValue: rawValue) ?? .sage
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppColorProfileKey: EnvironmentKey {
|
||||
static let defaultValue: AppColorProfile = .lavender
|
||||
static let defaultValue: AppColorProfile = .sage
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppIntents
|
||||
import AuthenticationServices
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
@@ -12,8 +13,8 @@ struct RootView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Query(sort: \MoodSnapshot.createdAt, order: .reverse) private var snapshots: [MoodSnapshot]
|
||||
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue
|
||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = false
|
||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
|
||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
|
||||
@AppStorage("lastPresentedFollowUpSnapshotID") private var lastPresentedFollowUpSnapshotID = ""
|
||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||
@State private var isUnlocked = false
|
||||
@@ -188,6 +189,9 @@ enum UserProfileStore {
|
||||
static let onboardingCompletedKey = "hasCompletedOnboarding"
|
||||
static let displayNameKey = "userDisplayName"
|
||||
static let profileImageDataKey = "userProfileImageData"
|
||||
static let accountProviderKey = "accountProvider"
|
||||
static let accountEmailKey = "accountEmail"
|
||||
static let appleUserIDKey = "appleUserID"
|
||||
|
||||
static func saveProfile(displayName: String, imageData: Data?) {
|
||||
UserDefaults.standard.set(displayName.trimmingCharacters(in: .whitespacesAndNewlines), forKey: displayNameKey)
|
||||
@@ -196,12 +200,50 @@ enum UserProfileStore {
|
||||
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 {
|
||||
@Environment(\.appColorProfile) private var colorProfile
|
||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||
@AppStorage(UserProfileStore.displayNameKey) private var storedDisplayName = ""
|
||||
@AppStorage(UserProfileStore.accountProviderKey) private var accountProviderRaw = AccountProvider.local.rawValue
|
||||
|
||||
@State private var page = 0
|
||||
@State private var displayName = ""
|
||||
@@ -209,6 +251,7 @@ private struct OnboardingView: View {
|
||||
@State private var profileImageData: Data?
|
||||
@State private var customCategories: [DiaryCategory] = []
|
||||
@State private var newCategoryName = ""
|
||||
@State private var accountMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -245,6 +288,11 @@ private struct OnboardingView: View {
|
||||
.onChange(of: selectedPhoto) { _, item in
|
||||
Task { await loadSelectedPhoto(item) }
|
||||
}
|
||||
.alert("Account", isPresented: accountMessageBinding) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(accountMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var progressDots: some View {
|
||||
@@ -286,14 +334,61 @@ private struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private var snapshotPage: some View {
|
||||
onboardingPage(
|
||||
VStack(spacing: 20) {
|
||||
onboardingHeader(
|
||||
symbol: "face.smiling",
|
||||
title: "Kurze Momentaufnahme",
|
||||
text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget.",
|
||||
rows: MoodRating.allCases.map { rating in
|
||||
OnboardingRow(symbol: rating.symbol, title: rating.rawValue, text: "Ein schneller Check-in für deinen Verlauf.")
|
||||
}
|
||||
text: "Mit fünf einfachen Stimmungen trackst du schnell, wie es dir im Alltag geht, auch über Erinnerungen oder das Widget."
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -393,15 +488,41 @@ private struct OnboardingView: View {
|
||||
private var accountPage: some View {
|
||||
VStack(spacing: 20) {
|
||||
onboardingHeader(
|
||||
symbol: "icloud.and.arrow.up.fill",
|
||||
title: "Zugang 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."
|
||||
symbol: "person.crop.circle.badge.checkmark",
|
||||
title: "Account sichern",
|
||||
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) {
|
||||
accountRow(symbol: "person.crop.circle.badge.checkmark", title: "Profil", text: displayName.isEmpty ? "Ohne Namen fortfahren" : displayName)
|
||||
accountRow(symbol: "icloud", title: "Datenzugang", text: "Über deinen Apple-Account und iCloud absichern")
|
||||
accountRow(symbol: "checkmark.seal", title: "Premium", text: "Käufe können über die Apple-ID wiederhergestellt werden")
|
||||
VStack(spacing: 12) {
|
||||
SignInWithAppleButton(.signUp) { request in
|
||||
request.requestedScopes = [.fullName, .email]
|
||||
} 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)
|
||||
.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 {
|
||||
HStack(spacing: 12) {
|
||||
if page > 0 {
|
||||
@@ -443,18 +571,19 @@ private struct OnboardingView: View {
|
||||
VStack(spacing: 20) {
|
||||
onboardingHeader(symbol: symbol, title: title, text: text)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(rows) { row in
|
||||
HStack(spacing: 12) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: row.symbol)
|
||||
.font(.headline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(colorProfile.accent)
|
||||
.frame(width: 34, height: 34)
|
||||
.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)
|
||||
.font(.headline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(row.text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -462,10 +591,18 @@ private struct OnboardingView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.vertical, 11)
|
||||
|
||||
if index < rows.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 4)
|
||||
.background(.background.opacity(0.58), in: RoundedRectangle(cornerRadius: 18))
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -547,6 +684,70 @@ private struct OnboardingView: View {
|
||||
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 {
|
||||
guard let item,
|
||||
let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
@@ -573,6 +774,13 @@ private struct OnboardingRow: Identifiable {
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct OnboardingSnapshotScaleItem: Identifiable {
|
||||
let rating: MoodRating
|
||||
let description: String
|
||||
|
||||
var id: String { rating.id }
|
||||
}
|
||||
|
||||
private struct OnboardingProfileImage: View {
|
||||
let imageData: Data?
|
||||
let accent: Color
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppIntents
|
||||
import AuthenticationServices
|
||||
import FoundationModels
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
@@ -15,14 +16,17 @@ struct SettingsView: View {
|
||||
@AppStorage(UserProfileStore.onboardingCompletedKey) private var hasCompletedOnboarding = false
|
||||
@AppStorage(UserProfileStore.displayNameKey) private var displayName = ""
|
||||
@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("snapshotReminderCount") private var reminderCount = 3
|
||||
@AppStorage("snapshotReminderTime1") private var reminderTime1 = 10 * 60
|
||||
@AppStorage("snapshotReminderTime2") private var reminderTime2 = 15 * 60
|
||||
@AppStorage("snapshotReminderTime3") private var reminderTime3 = 20 * 60
|
||||
@AppStorage("appLockEnabled") private var appLockEnabled = false
|
||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.lavender.rawValue
|
||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = false
|
||||
@AppStorage("appColorProfile") private var colorProfileRaw = AppColorProfile.sage.rawValue
|
||||
@AppStorage("forceLightAppearance") private var forceLightAppearance = true
|
||||
@AppStorage(PremiumAccess.storageKey) private var premiumFeaturesEnabled = false
|
||||
@AppStorage(FeelAloudModelStore.usedFallbackKey) private var swiftDataUsedFallback = false
|
||||
|
||||
@@ -198,7 +202,39 @@ struct SettingsView: View {
|
||||
}
|
||||
.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") {
|
||||
@@ -226,7 +262,7 @@ struct SettingsView: View {
|
||||
|
||||
Section("Zugang") {
|
||||
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("Passwort ändern", systemImage: "key")
|
||||
}
|
||||
@@ -495,6 +531,10 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var currentAccountProvider: AccountProvider {
|
||||
AccountProvider(rawValue: accountProviderRaw) ?? .local
|
||||
}
|
||||
|
||||
private var profileAvatar: some View {
|
||||
Group {
|
||||
#if canImport(UIKit)
|
||||
@@ -726,8 +766,84 @@ struct SettingsView: View {
|
||||
private func logoutProfile() {
|
||||
displayName = ""
|
||||
profileImageData = Data()
|
||||
accountEmail = ""
|
||||
accountProviderRaw = AccountProvider.local.rawValue
|
||||
UserProfileStore.clearAccount()
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppIntents
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
enum MomentRatingValue: String, AppEnum {
|
||||
enum MomentRatingValue: String, AppEnum, Codable, Hashable, Sendable {
|
||||
case veryGood = "Sehr gut"
|
||||
case good = "Gut"
|
||||
case middle = "Mittel"
|
||||
|
||||
@@ -65,7 +65,7 @@ struct MomentEntryView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 7) {
|
||||
ForEach(ratings, id: \.self) { rating in
|
||||
ForEach(ratings, id: \.rawValue) { rating in
|
||||
Button(intent: TrackMomentIntent(rating: rating)) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
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