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:
parent
6a41cc9f48
commit
e3add90f87
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
122
web/src/app/reset-password/page.tsx
Normal file
122
web/src/app/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
83
web/src/app/verify-email/page.tsx
Normal file
83
web/src/app/verify-email/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user