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
36 changes: 4 additions & 32 deletions actions/admin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use server';

import { createServerClient } from '@supabase/ssr';
import { createClient } from '@supabase/supabase-js';
import { cookies } from 'next/headers';
import { requireAdmin } from '@/lib/auth';

async function getAdminSupabase() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
Expand All @@ -14,42 +13,15 @@ async function getAdminSupabase() {
}

async function assertAdmin() {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: { get: (name) => cookieStore.get(name)?.value },
}
);

const {
data: { session },
} = await supabase.auth.getSession();

if (!session) throw new Error('Unauthorized');

const { data: profile } = await supabase
.from('users')
.select('user_role')
.eq('id', session.user.id)
.maybeSingle();

const role = profile?.user_role;
const isAdmin =
role === 'admin' ||
role === 'superadmin' ||
session.user.email === 'deepaksharma834@gmail.com';

if (!isAdmin) throw new Error('Forbidden');
await requireAdmin();
}

// Table mapping based on experience type
const TABLE_MAP: Record<string, string> = {
const TABLE_MAP = {
user: 'new_interview',
scraped: 'scraped_experiences',
legacy: 'experiences',
};
} as const satisfies Record<string, string>;

export async function deleteExperience(
rawId: string,
Expand Down
35 changes: 3 additions & 32 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import AdminDashboard from '@/components/admin-dashboard';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { getAuthState } from '@/lib/auth';

export const metadata: Metadata = {
title: 'Admin Panel | Frontend Junction',
Expand All @@ -11,37 +10,9 @@ export const metadata: Metadata = {
};

export default async function AdminPage() {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
},
}
);
const { isAdmin } = await getAuthState();

const {
data: { session },
} = await supabase.auth.getSession();

if (!session) {
redirect('/');
}

const { data: userProfile } = await supabase
.from('users')
.select('user_role')
.eq('id', session.user.id)
.maybeSingle();

if (
userProfile?.user_role !== 'admin' &&
userProfile?.user_role !== 'superadmin'
) {
if (!isAdmin) {
redirect('/');
}

Expand Down
34 changes: 6 additions & 28 deletions app/api/admin/stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,10 @@
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { requireAdmin, AuthError } from '@/lib/auth';

export async function GET() {
try {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
},
}
);

// 1. Check if user is logged in
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// 2. Check for Admin role (Hardcoded for current MVP, same as session-provider)
const isAdmin = session.user.email === 'deepaksharma834@gmail.com';
if (!isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Consistent admin gate (dual rule, verified user)
await requireAdmin();

const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
Expand Down Expand Up @@ -102,6 +77,9 @@ export async function GET() {
successRate,
});
} catch (error: any) {
if (error instanceof AuthError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[AdminStatsAPI] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
Expand Down
14 changes: 14 additions & 0 deletions app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getAuthState } from '@/lib/auth';

// Never cache — this reflects per-request session state.
export const dynamic = 'force-dynamic';
export const revalidate = 0;

export async function GET() {
const { isAdmin, role } = await getAuthState();
return NextResponse.json(
{ isAdmin, role: role ?? null },
{ headers: { 'Cache-Control': 'no-store, max-age=0' } }
);
}
7 changes: 5 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const metadata: Metadata = {
publisher: 'Frontend Junction',
alternates: {
canonical: '/',
types: {
'application/rss+xml': '/feed.xml',
},
},
formatDetection: {
email: false,
Expand Down Expand Up @@ -122,14 +125,14 @@ export default function RootLayout({
</head>
{process.env.NEXT_GOOGLE_ANALYTICS && (
<Script
strategy='afterInteractive'
strategy='lazyOnload'
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_GOOGLE_ANALYTICS}`}
/>
)}
{process.env.NEXT_GOOGLE_ANALYTICS && (
<Script
id='google-analytics'
strategy='afterInteractive'
strategy='lazyOnload'
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
Expand Down
15 changes: 2 additions & 13 deletions components/common/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,13 @@ import { usePathname } from 'next/navigation';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { getSupabaseBrowserClient } from '@/lib/supabase-browser';
import { useAuth } from '../session-provider';
import { useRouter } from 'next/navigation';

export default function Profile() {
const router = useRouter();
const path = usePathname();
const supabase = getSupabaseBrowserClient();
const { user = null, setUser } = useAuth();
const { user = null } = useAuth();

const handleLogout = async () => {
await supabase.auth.signOut();
setUser(null);
router.push('/');
};

const isAdmin = user?.role === 'admin';
const isSub = user?.stripe_customer_id;
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';

return (
<Popover>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
115 changes: 115 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'server-only';
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
import type { SupabaseClient, User } from '@supabase/supabase-js';

const ADMIN_ROLES = ['admin', 'superadmin'] as const;

export interface AuthState {
user: User | null;
role: string | null;
isAdmin: boolean;
}

export class AuthError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'AuthError';
this.status = status;
}
}

/**
* The single server-side Supabase client factory.
* Full cookie read/write so session refresh works in route handlers
* and server actions. (In Server Components, writes are no-ops — that's fine,
* middleware refreshes the session.)
*/
export async function getServerSupabase(): Promise<SupabaseClient> {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch {
/* called from a Server Component — middleware handles refresh */
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch {
/* called from a Server Component */
}
},
},
}
);
}

/**
* Returns the verified user (validated against the Supabase Auth server via
* getUser()). Use this for ALL authorization decisions — never getSession(),
* which only reads the (potentially forged) cookie without verifying the JWT.
*/
export async function getVerifiedUser(
client?: SupabaseClient
): Promise<User | null> {
const supabase = client ?? (await getServerSupabase());
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error) return null;
return user;
Comment on lines +67 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In @supabase/supabase-js v2.108.0, what error does auth.getUser() return when no session is present, and how should it be distinguished from network or auth service failures?

💡 Result:

In @supabase/supabase-js v2.108.0, when no session is present, auth.getUser returns an object where the data.user property is null, and the error property is typically null (or specifically indicates no error occurred, as the absence of a session is a valid state) [1][2]. To distinguish between the absence of a session and actual service failures (such as network issues or auth server errors), you should inspect the returned error object: 1. No Session (Expected State): If data.user is null and error is null, this simply means the client has no valid session. This is the expected behavior when a user is not logged in [1][2]. 2. Auth/Network Failures: If the error property is populated, it will contain an AuthError (often an AuthApiError or similar) [3][4]. These errors, such as a 400 status code with a "session_not_found" or other service-level messages, indicate that the attempt to retrieve the user failed due to an issue with the request, the token, or the connection to the Supabase Auth server [4][5]. For robust error handling, you should prioritize checking if the error object is truthy before assuming that a null user signifies a logged-out state [5]. If an error exists, you can inspect its properties (e.g., status or code) to determine if it is a transient network issue or a persistent service-side failure [4]. Note that auth.getUser performs a network request to the Supabase Auth server to verify the authenticity of the session [6][7]. This distinguishes it from auth.getSession, which primarily reads from local storage and should not be used for critical authorization checks on the server [8][7].

Citations:


Don't collapse getUser() errors into null. auth.getUser() returns error = null for the expected no-session case; unexpected auth/network failures should be rethrown so protected routes fail with a real server error instead of 401.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth.ts` around lines 67 - 72, The `getUser()` handling in `auth.ts` is
collapsing all failures into `null`, which hides unexpected auth or network
errors. Update the logic around `supabase.auth.getUser()` so the expected
no-session case still returns `null`, but any real `error` is rethrown or
surfaced instead of being swallowed; use the `getUser` call and its
`error`/`user` result to distinguish these paths.

}

/**
* Resolves the full auth state with the canonical admin rule:
* admin if app_metadata.role OR users.user_role is in {admin, superadmin}.
* This is the ONE place role resolution lives.
*/
export async function getAuthState(): Promise<AuthState> {
const supabase = await getServerSupabase();
const user = await getVerifiedUser(supabase);

if (!user) {
return { user: null, role: null, isAdmin: false };
}

// 1. Prefer the role embedded in the verified JWT.
let role: string | null = (user.app_metadata as any)?.role ?? null;

// 2. Fall back to the users table when the JWT has no admin role.
if (!role || !ADMIN_ROLES.includes(role as any)) {
const { data: profile } = await supabase
.from('users')
.select('user_role')
.eq('id', user.id)
.maybeSingle();
role = profile?.user_role ?? role;
}

const isAdmin = role != null && ADMIN_ROLES.includes(role as any);
return { user, role, isAdmin };
}

/**
* Guard for API routes and server actions. Returns the AuthState when the
* caller is an admin; otherwise throws AuthError (401 if unauthenticated,
* 403 if authenticated but not admin).
*/
export async function requireAdmin(): Promise<AuthState> {
const state = await getAuthState();
if (!state.user) throw new AuthError('Unauthorized', 401);
if (!state.isAdmin) throw new AuthError('Forbidden', 403);
return state;
}
Loading
Loading