diff --git a/.env.example b/.env.example index 10ce1a9..a3360c8 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= # Server-only integrations. Never prefix these with NEXT_PUBLIC_. -ADMIN_EMAIL=admin@example.com +ADMIN_EMAIL=dev.sxhd@gmail.com +ADMIN_USER_ID= GITHUB_TOKEN= DISCORD_WEBHOOK_URL= diff --git a/README.md b/README.md index 5fcc423..814972c 100644 --- a/README.md +++ b/README.md @@ -220,11 +220,14 @@ NEXT_PUBLIC_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY ADMIN_EMAIL +ADMIN_USER_ID GITHUB_TOKEN DISCORD_WEBHOOK_URL ``` -Keep the service-role key, GitHub token, and Discord webhook server-only. Copy +`ADMIN_EMAIL` accepts a comma-separated allowlist, and `ADMIN_USER_ID` can pin +access to one Supabase Auth user ID. Keep the service-role key, admin identity, +GitHub token, and Discord webhook server-only. Copy `.env.example` to `.env.local` for local development and set the same values in Vercel for production. diff --git a/src/app/PageClient.tsx b/src/app/PageClient.tsx index 16c1b67..205c5c2 100644 --- a/src/app/PageClient.tsx +++ b/src/app/PageClient.tsx @@ -10,6 +10,7 @@ import About from '@/components/sections/About' import PortfolioShowcase from '@/components/sections/PortfolioShowcase' import ContactSection from '@/components/sections/contact/ContactSection' import IntroScreen from '@/components/IntroScreen' +import VisitorDetailsPrompt from '@/components/VisitorDetailsPrompt' import { useVisitor } from '@/hooks/useVisitor' import { mergeSiteSettings, SiteSettings } from '@/lib/siteSettings' @@ -117,6 +118,8 @@ export default function PageClient({ projects, technologies, settings: settingsI )} + + ) } diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index 2880ad8..9ca7c73 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -1,17 +1,13 @@ 'use client' export const dynamic = 'force-dynamic'; -import { useState, useEffect } from 'react' -import { createClient } from '@/utils/supabase/client' -import { useRouter } from 'next/navigation' +import { FormEvent, useState, useEffect } from 'react' +import { supabase } from '@/lib/supabase' import { Lock, Mail, Eye, EyeOff, Loader2 } from 'lucide-react' import { FaGithub, FaGoogle } from 'react-icons/fa' import { motion } from 'framer-motion' export default function LoginPage() { - const router = useRouter() - const [supabase] = useState(() => typeof window !== 'undefined' ? createClient() : null as any) - const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) @@ -20,9 +16,24 @@ export default function LoginPage() { const [successMsg, setSuccessMsg] = useState('') const [mounted, setMounted] = useState(false) - useEffect(() => setMounted(true), []) + useEffect(() => { + let active = true + setMounted(true) + + const redirectExistingAdmin = async () => { + const { data: { user } } = await supabase.auth.getUser() + const isKnownAdmin = + user?.app_metadata?.role === 'admin' || + user?.email?.toLowerCase() === 'dev.sxhd@gmail.com' + if (active && isKnownAdmin) window.location.replace('/admin/dashboard') + } - const handleLogin = async () => { + void redirectExistingAdmin() + return () => { active = false } + }, []) + + const handleLogin = async (event: FormEvent) => { + event.preventDefault() setErrorMsg('') setSuccessMsg('') if (!email || !password) { setErrorMsg('Please enter both email and password.'); return } @@ -33,12 +44,17 @@ export default function LoginPage() { setErrorMsg('Invalid credentials. Please try again.') } else { setSuccessMsg('Login successful! Redirecting...') - setTimeout(() => router.push('/admin/dashboard'), 800) + window.location.replace('/admin/dashboard') } } const handleOAuth = async (provider: 'google' | 'github') => { - await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/auth/callback` } }) + setErrorMsg('') + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: `${window.location.origin}/auth/callback?next=/admin/dashboard` }, + }) + if (error) setErrorMsg(`Unable to start ${provider} login. Please try again.`) } // Floating particles for background @@ -126,6 +142,7 @@ export default function LoginPage() { )} +
{/* EMAIL */} @@ -136,7 +153,7 @@ export default function LoginPage() { placeholder="admin@example.com" value={email} onChange={(e) => setEmail(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleLogin()} + autoComplete="email" className="w-full h-[52px] rounded-2xl bg-white/[0.05] border border-white/10 pl-11 pr-4 text-white text-sm outline-none focus:border-white/30 focus:bg-white/[0.07] transition placeholder:text-white/20" /> @@ -152,7 +169,7 @@ export default function LoginPage() { placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleLogin()} + autoComplete="current-password" className="w-full h-[52px] rounded-2xl bg-white/[0.05] border border-white/10 pl-11 pr-12 text-white text-sm outline-none focus:border-white/30 focus:bg-white/[0.07] transition placeholder:text-white/20" /> + +
+
+ Optional visitor details +
+

Say hello

+

+ Share your name with the portfolio owner. Phone and precise location are optional. +

+
+ +
+ + + + + + + {error &&

{error}

} + +
+ + +
+
+ +

+ Details are sent privately through the portfolio notification webhook and are never requested without your action. +

+ + )} + + ) +} diff --git a/src/hooks/useVisitor.ts b/src/hooks/useVisitor.ts index 33fa801..51df389 100644 --- a/src/hooks/useVisitor.ts +++ b/src/hooks/useVisitor.ts @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { VISITOR_PROFILE_EVENT, type VisitorProfile } from '@/lib/visitorProfile' function getSessionId() { let id = sessionStorage.getItem('_sid') @@ -13,7 +14,6 @@ function getSessionId() { export function useVisitor() { const [liveViewers, setLiveViewers] = useState(1) - const sessionId = useRef(null) const fired = useRef(false) useEffect(() => { @@ -21,30 +21,50 @@ export function useVisitor() { fired.current = true const sid = getSessionId() - sessionId.current = sid + let visitSent = sessionStorage.getItem('_visited') === '1' - const alreadyNotified = sessionStorage.getItem('_visited') - if (!alreadyNotified) { + const visitorContext = () => ({ + sessionId: sid, + page: window.location.pathname + window.location.hash, + referrer: document.referrer || 'Direct', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + language: navigator.language || navigator.languages?.[0] || '', + screenWidth: window.screen.width, + screenHeight: window.screen.height, + }) + + const notifyVisit = (profile: VisitorProfile | null) => { + if (visitSent) return + visitSent = true sessionStorage.setItem('_visited', '1') - fetch('/api/visitors', { + void fetch('/api/visitors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'visit', ...visitorContext(), ...(profile || {}) }), + }).catch(() => {}) + } + + const identifyVisitor = (profile: VisitorProfile) => { + void fetch('/api/visitors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'visit', - sessionId: sid, - page: window.location.pathname + window.location.hash, - referrer: document.referrer || 'Direct', - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - language: navigator.language || navigator.languages?.[0] || '', - screenWidth: window.screen.width, - screenHeight: window.screen.height, - // visitorName filled in only if they've left a comment before - visitorName: localStorage.getItem('_visitor_name') || null, - }), + body: JSON.stringify({ type: 'identify', ...visitorContext(), ...profile }), }).catch(() => {}) } + const handleProfile = (event: Event) => { + const profile = (event as CustomEvent).detail + if (!visitSent) notifyVisit(null) + if (profile) identifyVisitor(profile) + } + + window.addEventListener(VISITOR_PROFILE_EVENT, handleProfile) + + const anonymousTimer = visitSent + ? null + : window.setTimeout(() => notifyVisit(null), 15_000) + // Heartbeat every 28s const heartbeat = async () => { try { @@ -69,7 +89,12 @@ export function useVisitor() { navigator.sendBeacon('/api/visitors', JSON.stringify({ type: 'leave', sessionId: sid })) window.addEventListener('beforeunload', leave) - return () => { clearInterval(iv); window.removeEventListener('beforeunload', leave) } + return () => { + clearInterval(iv) + if (anonymousTimer) window.clearTimeout(anonymousTimer) + window.removeEventListener(VISITOR_PROFILE_EVENT, handleProfile) + window.removeEventListener('beforeunload', leave) + } }, []) return { liveViewers } diff --git a/src/lib/adminAccess.ts b/src/lib/adminAccess.ts new file mode 100644 index 0000000..198bb3f --- /dev/null +++ b/src/lib/adminAccess.ts @@ -0,0 +1,25 @@ +type AdminIdentity = { + id?: string + email?: string | null + app_metadata?: Record | null +} + +const OWNER_EMAIL = 'dev.sxhd@gmail.com' + +export function isAdminUser(user?: AdminIdentity | null) { + if (!user) return false + + const configuredEmails = (process.env.ADMIN_EMAIL || '') + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean) + + const allowedEmails = new Set([OWNER_EMAIL, ...configuredEmails]) + const configuredUserId = process.env.ADMIN_USER_ID?.trim() + + return ( + user.app_metadata?.role === 'admin' || + Boolean(user.email && allowedEmails.has(user.email.toLowerCase())) || + Boolean(configuredUserId && user.id === configuredUserId) + ) +} diff --git a/src/lib/visitorProfile.ts b/src/lib/visitorProfile.ts new file mode 100644 index 0000000..7891fd9 --- /dev/null +++ b/src/lib/visitorProfile.ts @@ -0,0 +1,10 @@ +export const VISITOR_PROFILE_EVENT = 'portfolio:visitor-profile' + +export type VisitorProfile = { + visitorName: string + phone: string | null + locationConsent: boolean + latitude: number | null + longitude: number | null + locationAccuracy: number | null +} diff --git a/src/middleware.ts b/src/middleware.ts index db345df..81ad343 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -13,5 +13,6 @@ export const config = { '/api/import-github-projects', '/api/seed-certificates', '/api/setup-db', + '/api/visitors', ], } diff --git a/src/utils/supabase/middleware.ts b/src/utils/supabase/middleware.ts index ca42624..6c75412 100644 --- a/src/utils/supabase/middleware.ts +++ b/src/utils/supabase/middleware.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' +import { isAdminUser } from '@/lib/adminAccess' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ @@ -8,16 +9,21 @@ export async function updateSession(request: NextRequest) { const url = process.env.NEXT_PUBLIC_SUPABASE_URL const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + const isAdminLoginRoute = request.nextUrl.pathname === '/admin/login' const isProtectedAdminRoute = request.nextUrl.pathname.startsWith('/admin') && - request.nextUrl.pathname !== '/admin/login' + !isAdminLoginRoute const protectedAdminApiRoutes = new Set([ '/api/add-sample-certificates', '/api/import-github-projects', '/api/seed-certificates', '/api/setup-db', ]) - const isProtectedAdminApiRoute = protectedAdminApiRoutes.has(request.nextUrl.pathname) + const isProtectedAdminApiRoute = + protectedAdminApiRoutes.has(request.nextUrl.pathname) || + (request.nextUrl.pathname === '/api/visitors' && request.method === 'GET') + const isPublicVisitorRequest = + request.nextUrl.pathname === '/api/visitors' && request.method !== 'GET' const redirectToLogin = () => { const loginUrl = request.nextUrl.clone() @@ -28,6 +34,15 @@ export async function updateSession(request: NextRequest) { return redirect } + const redirectToDashboard = () => { + const dashboardUrl = request.nextUrl.clone() + dashboardUrl.pathname = '/admin/dashboard' + dashboardUrl.search = '' + const redirect = NextResponse.redirect(dashboardUrl) + supabaseResponse.cookies.getAll().forEach((cookie) => redirect.cookies.set(cookie)) + return redirect + } + const denyApiRequest = (status: 401 | 403) => NextResponse.json( { error: status === 401 ? 'Authentication required' : 'Admin access required' }, { status }, @@ -38,6 +53,8 @@ export async function updateSession(request: NextRequest) { return isProtectedAdminRoute ? redirectToLogin() : supabaseResponse } + if (isPublicVisitorRequest) return supabaseResponse + const supabase = createServerClient( url, key, @@ -59,13 +76,7 @@ export async function updateSession(request: NextRequest) { // Verify the identity with Supabase Auth instead of trusting cookie data. const { data: { user } } = await supabase.auth.getUser() - const configuredAdminEmail = (process.env.ADMIN_EMAIL || 'dev.sxhd@gmail.com').toLowerCase() - const isAdmin = Boolean( - user && ( - user.app_metadata?.role === 'admin' || - user.email?.toLowerCase() === configuredAdminEmail - ) - ) + const isAdmin = isAdminUser(user) if (isProtectedAdminApiRoute && !user) return denyApiRequest(401) if (isProtectedAdminApiRoute && !isAdmin) return denyApiRequest(403) @@ -74,5 +85,9 @@ export async function updateSession(request: NextRequest) { return redirectToLogin() } + if (isAdminLoginRoute && isAdmin) { + return redirectToDashboard() + } + return supabaseResponse } diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts index 7595477..1b3f0b4 100644 --- a/src/utils/supabase/server.ts +++ b/src/utils/supabase/server.ts @@ -31,6 +31,10 @@ const createNoopServerClient = () => { data: null, error: new Error('Missing Supabase environment variables'), }), + getUser: async () => ({ + data: { user: null }, + error: new Error('Missing Supabase environment variables'), + }), }, } as unknown as ReturnType }