diff --git a/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt b/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt index 102dc79..62df8f5 100644 --- a/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt +++ b/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt @@ -197,6 +197,102 @@ class AuthService @Inject constructor( } } + suspend fun forgotPassword(email: String): String? = withContext(Dispatchers.IO) { + val baseUrl = getBaseUrl() + val body = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("email" to email, "productId" to PRODUCT_ID), + ) + try { + val url = URL("$baseUrl/auth/forgot-password") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("X-Product-Id", PRODUCT_ID) + conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString()) + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.doOutput = true + conn.outputStream.use { it.write(body.toByteArray()) } + + if (conn.responseCode == 200) null + else "Failed to send reset email" + } catch (e: Exception) { + e.message ?: "Network error" + } + } + + suspend fun changePassword(currentPassword: String, newPassword: String): String? = withContext(Dispatchers.IO) { + val token = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return@withContext "Not authenticated" + val baseUrl = getBaseUrl() + val body = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("currentPassword" to currentPassword, "newPassword" to newPassword), + ) + try { + val url = URL("$baseUrl/auth/change-password") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("X-Product-Id", PRODUCT_ID) + conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString()) + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.doOutput = true + conn.outputStream.use { it.write(body.toByteArray()) } + + if (conn.responseCode == 200) null + else { + val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { null } + errBody ?: "Failed to change password" + } + } catch (e: Exception) { + e.message ?: "Network error" + } + } + + suspend fun deleteAccount(password: String): String? = withContext(Dispatchers.IO) { + val token = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return@withContext "Not authenticated" + val baseUrl = getBaseUrl() + val body = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("password" to password), + ) + try { + val url = URL("$baseUrl/auth/account") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "DELETE" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("X-Product-Id", PRODUCT_ID) + conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString()) + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.doOutput = true + conn.outputStream.use { it.write(body.toByteArray()) } + + if (conn.responseCode == 200) { + withContext(Dispatchers.Main) { logout() } + null + } else { + val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { null } + errBody ?: "Failed to delete account" + } + } catch (e: Exception) { + e.message ?: "Network error" + } + } + private fun handleAuthResult(responseBody: String) { try { val tokenResp = json.decodeFromString(responseBody) diff --git a/ios/ChronoMind/Shared/Cloud/AuthService.swift b/ios/ChronoMind/Shared/Cloud/AuthService.swift index 5dd7b60..2caa06e 100644 --- a/ios/ChronoMind/Shared/Cloud/AuthService.swift +++ b/ios/ChronoMind/Shared/Cloud/AuthService.swift @@ -145,6 +145,90 @@ final class CMAuthService: ObservableObject { PlatformSyncManager.shared.setAuthToken(nil) } + func forgotPassword(email: String) async -> String? { + guard let url = URL(string: "\(baseURL)/auth/forgot-password"), + let jsonData = try? JSONSerialization.data(withJSONObject: [ + "email": email, "productId": productId + ]) else { return "Invalid request" } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.httpBody = jsonData + request.timeoutInterval = 15 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return "Failed to send reset email" + } + return nil + } catch { + return error.localizedDescription + } + } + + func changePassword(currentPassword: String, newPassword: String) async -> String? { + guard !accessToken.isEmpty, + let url = URL(string: "\(baseURL)/auth/change-password"), + let jsonData = try? JSONSerialization.data(withJSONObject: [ + "currentPassword": currentPassword, "newPassword": newPassword + ]) else { return "Not authenticated" } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.httpBody = jsonData + request.timeoutInterval = 15 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = body["message"] as? String { return msg } + return "Failed to change password" + } + return nil + } catch { + return error.localizedDescription + } + } + + func deleteAccount(password: String) async -> String? { + guard !accessToken.isEmpty, + let url = URL(string: "\(baseURL)/auth/account"), + let jsonData = try? JSONSerialization.data(withJSONObject: [ + "password": password + ]) else { return "Not authenticated" } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.httpBody = jsonData + request.timeoutInterval = 15 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + if let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = body["message"] as? String { return msg } + return "Failed to delete account" + } + logout() + return nil + } catch { + return error.localizedDescription + } + } + var isLoggedIn: Bool { if case .loggedIn = state { return true } return false diff --git a/ios/ChronoMind/Views/Settings/SettingsView.swift b/ios/ChronoMind/Views/Settings/SettingsView.swift index 77ec039..bb0b152 100644 --- a/ios/ChronoMind/Views/Settings/SettingsView.swift +++ b/ios/ChronoMind/Views/Settings/SettingsView.swift @@ -15,6 +15,17 @@ struct SettingsView: View { @AppStorage("cm_hapticEnabled") private var hapticEnabled = true @AppStorage("cm_soundEnabled") private var soundEnabled = true @State private var showFeedback = false + @State private var showChangePw = false + @State private var changePwCurrent = "" + @State private var changePwNew = "" + @State private var changePwConfirm = "" + @State private var showDeleteAccount = false + @State private var deleteConfirmPw = "" + @State private var showForgotPw = false + @State private var forgotPwEmail = "" + @State private var authMessage = "" + @State private var authIsError = false + @State private var authSubmitting = false var body: some View { NavigationStack { @@ -46,6 +57,113 @@ struct SettingsView: View { } label: { Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") } + + if !authMessage.isEmpty { + Text(authMessage) + .font(CMFonts.body(size: 12)) + .foregroundStyle(authIsError ? CMColors.error : CMColors.success) + } + + // Change Password + Button { + showChangePw.toggle() + showDeleteAccount = false + authMessage = "" + } label: { + Label(showChangePw ? "Cancel" : "Change Password", systemImage: "key.fill") + .foregroundStyle(CMColors.accent) + } + + if showChangePw { + SecureField("Current password", text: $changePwCurrent) + .textContentType(.password) + SecureField("New password (min 8 chars)", text: $changePwNew) + .textContentType(.newPassword) + SecureField("Confirm new password", text: $changePwConfirm) + .textContentType(.newPassword) + + if !changePwNew.isEmpty && !changePwConfirm.isEmpty && changePwNew != changePwConfirm { + Text("Passwords do not match") + .font(CMFonts.body(size: 12)) + .foregroundStyle(CMColors.error) + } + + Button { + Task { await performChangePassword() } + } label: { + HStack { + Spacer() + Text(authSubmitting ? "Updating\u{2026}" : "Update Password") + .font(CMFonts.body(size: 14, weight: .semibold)) + Spacer() + } + } + .disabled(authSubmitting || changePwCurrent.isEmpty || changePwNew.count < 8 || changePwNew != changePwConfirm) + } + + // Delete Account + Button { + showDeleteAccount.toggle() + showChangePw = false + authMessage = "" + } label: { + Label(showDeleteAccount ? "Cancel" : "Delete Account", systemImage: "trash.fill") + .foregroundStyle(CMColors.error) + } + + if showDeleteAccount { + Text("This action is permanent. Enter your password to confirm.") + .font(CMFonts.body(size: 12)) + .foregroundStyle(CMColors.textMuted) + SecureField("Your password", text: $deleteConfirmPw) + .textContentType(.password) + Button(role: .destructive) { + Task { await performDeleteAccount() } + } label: { + HStack { + Spacer() + Text(authSubmitting ? "Deleting\u{2026}" : "Permanently Delete Account") + .font(CMFonts.body(size: 14, weight: .semibold)) + Spacer() + } + } + .disabled(authSubmitting || deleteConfirmPw.isEmpty) + } + } else { + // Forgot Password (shown when logged out) + Button { + showForgotPw.toggle() + authMessage = "" + } label: { + Label(showForgotPw ? "Cancel" : "Forgot Password?", systemImage: "questionmark.circle") + .foregroundStyle(CMColors.textSecondary) + .font(CMFonts.body(size: 13)) + } + + if showForgotPw { + TextField("Email", text: $forgotPwEmail) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + + if !authMessage.isEmpty { + Text(authMessage) + .font(CMFonts.body(size: 12)) + .foregroundStyle(authIsError ? CMColors.error : CMColors.success) + } + + Button { + Task { await performForgotPassword() } + } label: { + HStack { + Spacer() + Text(authSubmitting ? "Sending\u{2026}" : "Send Reset Link") + .font(CMFonts.body(size: 14, weight: .semibold)) + Spacer() + } + } + .disabled(authSubmitting || forgotPwEmail.isEmpty) + } } } header: { Text("Account") @@ -248,6 +366,54 @@ struct SettingsView: View { } } } + + // MARK: - Auth Actions + + private func performChangePassword() async { + authSubmitting = true + authMessage = "" + let err = await authService.changePassword(currentPassword: changePwCurrent, newPassword: changePwNew) + authSubmitting = false + if let err { + authMessage = err + authIsError = true + } else { + authMessage = "Password changed successfully." + authIsError = false + changePwCurrent = "" + changePwNew = "" + changePwConfirm = "" + showChangePw = false + } + } + + private func performDeleteAccount() async { + authSubmitting = true + authMessage = "" + let err = await authService.deleteAccount(password: deleteConfirmPw) + authSubmitting = false + if let err { + authMessage = err + authIsError = true + } else { + deleteConfirmPw = "" + showDeleteAccount = false + } + } + + private func performForgotPassword() async { + authSubmitting = true + authMessage = "" + let err = await authService.forgotPassword(email: forgotPwEmail) + authSubmitting = false + if let err { + authMessage = err + authIsError = true + } else { + authMessage = "If that email exists, a reset link has been sent." + authIsError = false + } + } } // MARK: - Feedback Sheet diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx new file mode 100644 index 0000000..424d17d --- /dev/null +++ b/web/src/app/reset-password/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { useAuth } from '@/lib/auth-context'; + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get('token') ?? ''; + const { resetPassword, error, successMessage, clearError } = useAuth(); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + clearError(); + }, [clearError]); + + const handleSubmit = async () => { + if (newPassword !== confirmPassword || newPassword.length < 8 || !token) return; + setSubmitting(true); + await resetPassword(token, newPassword); + setSubmitting(false); + }; + + if (!token) { + return ( +
+

+ Invalid or missing reset token. Please use the link from your email. +

+ + Go to Settings + +
+ ); + } + + return ( +
+

+ Enter your new password below. +

+ setNewPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + {newPassword && confirmPassword && newPassword !== confirmPassword && ( +

Passwords do not match

+ )} + {error &&

{error}

} + {successMessage && ( +
+

{successMessage}

+ + Go to Sign In + +
+ )} + {!successMessage && ( + + )} +
+ ); +} + +export default function ResetPasswordPage() { + return ( +
+
+ + Back to Settings + +

+ Reset Password +

+
+ Loading…

}> + +
+
+
+
+ ); +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 7136a36..15c8969 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -18,12 +18,21 @@ export default function SettingsPage() { const { theme, toggle: toggleTheme } = useTheme(); const timers = useTimerStore((s) => s.timers); const removeTimer = useTimerStore((s) => s.removeTimer); - const { user, isAuthenticated, isLoading: authLoading, error: authError, login, register, logout, clearError } = useAuth(); - const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); + const { + user, isAuthenticated, isLoading: authLoading, error: authError, successMessage, + login, register, logout, clearError, forgotPassword, changePassword, deleteAccount, + } = useAuth(); + const [authMode, setAuthMode] = useState<'login' | 'register' | 'forgot'>('login'); const [authEmail, setAuthEmail] = useState(''); const [authPassword, setAuthPassword] = useState(''); const [authName, setAuthName] = useState(''); const [authSubmitting, setAuthSubmitting] = useState(false); + const [changePwCurrent, setChangePwCurrent] = useState(''); + const [changePwNew, setChangePwNew] = useState(''); + const [changePwConfirm, setChangePwConfirm] = useState(''); + const [showChangePw, setShowChangePw] = useState(false); + const [showDeleteAccount, setShowDeleteAccount] = useState(false); + const [deleteConfirmPw, setDeleteConfirmPw] = useState(''); useEffect(() => { setMounted(true); @@ -41,17 +50,47 @@ export default function SettingsPage() { const handleAuthSubmit = async () => { setAuthSubmitting(true); clearError(); - const ok = authMode === 'login' - ? await login(authEmail, authPassword) - : await register(authEmail, authPassword, authName); + let ok = false; + if (authMode === 'forgot') { + ok = await forgotPassword(authEmail); + } else if (authMode === 'login') { + ok = await login(authEmail, authPassword); + } else { + ok = await register(authEmail, authPassword, authName); + } setAuthSubmitting(false); if (ok) { - setAuthEmail(''); - setAuthPassword(''); - setAuthName(''); + if (authMode !== 'forgot') { + setAuthEmail(''); + setAuthPassword(''); + setAuthName(''); + } } }; + const handleChangePassword = async () => { + if (changePwNew !== changePwConfirm) return; + setAuthSubmitting(true); + clearError(); + const ok = await changePassword(changePwCurrent, changePwNew); + setAuthSubmitting(false); + if (ok) { + setChangePwCurrent(''); + setChangePwNew(''); + setChangePwConfirm(''); + setShowChangePw(false); + } + }; + + const handleDeleteAccount = async () => { + setAuthSubmitting(true); + clearError(); + await deleteAccount(deleteConfirmPw); + setAuthSubmitting(false); + setDeleteConfirmPw(''); + setShowDeleteAccount(false); + }; + if (!mounted) return null; const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length; @@ -108,6 +147,78 @@ export default function SettingsPage() { Sign Out + {authError &&

{authError}

} + {successMessage &&

{successMessage}

} + + {/* Change Password */} +
+ + {showChangePw && ( +
+ setChangePwCurrent(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + setChangePwNew(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + setChangePwConfirm(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + {changePwNew && changePwConfirm && changePwNew !== changePwConfirm && ( +

Passwords do not match

+ )} + +
+ )} +
+ + {/* Delete Account */} +
+ + {showDeleteAccount && ( +
+

+ This action is permanent. Enter your password to confirm. +

+ setDeleteConfirmPw(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + +
+ )} +
) : (
@@ -163,29 +274,52 @@ export default function SettingsPage() { border: '1px solid var(--cm-border)', }} /> - setAuthPassword(e.target.value)} - className="w-full px-3 py-2 rounded-lg text-sm outline-none" - style={{ - backgroundColor: 'var(--cm-surface-muted)', - color: 'var(--cm-text-primary)', - border: '1px solid var(--cm-border)', - }} - /> + {authMode !== 'forgot' && ( + setAuthPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ + backgroundColor: 'var(--cm-surface-muted)', + color: 'var(--cm-text-primary)', + border: '1px solid var(--cm-border)', + }} + /> + )} {authError && (

{authError}

)} + {successMessage && ( +

{successMessage}

+ )} + {authMode === 'login' && ( + + )} + {authMode === 'forgot' && ( + + )}
)} diff --git a/web/src/app/verify-email/page.tsx b/web/src/app/verify-email/page.tsx new file mode 100644 index 0000000..dfc0a0c --- /dev/null +++ b/web/src/app/verify-email/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { ArrowLeft, CheckCircle } from 'lucide-react'; +import { verifyEmail } from '@/lib/platform-sync'; + +function VerifyEmailForm() { + const searchParams = useSearchParams(); + const token = searchParams.get('token') ?? ''; + const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying'); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!token) { + setStatus('error'); + setMessage('Invalid or missing verification token.'); + return; + } + verifyEmail(token) + .then((res) => { + setStatus('success'); + setMessage(res.message); + }) + .catch((err) => { + setStatus('error'); + setMessage(err instanceof Error ? err.message : 'Verification failed'); + }); + }, [token]); + + return ( +
+ {status === 'verifying' && ( +

Verifying your email…

+ )} + {status === 'success' && ( + <> + +

{message}

+ + Go to Settings + + + )} + {status === 'error' && ( + <> +

{message}

+ + Go to Settings + + + )} +
+ ); +} + +export default function VerifyEmailPage() { + return ( +
+
+ + Back to Dashboard + +

+ Email Verification +

+
+ Loading…

}> + +
+
+
+
+ ); +} diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx index c7583f2..d342108 100644 --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -13,6 +13,11 @@ import { setRefreshToken, setSyncEnabled, isAuthenticated as checkAuth, + refreshAccessToken, + forgotPassword as apiForgotPassword, + resetPassword as apiResetPassword, + changePassword as apiChangePassword, + deleteAccount as apiDeleteAccount, type AuthUser, } from './platform-sync'; @@ -28,6 +33,11 @@ interface AuthActions { register: (email: string, password: string, displayName: string) => Promise; logout: () => void; clearError: () => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; + deleteAccount: (password: string) => Promise; + successMessage: string | null; } type AuthContextValue = AuthState & AuthActions; @@ -38,6 +48,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); // Hydrate user from stored token on mount useEffect(() => { @@ -53,6 +64,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { .finally(() => setIsLoading(false)); }, []); + // Auto-refresh token every 45 minutes + useEffect(() => { + if (!user) return; + const REFRESH_INTERVAL = 45 * 60 * 1000; + const timer = setInterval(async () => { + const refreshed = await refreshAccessToken(); + if (!refreshed) { + setUser(null); + } + }, REFRESH_INTERVAL); + return () => clearInterval(timer); + }, [user]); + const login = useCallback(async (email: string, password: string): Promise => { setError(null); try { @@ -92,7 +116,70 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); - const clearError = useCallback(() => setError(null), []); + const clearError = useCallback(() => { + setError(null); + setSuccessMessage(null); + }, []); + + const forgotPassword = useCallback(async (email: string): Promise => { + setError(null); + setSuccessMessage(null); + try { + const result = await apiForgotPassword(email); + setSuccessMessage(result.message); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send reset email'); + return false; + } + }, []); + + const resetPassword = useCallback(async (token: string, newPassword: string): Promise => { + setError(null); + setSuccessMessage(null); + try { + const result = await apiResetPassword(token, newPassword); + setSuccessMessage(result.message); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reset password'); + return false; + } + }, []); + + const changePassword = useCallback( + async (currentPassword: string, newPassword: string): Promise => { + setError(null); + setSuccessMessage(null); + try { + const result = await apiChangePassword(currentPassword, newPassword); + setSuccessMessage(result.message); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to change password'); + return false; + } + }, + [] + ); + + const deleteAccount = useCallback( + async (password: string): Promise => { + setError(null); + try { + await apiDeleteAccount(password); + setAuthToken(null); + setSyncEnabled(false); + setUser(null); + setSuccessMessage('Account deleted successfully.'); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete account'); + return false; + } + }, + [] + ); return ( {children} diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index 0b9cd13..6fedde0 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -257,6 +257,42 @@ export async function getMe(): Promise { return apiRequest('/auth/me', 'GET'); } +export async function forgotPassword(email: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/forgot-password', 'POST', { + email, + productId: PRODUCT_ID, + }); +} + +export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/reset-password', 'POST', { token, newPassword }); +} + +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/change-password', 'POST', { + currentPassword, + newPassword, + }); +} + +export async function verifyEmail(token: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/verify-email', 'POST', { token }); +} + +export async function resendVerification(email: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/resend-verification', 'POST', { + email, + productId: PRODUCT_ID, + }); +} + +export async function deleteAccount(password: string): Promise<{ message: string }> { + return apiRequest<{ message: string }>('/auth/account', 'DELETE', { password }); +} + // ── Sync Operations ─────────────────────────────────────────── export async function pullDelta(since?: string): Promise {