Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions packages/web/app/admin/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,48 @@ export default function AdminLoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

// 세션이 있으면 /admin 으로 이동.
// - SIGNED_IN: OAuth / password 로그인 직후
// - INITIAL_SESSION: 이미 localStorage 에 유효 세션이 있는 상태에서 로그인
// 페이지에 다시 들어온 경우 (proxy.ts 가 쿠키 부재로 리다이렉트시킨 케이스 포함)
// SIGNED_IN 이벤트(OAuth / password 로그인 직후)에만 /admin 으로 이동한다.
// INITIAL_SESSION 은 처리하지 않는다 — 비-admin 세션 보유자가 /admin/login 에
// 진입하면 INITIAL_SESSION → /admin → proxy.ts 가 /admin/login 으로 다시
// 리다이렉트 → INITIAL_SESSION 재발화 의 무한 루프가 생긴다.
// 이미 admin 인증된 사용자가 /admin/login 에 들어오는 케이스는 proxy.ts 가
// /admin 으로 바운스해 처리한다.
// 비-admin 이 password/OAuth 로그인을 시도해 SIGNED_IN 이 발화한 경우에도
// /admin 으로 보내면 proxy.ts 가 /admin/login 으로 되돌려보내며 루프가 생기므로,
// 세션 sync 응답의 isAdmin 플래그로 분기해 비-admin 은 즉시 sign out 하고
// 에러를 표시한다.
useEffect(() => {
const {
data: { subscription },
} = supabaseBrowserClient.auth.onAuthStateChange((event, session) => {
if (
(event === "SIGNED_IN" || event === "INITIAL_SESSION") &&
event === "SIGNED_IN" &&
session?.access_token &&
session?.refresh_token
) {
// Sync session to server-side cookies, then navigate via hard reload
// so proxy.ts sees the refreshed cookies.
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access_token: session.access_token,
refresh_token: session.refresh_token,
}),
}).then(() => {
window.location.href = "/admin";
});
})
.then((res) => res.json())
.then(async (body: { ok?: boolean; isAdmin?: boolean }) => {
if (body?.isAdmin) {
window.location.href = "/admin";
return;
}
await supabaseBrowserClient.auth.signOut();
await fetch("/api/auth/session", { method: "DELETE" });
setError("관리자 권한이 없는 계정입니다.");
setLoading(false);
})
.catch(() => {
setError("로그인 처리 중 오류가 발생했습니다.");
setLoading(false);
});
}
});
return () => subscription.unsubscribe();
Expand Down
12 changes: 10 additions & 2 deletions packages/web/app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { checkIsAdmin } from "@/lib/supabase/admin";

/**
* POST /api/auth/session
* Sets server-side session cookies from client-provided tokens.
* Called by AuthProvider on SIGNED_IN / TOKEN_REFRESHED / INITIAL_SESSION so
* that server route handlers and proxy.ts middleware see the same session as
* the browser localStorage.
*
* Response includes `isAdmin` so the admin login page can decide whether to
* navigate to /admin or block a non-admin sign-in without bouncing through
* proxy.ts back to /admin/login.
*/
export async function POST(req: NextRequest) {
const { access_token, refresh_token } = await req.json();
Expand All @@ -16,7 +21,7 @@ export async function POST(req: NextRequest) {
}

const supabase = await createSupabaseServerClient();
const { error } = await supabase.auth.setSession({
const { data, error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
Expand All @@ -25,7 +30,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: error.message }, { status: 401 });
}

return NextResponse.json({ ok: true });
const userId = data.user?.id;
const isAdmin = userId ? await checkIsAdmin(supabase, userId) : false;

return NextResponse.json({ ok: true, isAdmin });
}

/**
Expand Down
Loading