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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions src/app/PageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -117,6 +118,8 @@ export default function PageClient({ projects, technologies, settings: settingsI
</motion.div>
)}
</AnimatePresence>

<VisitorDetailsPrompt enabled={showApp} />
</main>
)
}
44 changes: 31 additions & 13 deletions src/app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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<HTMLFormElement>) => {
event.preventDefault()
setErrorMsg('')
setSuccessMsg('')
if (!email || !password) { setErrorMsg('Please enter both email and password.'); return }
Expand All @@ -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
Expand Down Expand Up @@ -126,6 +142,7 @@ export default function LoginPage() {
</motion.div>
)}

<form onSubmit={handleLogin}>
{/* EMAIL */}
<motion.div initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }} className="mb-4">
<label className="text-xs text-white/50 mb-2 block font-medium tracking-wide">Email</label>
Expand All @@ -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"
/>
</div>
Expand All @@ -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"
/>
<button type="button" onClick={() => setShowPassword(!showPassword)}
Expand All @@ -164,14 +181,15 @@ export default function LoginPage() {

{/* LOGIN BUTTON */}
<motion.button
type="submit"
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}
onClick={handleLogin}
disabled={loading}
className="w-full h-[52px] rounded-2xl bg-white text-black font-semibold text-sm hover:opacity-95 transition flex items-center justify-center gap-2 disabled:opacity-60 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
>
{loading ? <><Loader2 size={17} className="animate-spin" /> Signing In...</> : 'Sign In'}
</motion.button>
</form>

{/* DIVIDER */}
<div className="flex items-center gap-3 my-5">
Expand Down
39 changes: 32 additions & 7 deletions src/app/admin/webhook/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,19 @@ const DEFAULTS: Settings = {
custom_title: '👨🏻‍💻 New Visitor',
}

// Live sessions from /api/visitors
interface Session { id: string; page: string; since: number; country: string; city: string }
// Live sessions from the admin-protected /api/visitors endpoint
interface Session {
id: string
page: string
since: number
country: string
city: string
name: string | null
phone: string | null
latitude: number | null
longitude: number | null
accuracy: number | null
}

export default function WebhookPage() {
const router = useRouter()
Expand Down Expand Up @@ -268,8 +279,22 @@ CREATE POLICY "admin_rw" ON public.webhook_settings
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center text-xs font-mono text-white/50">{sv.id}</div>
<div>
<p className="text-sm font-medium">{sv.city && sv.city !== '—' ? `${sv.city}, ${sv.country}` : sv.country || 'Unknown'}</p>
<p className="text-xs text-white/30">{sv.page}</p>
<p className="text-sm font-medium">{sv.name || 'Anonymous visitor'}</p>
<p className="text-xs text-white/40">
{sv.city && sv.city !== '—' ? `${sv.city}, ${sv.country}` : sv.country || 'Unknown location'}
</p>
<p className="text-xs text-white/30">{sv.phone ? `${sv.phone} · ` : ''}{sv.page}</p>
{sv.latitude !== null && sv.longitude !== null && (
<a
href={`https://www.google.com/maps?q=${sv.latitude},${sv.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
>
<MapPin size={11} /> Precise map
{sv.accuracy !== null ? ` (±${Math.round(sv.accuracy)}m)` : ''}
</a>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-white/25">
Expand Down Expand Up @@ -339,9 +364,9 @@ CREATE POLICY "admin_rw" ON public.webhook_settings
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Globe size={15} className="text-white/40" /> Visitor Details in Embed
</h2>
<Toggle label="Visitor Name" desc="Shown if they've commented before" field="show_visitor_name" icon={Users} />
<Toggle label="Location" desc="City, Region, Country from IP" field="show_location" icon={MapPin} />
<Toggle label="Map Link" desc="Clickable Google Maps link" field="show_map_link" icon={MapPin} />
<Toggle label="Visitor Name" desc="Shown after the visitor shares it" field="show_visitor_name" icon={Users} />
<Toggle label="Location" desc="IP estimate plus consented precise coordinates" field="show_location" icon={MapPin} />
<Toggle label="Map Link" desc="Precise only with permission; otherwise approximate" field="show_map_link" icon={MapPin} />
<Toggle label="ISP / Organization" desc="Their internet provider" field="show_isp" icon={Globe} />
<Toggle label="Device Type" desc="Desktop / Mobile / Tablet" field="show_device" icon={Monitor} />
<Toggle label="Browser & OS" desc="Chrome on Windows, etc" field="show_browser" icon={Chrome} />
Expand Down
Loading
Loading