feat(frontend): move settings profile + password off Supabase to V2 Identity
Adds two authenticated gateway proxies and rewires the settings UI to them: - POST /api/auth/password → Identity /v1/auth/password/change (server-side re-validates current password; drops the client-side re-auth round-trip) - PATCH /api/profile → Identity PATCH /v1/users/me (full_name) SettingsProfile and SettingsSecurity now fetch these routes instead of the Supabase browser client. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { gatewayFetch } from "@/lib/api/gateway";
|
||||
import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password via Identity (`/v1/auth/password/change`).
|
||||
* Identity re-validates the current password server-side, so no client-side
|
||||
* re-authentication round-trip is needed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password;
|
||||
const newPassword = body?.new_password;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current and new password are required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (typeof newPassword !== "string" || newPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "New password must be at least 8 characters." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const res = await gatewayFetch("/v1/auth/password/change", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
const message =
|
||||
res.status === 400 || res.status === 401
|
||||
? (data?.message ?? "Current password is incorrect.")
|
||||
: (data?.message ?? "Could not change password.");
|
||||
return NextResponse.json({ error: message }, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { gatewayFetch } from "@/lib/api/gateway";
|
||||
import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Update the signed-in user's profile via Identity (`PATCH /v1/users/me`).
|
||||
* Currently surfaces the display name (full_name); the Identity DTO accepts more
|
||||
* fields that can be added here as the settings UI grows.
|
||||
*/
|
||||
export async function PATCH(req: Request) {
|
||||
const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const fullName = typeof body?.full_name === "string" ? body.full_name.trim() : undefined;
|
||||
if (fullName === undefined) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
const res = await gatewayFetch("/v1/users/me", {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ full_name: fullName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ error: data?.message ?? "Could not update profile." },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await res.json().catch(() => null);
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
interface SettingsProfileProps {
|
||||
email: string;
|
||||
@@ -20,13 +19,22 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase.auth.updateUser({ data: { full_name: name.trim() } });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Profile updated successfully." });
|
||||
try {
|
||||
const res = await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ full_name: name.trim() }),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
setMessage({ type: "error", text: data?.error ?? "Could not update profile." });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Profile updated successfully." });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error. Please try again." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -26,32 +25,25 @@ export function SettingsSecurity() {
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const supabase = createClient();
|
||||
|
||||
// Re-authenticate with current password first
|
||||
const { data: session } = await supabase.auth.getSession();
|
||||
const email = session.session?.user?.email;
|
||||
if (!email) {
|
||||
setMessage({ type: "error", text: "Session expired. Please sign in again." });
|
||||
try {
|
||||
// Identity re-validates the current password server-side, so no separate
|
||||
// client-side re-authentication round-trip is needed.
|
||||
const res = await fetch("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current_password: current, new_password: next }),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
setMessage({ type: "error", text: data?.error ?? "Could not change password." });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Password changed successfully." });
|
||||
setCurrent(""); setNext(""); setConfirm("");
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error. Please try again." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password: current });
|
||||
if (signInError) {
|
||||
setSaving(false);
|
||||
setMessage({ type: "error", text: "Current password is incorrect." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.updateUser({ password: next });
|
||||
setSaving(false);
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Password changed successfully." });
|
||||
setCurrent(""); setNext(""); setConfirm("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user