feat(settings): add Security section with change password form (task 12.2)

- 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 <noreply@anthropic.com>
This commit is contained in:
Marko Djordjevic 2026-02-20 13:35:19 +01:00
parent 9514a987e3
commit 64b3bfd0d4
3 changed files with 228 additions and 12 deletions

View file

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

View file

@ -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();

View file

@ -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<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [profileSuccess, setProfileSuccess] = useState<string | null>(null);
const [profileError, setProfileError] = useState<string | null>(null);
// User provider state (fetched from API)
const [userProvider, setUserProvider] = useState<string | null>(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<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(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 (
<div className="max-w-xl mx-auto py-10 px-4 space-y-6">
@ -68,16 +139,16 @@ export default function SettingsPage() {
<CardContent>
<form onSubmit={handleSaveProfile} className="space-y-4">
{/* Feedback messages */}
{successMessage && (
{profileSuccess && (
<div className="flex items-center gap-2 rounded-md bg-green-500/10 border border-green-500/20 p-3">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
<p className="text-sm text-green-600 dark:text-green-400 font-mono">{successMessage}</p>
<p className="text-sm text-green-600 dark:text-green-400 font-mono">{profileSuccess}</p>
</div>
)}
{errorMessage && (
{profileError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 border border-destructive/20 p-3">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<p className="text-sm text-destructive font-mono">{errorMessage}</p>
<p className="text-sm text-destructive font-mono">{profileError}</p>
</div>
)}
@ -126,6 +197,127 @@ export default function SettingsPage() {
</form>
</CardContent>
</Card>
{/* Security section */}
{userProvider !== null && (
<Card>
<CardHeader>
<CardTitle className="text-base font-mono">Security</CardTitle>
<CardDescription className="text-xs">
{isCredentialsUser
? "Change your account password."
: "Manage your sign-in method."}
</CardDescription>
</CardHeader>
<CardContent>
{isOAuthUser ? (
/* OAuth user — no password to change */
<div className="flex items-center gap-3 rounded-md bg-muted/50 border p-4">
{/* Google "G" icon (inline SVG) */}
<svg
className="h-5 w-5 shrink-0"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
<p className="text-sm font-mono text-muted-foreground">
Signed in via Google password cannot be changed.
</p>
</div>
) : (
/* Credentials user — change password form */
<form onSubmit={handleChangePassword} className="space-y-4">
{/* Feedback messages */}
{passwordSuccess && (
<div className="flex items-center gap-2 rounded-md bg-green-500/10 border border-green-500/20 p-3">
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
<p className="text-sm text-green-600 dark:text-green-400 font-mono">{passwordSuccess}</p>
</div>
)}
{passwordError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 border border-destructive/20 p-3">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<p className="text-sm text-destructive font-mono">{passwordError}</p>
</div>
)}
{/* Current password */}
<div className="space-y-2">
<Label htmlFor="currentPassword" className="text-xs font-mono">
Current Password
</Label>
<Input
id="currentPassword"
type="password"
placeholder="Enter current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="font-mono text-sm"
required
/>
</div>
{/* New password */}
<div className="space-y-2">
<Label htmlFor="newPassword" className="text-xs font-mono">
New Password
</Label>
<Input
id="newPassword"
type="password"
placeholder="At least 8 characters"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="font-mono text-sm"
required
minLength={8}
/>
</div>
{/* Confirm new password */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-xs font-mono">
Confirm New Password
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Repeat new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="font-mono text-sm"
required
/>
</div>
<Button
type="submit"
disabled={isChangingPassword}
className="font-mono text-sm"
>
{isChangingPassword ? "Updating…" : "Update Password"}
</Button>
</form>
)}
</CardContent>
</Card>
)}
</div>
);
}