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:
parent
9514a987e3
commit
64b3bfd0d4
3 changed files with 228 additions and 12 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue