From 64b3bfd0d471f5d7927f565339bafea54677cc45 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Fri, 20 Feb 2026 13:35:19 +0100 Subject: [PATCH] feat(settings): add Security section with change password form (task 12.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/auth/profile endpoint to expose user provider info - Settings page fetches provider on load to detect credentials vs OAuth - Credentials users: change password form (current/new/confirm) calling PUT /api/auth/password - OAuth (Google) users: "Signed in via Google — password cannot be changed" message - Client-side validation: min 8 chars, passwords-must-match before API call - Success and error feedback displayed inline in the Security card Co-Authored-By: Claude Sonnet 4.6 --- openspec/changes/user-accounts/tasks.md | 2 +- src/app/api/auth/profile/route.ts | 24 +++ src/app/app/settings/page.tsx | 214 ++++++++++++++++++++++-- 3 files changed, 228 insertions(+), 12 deletions(-) diff --git a/openspec/changes/user-accounts/tasks.md b/openspec/changes/user-accounts/tasks.md index 661c7e8..e5b84c7 100644 --- a/openspec/changes/user-accounts/tasks.md +++ b/openspec/changes/user-accounts/tasks.md @@ -69,7 +69,7 @@ ## 12. Settings Page - [x] 12.1 `[sonnet]` Create `src/app/app/settings/page.tsx` — Profile section: display name input with save, read-only email -- [ ] 12.2 `[sonnet]` Add Security section: change password form (current/new/confirm) for credentials users, "Signed in via Google" for OAuth users +- [x] 12.2 `[sonnet]` Add Security section: change password form (current/new/confirm) for credentials users, "Signed in via Google" for OAuth users - [ ] 12.3 `[sonnet]` Add Danger Zone section: delete account button with confirmation dialog (type "DELETE" to confirm) - [ ] 12.4 `[haiku]` Add back navigation link to `/app` diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 9b55d88..a54acfd 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -4,6 +4,30 @@ import { db } from '@/lib/db'; import { users } from '@/lib/db/schema'; import { getAuthUser } from '@/lib/auth'; +export async function GET() { + const user = await getAuthUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const [dbUser] = await db + .select({ + id: users.id, + email: users.email, + name: users.name, + provider: users.provider, + }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (!dbUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json(dbUser, { status: 200 }); +} + export async function PUT(request: NextRequest) { // Get authenticated user const user = await getAuthUser(); diff --git a/src/app/app/settings/page.tsx b/src/app/app/settings/page.tsx index 61590d5..efe0f5d 100644 --- a/src/app/app/settings/page.tsx +++ b/src/app/app/settings/page.tsx @@ -10,10 +10,23 @@ import { CheckCircle, AlertCircle } from "lucide-react"; export default function SettingsPage() { const { data: session, update } = useSession(); + + // Profile state const [displayName, setDisplayName] = useState(""); const [isSaving, setIsSaving] = useState(false); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); + const [profileSuccess, setProfileSuccess] = useState(null); + const [profileError, setProfileError] = useState(null); + + // User provider state (fetched from API) + const [userProvider, setUserProvider] = useState(null); + + // Security / change-password state + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isChangingPassword, setIsChangingPassword] = useState(false); + const [passwordSuccess, setPasswordSuccess] = useState(null); + const [passwordError, setPasswordError] = useState(null); // Pre-fill display name from session useEffect(() => { @@ -22,10 +35,25 @@ export default function SettingsPage() { } }, [session?.user?.name]); + // Fetch provider info to determine credentials vs OAuth + useEffect(() => { + if (!session?.user) return; + fetch("/api/auth/profile") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.provider) { + setUserProvider(data.provider); + } + }) + .catch(() => { + // silently ignore; Security section will not render + }); + }, [session?.user]); + async function handleSaveProfile(e: React.FormEvent) { e.preventDefault(); - setSuccessMessage(null); - setErrorMessage(null); + setProfileSuccess(null); + setProfileError(null); setIsSaving(true); try { @@ -37,21 +65,64 @@ export default function SettingsPage() { if (!response.ok) { const data = await response.json(); - setErrorMessage(data.error ?? "Failed to save profile."); + setProfileError(data.error ?? "Failed to save profile."); return; } // Refresh session so the new name propagates to the nav bar await update(); - setSuccessMessage("Profile updated successfully."); + setProfileSuccess("Profile updated successfully."); } catch { - setErrorMessage("An unexpected error occurred. Please try again."); + setProfileError("An unexpected error occurred. Please try again."); } finally { setIsSaving(false); } } + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setPasswordSuccess(null); + setPasswordError(null); + + // Client-side validation + if (newPassword.length < 8) { + setPasswordError("New password must be at least 8 characters."); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError("Passwords do not match."); + return; + } + + setIsChangingPassword(true); + try { + const response = await fetch("/api/auth/password", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + const data = await response.json(); + + if (!response.ok) { + setPasswordError(data.error ?? "Failed to change password."); + return; + } + + setPasswordSuccess("Password changed successfully."); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setPasswordError("An unexpected error occurred. Please try again."); + } finally { + setIsChangingPassword(false); + } + } + const email = session?.user?.email ?? ""; + const isCredentialsUser = userProvider === "credentials"; + const isOAuthUser = userProvider !== null && userProvider !== "credentials"; return (
@@ -68,16 +139,16 @@ export default function SettingsPage() {
{/* Feedback messages */} - {successMessage && ( + {profileSuccess && (
-

{successMessage}

+

{profileSuccess}

)} - {errorMessage && ( + {profileError && (
-

{errorMessage}

+

{profileError}

)} @@ -126,6 +197,127 @@ export default function SettingsPage() {
+ + {/* Security section */} + {userProvider !== null && ( + + + Security + + {isCredentialsUser + ? "Change your account password." + : "Manage your sign-in method."} + + + + {isOAuthUser ? ( + /* OAuth user — no password to change */ +
+ {/* Google "G" icon (inline SVG) */} + +

+ Signed in via Google — password cannot be changed. +

+
+ ) : ( + /* Credentials user — change password form */ +
+ {/* Feedback messages */} + {passwordSuccess && ( +
+ +

{passwordSuccess}

+
+ )} + {passwordError && ( +
+ +

{passwordError}

+
+ )} + + {/* Current password */} +
+ + setCurrentPassword(e.target.value)} + className="font-mono text-sm" + required + /> +
+ + {/* New password */} +
+ + setNewPassword(e.target.value)} + className="font-mono text-sm" + required + minLength={8} + /> +
+ + {/* Confirm new password */} +
+ + setConfirmPassword(e.target.value)} + className="font-mono text-sm" + required + /> +
+ + +
+ )} +
+
+ )}
); }