feat(auth): add forgot password, reset password, change password, delete account, email verify, token refresh

Web:
- platform-sync.ts: Added forgotPassword, resetPassword, changePassword, verifyEmail, resendVerification, deleteAccount API functions
- auth-context.tsx: Added forgotPassword, resetPassword, changePassword, deleteAccount actions + successMessage state + 45min auto-refresh timer
- settings/page.tsx: Added forgot password link, change password form, delete account form with confirmation
- reset-password/page.tsx: New page for password reset via email token
- verify-email/page.tsx: New page for email verification via token

iOS:
- AuthService.swift: Added forgotPassword, changePassword, deleteAccount methods
- SettingsView.swift: Added change password, delete account, forgot password UI sections

Android:
- AuthService.kt: Added forgotPassword, changePassword, deleteAccount methods
This commit is contained in:
saravanakumardb1 2026-02-28 04:08:58 -08:00
parent 6a41cc9f48
commit e3add90f87
8 changed files with 836 additions and 23 deletions

View File

@ -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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
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<TokenResponse>(responseBody)

View File

@ -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

View File

@ -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

View File

@ -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 (
<div>
<p className="text-sm" style={{ color: 'var(--cm-danger)' }}>
Invalid or missing reset token. Please use the link from your email.
</p>
<Link href="/settings" className="text-xs mt-4 inline-block" style={{ color: 'var(--cm-accent)' }}>
Go to Settings
</Link>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>
Enter your new password below.
</p>
<input
type="password"
placeholder="New password (min 8 characters)"
value={newPassword}
onChange={(e) => 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)',
}}
/>
<input
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => 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 && (
<p className="text-xs" style={{ color: 'var(--cm-danger)' }}>Passwords do not match</p>
)}
{error && <p className="text-xs" style={{ color: 'var(--cm-danger)' }}>{error}</p>}
{successMessage && (
<div>
<p className="text-xs" style={{ color: 'var(--cm-success, #34d399)' }}>{successMessage}</p>
<Link href="/settings" className="text-xs mt-2 inline-block" style={{ color: 'var(--cm-accent)' }}>
Go to Sign In
</Link>
</div>
)}
{!successMessage && (
<button
onClick={handleSubmit}
disabled={submitting || newPassword.length < 8 || newPassword !== confirmPassword}
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
{submitting ? 'Resetting…' : 'Reset Password'}
</button>
)}
</div>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-md mx-auto px-4 py-16">
<Link
href="/settings"
className="flex items-center gap-2 text-sm mb-8"
style={{ color: 'var(--cm-accent)' }}
>
<ArrowLeft size={16} /> Back to Settings
</Link>
<h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--cm-text-primary)' }}>
Reset Password
</h1>
<div
className="rounded-xl border p-6"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<Suspense fallback={<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>Loading</p>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
);
}

View File

@ -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() {
<LogOut size={14} /> Sign Out
</button>
</div>
{authError && <p className="text-xs mt-2" style={{ color: 'var(--cm-danger)' }}>{authError}</p>}
{successMessage && <p className="text-xs mt-2" style={{ color: 'var(--cm-success, #34d399)' }}>{successMessage}</p>}
{/* Change Password */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--cm-border)' }}>
<button
onClick={() => { setShowChangePw(!showChangePw); setShowDeleteAccount(false); clearError(); }}
className="text-xs font-medium cursor-pointer"
style={{ color: 'var(--cm-accent)' }}
>
{showChangePw ? 'Cancel' : 'Change Password'}
</button>
{showChangePw && (
<div className="mt-3 space-y-2">
<input type="password" placeholder="Current password" value={changePwCurrent}
onChange={(e) => 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)' }}
/>
<input type="password" placeholder="New password (min 8 chars)" value={changePwNew}
onChange={(e) => 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)' }}
/>
<input type="password" placeholder="Confirm new password" value={changePwConfirm}
onChange={(e) => 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 && (
<p className="text-xs" style={{ color: 'var(--cm-danger)' }}>Passwords do not match</p>
)}
<button onClick={handleChangePassword}
disabled={authSubmitting || !changePwCurrent || changePwNew.length < 8 || changePwNew !== changePwConfirm}
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
{authSubmitting ? 'Updating…' : 'Update Password'}
</button>
</div>
)}
</div>
{/* Delete Account */}
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--cm-border)' }}>
<button
onClick={() => { setShowDeleteAccount(!showDeleteAccount); setShowChangePw(false); clearError(); }}
className="text-xs font-medium cursor-pointer"
style={{ color: 'var(--cm-danger)' }}
>
{showDeleteAccount ? 'Cancel' : 'Delete Account'}
</button>
{showDeleteAccount && (
<div className="mt-3 space-y-2">
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
This action is permanent. Enter your password to confirm.
</p>
<input type="password" placeholder="Your password" value={deleteConfirmPw}
onChange={(e) => 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)' }}
/>
<button onClick={handleDeleteAccount}
disabled={authSubmitting || !deleteConfirmPw}
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
style={{ backgroundColor: 'var(--cm-danger)', color: '#fff' }}
>
{authSubmitting ? 'Deleting…' : 'Permanently Delete Account'}
</button>
</div>
)}
</div>
</div>
) : (
<div>
@ -163,29 +274,52 @@ export default function SettingsPage() {
border: '1px solid var(--cm-border)',
}}
/>
<input
type="password"
placeholder="Password"
value={authPassword}
onChange={(e) => 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' && (
<input
type="password"
placeholder="Password (min 8 characters)"
value={authPassword}
onChange={(e) => 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 && (
<p className="text-xs" style={{ color: 'var(--cm-danger)' }}>{authError}</p>
)}
{successMessage && (
<p className="text-xs" style={{ color: 'var(--cm-success, #34d399)' }}>{successMessage}</p>
)}
<button
onClick={handleAuthSubmit}
disabled={authSubmitting || !authEmail || !authPassword || (authMode === 'register' && !authName)}
disabled={authSubmitting || !authEmail || (authMode !== 'forgot' && !authPassword) || (authMode === 'register' && !authName)}
className="w-full px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
{authSubmitting ? 'Please wait…' : authMode === 'login' ? 'Sign In' : 'Create Account'}
{authSubmitting ? 'Please wait…' : authMode === 'login' ? 'Sign In' : authMode === 'register' ? 'Create Account' : 'Send Reset Link'}
</button>
{authMode === 'login' && (
<button
onClick={() => { setAuthMode('forgot'); clearError(); }}
className="w-full text-xs cursor-pointer mt-1"
style={{ color: 'var(--cm-text-tertiary)' }}
>
Forgot password?
</button>
)}
{authMode === 'forgot' && (
<button
onClick={() => { setAuthMode('login'); clearError(); }}
className="w-full text-xs cursor-pointer mt-1"
style={{ color: 'var(--cm-text-tertiary)' }}
>
Back to sign in
</button>
)}
</div>
</div>
)}

View File

@ -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 (
<div className="text-center space-y-4">
{status === 'verifying' && (
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>Verifying your email</p>
)}
{status === 'success' && (
<>
<CheckCircle size={48} style={{ color: 'var(--cm-success, #34d399)', margin: '0 auto' }} />
<p className="text-sm font-medium" style={{ color: 'var(--cm-success, #34d399)' }}>{message}</p>
<Link href="/settings" className="text-sm inline-block" style={{ color: 'var(--cm-accent)' }}>
Go to Settings
</Link>
</>
)}
{status === 'error' && (
<>
<p className="text-sm" style={{ color: 'var(--cm-danger)' }}>{message}</p>
<Link href="/settings" className="text-sm inline-block" style={{ color: 'var(--cm-accent)' }}>
Go to Settings
</Link>
</>
)}
</div>
);
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-md mx-auto px-4 py-16">
<Link
href="/"
className="flex items-center gap-2 text-sm mb-8"
style={{ color: 'var(--cm-accent)' }}
>
<ArrowLeft size={16} /> Back to Dashboard
</Link>
<h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--cm-text-primary)' }}>
Email Verification
</h1>
<div
className="rounded-xl border p-6"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<Suspense fallback={<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>Loading</p>}>
<VerifyEmailForm />
</Suspense>
</div>
</div>
</div>
);
}

View File

@ -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<boolean>;
logout: () => void;
clearError: () => void;
forgotPassword: (email: string) => Promise<boolean>;
resetPassword: (token: string, newPassword: string) => Promise<boolean>;
changePassword: (currentPassword: string, newPassword: string) => Promise<boolean>;
deleteAccount: (password: string) => Promise<boolean>;
successMessage: string | null;
}
type AuthContextValue = AuthState & AuthActions;
@ -38,6 +48,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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 (
<AuthContext.Provider
@ -101,10 +188,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isLoading,
isAuthenticated: user !== null,
error,
successMessage,
login,
register,
logout,
clearError,
forgotPassword,
resetPassword,
changePassword,
deleteAccount,
}}
>
{children}

View File

@ -257,6 +257,42 @@ export async function getMe(): Promise<AuthUser> {
return apiRequest<AuthUser>('/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<SyncTimerDTO[]> {