Skip to content

Commit 705d9cf

Browse files
committed
Refine portfolio UX and add access gate
1 parent a20e995 commit 705d9cf

File tree

4 files changed

+928
-610
lines changed

4 files changed

+928
-610
lines changed

app/access/page.tsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
interface AccessPageProps {
2+
searchParams?: {
3+
error?: string
4+
next?: string
5+
}
6+
}
7+
8+
export default function AccessPage({ searchParams }: AccessPageProps) {
9+
const hasError = searchParams?.error === 'invalid'
10+
const nextPath =
11+
searchParams?.next && searchParams.next.startsWith('/') && !searchParams.next.startsWith('//')
12+
? searchParams.next
13+
: '/'
14+
15+
return (
16+
<main className="min-h-screen bg-[#0A0A0A] text-[#F5F5F0]">
17+
<div className="absolute inset-0 opacity-[0.04]">
18+
<div
19+
className="absolute inset-0"
20+
style={{
21+
backgroundImage:
22+
'linear-gradient(#F5F5F0 1px, transparent 1px), linear-gradient(90deg, #F5F5F0 1px, transparent 1px)',
23+
backgroundSize: '72px 72px',
24+
}}
25+
/>
26+
</div>
27+
28+
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1440px] items-center px-4 py-12 md:px-8 md:py-16">
29+
<div className="grid w-full gap-6 xl:grid-cols-12 xl:gap-8">
30+
<section className="flex flex-col justify-between rounded-[32px] border border-[#232326] bg-[#111111]/90 p-8 shadow-[0_32px_120px_rgba(0,0,0,0.35)] backdrop-blur md:p-10 xl:col-span-7">
31+
<div>
32+
<div className="mb-6 inline-flex items-center rounded-full border border-[#3B3020] bg-[#171717] px-3 py-1 text-[11px] font-medium uppercase tracking-[0.24em] text-[#C9A86A]">
33+
Private portfolio access
34+
</div>
35+
36+
<h1 className="font-serif text-4xl leading-[1.02] text-[#F5F5F0] md:text-6xl">
37+
Review the work behind the gate.
38+
</h1>
39+
40+
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-[#A1A1AA]">
41+
This portfolio is shared privately. Enter your access credentials to review live
42+
products, open-source contributions, and engineering case studies.
43+
</p>
44+
</div>
45+
46+
<div className="mt-12 grid gap-4 md:grid-cols-3">
47+
<div className="rounded-2xl border border-[#232326] bg-[#0D0D0D] p-5">
48+
<div className="text-[11px] uppercase tracking-[0.2em] text-[#71717A]">
49+
Included
50+
</div>
51+
<div className="mt-2 text-lg font-medium text-[#F5F5F0]">Flagship products</div>
52+
<p className="mt-2 text-sm leading-relaxed text-[#8F8F96]">
53+
Live product walkthroughs, architecture notes, and inspectable source links.
54+
</p>
55+
</div>
56+
57+
<div className="rounded-2xl border border-[#232326] bg-[#0D0D0D] p-5">
58+
<div className="text-[11px] uppercase tracking-[0.2em] text-[#71717A]">
59+
Included
60+
</div>
61+
<div className="mt-2 text-lg font-medium text-[#F5F5F0]">OSS proof</div>
62+
<p className="mt-2 text-sm leading-relaxed text-[#8F8F96]">
63+
Maintainer-reviewed upstream work and public repositories grouped by depth.
64+
</p>
65+
</div>
66+
67+
<div className="rounded-2xl border border-[#232326] bg-[#0D0D0D] p-5">
68+
<div className="text-[11px] uppercase tracking-[0.2em] text-[#71717A]">
69+
Included
70+
</div>
71+
<div className="mt-2 text-lg font-medium text-[#F5F5F0]">Resume + contact</div>
72+
<p className="mt-2 text-sm leading-relaxed text-[#8F8F96]">
73+
Current resume, contact paths, and the full engineering narrative.
74+
</p>
75+
</div>
76+
</div>
77+
</section>
78+
79+
<section className="rounded-[32px] border border-[#232326] bg-[#111111]/95 p-8 shadow-[0_32px_120px_rgba(0,0,0,0.4)] backdrop-blur md:p-10 xl:col-span-5">
80+
<div className="mb-8">
81+
<div className="text-xs font-medium uppercase tracking-[0.24em] text-[#C9A86A]">
82+
Sign in
83+
</div>
84+
<h2 className="mt-4 font-serif text-4xl leading-tight text-[#F5F5F0]">
85+
Enter credentials to continue
86+
</h2>
87+
<p className="mt-3 text-sm leading-relaxed text-[#8F8F96]">
88+
Access is limited to approved reviewers. The session stays active on this browser
89+
after a successful sign-in.
90+
</p>
91+
</div>
92+
93+
<form action="/api/access" method="post" className="space-y-5">
94+
<input type="hidden" name="next" value={nextPath} />
95+
96+
<label className="block">
97+
<span className="mb-2 block text-sm font-medium text-[#F5F5F0]">Username</span>
98+
<input
99+
type="text"
100+
name="username"
101+
autoComplete="username"
102+
required
103+
className="w-full rounded-2xl border border-[#2B2B31] bg-[#0D0D0D] px-4 py-3 text-base text-[#F5F5F0] outline-none transition-all placeholder:text-[#5C5C63] focus:border-[#C9A86A] focus:ring-2 focus:ring-[#C9A86A]/20"
104+
placeholder="Enter username"
105+
/>
106+
</label>
107+
108+
<label className="block">
109+
<span className="mb-2 block text-sm font-medium text-[#F5F5F0]">Password</span>
110+
<input
111+
type="password"
112+
name="password"
113+
autoComplete="current-password"
114+
required
115+
aria-invalid={hasError}
116+
className="w-full rounded-2xl border border-[#2B2B31] bg-[#0D0D0D] px-4 py-3 text-base text-[#F5F5F0] outline-none transition-all placeholder:text-[#5C5C63] focus:border-[#C9A86A] focus:ring-2 focus:ring-[#C9A86A]/20 aria-[invalid=true]:border-[#A84B4B] aria-[invalid=true]:focus:ring-[#A84B4B]/20"
117+
placeholder="Enter password"
118+
/>
119+
</label>
120+
121+
{hasError ? (
122+
<div className="rounded-2xl border border-[#5A2E2E] bg-[#281717] px-4 py-3 text-sm text-[#F2C1C1]">
123+
The username or password was incorrect. Try again with the shared access
124+
credentials.
125+
</div>
126+
) : null}
127+
128+
<button
129+
type="submit"
130+
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-[#C9A86A] px-5 py-3 font-medium text-[#0A0A0A] transition-all duration-300 hover:bg-[#D4B57A] hover:shadow-[0_0_24px_rgba(201,168,106,0.25)] focus:outline-none focus:ring-2 focus:ring-[#C9A86A] focus:ring-offset-2 focus:ring-offset-[#111111]"
131+
>
132+
Open portfolio
133+
</button>
134+
</form>
135+
136+
<div className="mt-8 border-t border-[#232326] pt-6">
137+
<div className="text-[11px] uppercase tracking-[0.24em] text-[#71717A]">
138+
Access note
139+
</div>
140+
<p className="mt-3 text-sm leading-relaxed text-[#8F8F96]">
141+
If you need credentials, request them directly from Felmon. Portfolio content,
142+
resume assets, and project links remain protected until access is granted.
143+
</p>
144+
</div>
145+
</section>
146+
</div>
147+
</div>
148+
</main>
149+
)
150+
}

app/api/access/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
4+
const ACCESS_COOKIE_NAME = 'portfolio_access'
5+
const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
6+
7+
function normalizeCredential(value: string | undefined) {
8+
return value?.trim() ?? ''
9+
}
10+
11+
function getSafeRedirectPath(value: FormDataEntryValue | null) {
12+
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
13+
return '/'
14+
}
15+
16+
return value
17+
}
18+
19+
async function createAccessToken(username: string, password: string) {
20+
const payload = new TextEncoder().encode(`portfolio:${username}:${password}`)
21+
const digest = await crypto.subtle.digest('SHA-256', payload)
22+
23+
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join(
24+
''
25+
)
26+
}
27+
28+
export async function POST(request: NextRequest) {
29+
const configuredUsername = normalizeCredential(process.env.BASIC_AUTH_USERNAME)
30+
const configuredPassword = normalizeCredential(process.env.BASIC_AUTH_PASSWORD)
31+
32+
if (!configuredUsername || !configuredPassword) {
33+
return new NextResponse('Site access is not configured.', {
34+
status: 503,
35+
headers: {
36+
'Cache-Control': 'private, no-store',
37+
},
38+
})
39+
}
40+
41+
const formData = await request.formData()
42+
const submittedUsername = normalizeCredential(formData.get('username')?.toString())
43+
const submittedPassword = normalizeCredential(formData.get('password')?.toString())
44+
const nextPath = getSafeRedirectPath(formData.get('next'))
45+
46+
if (submittedUsername !== configuredUsername || submittedPassword !== configuredPassword) {
47+
const loginUrl = new URL('/access', request.url)
48+
loginUrl.searchParams.set('error', 'invalid')
49+
50+
if (nextPath !== '/') {
51+
loginUrl.searchParams.set('next', nextPath)
52+
}
53+
54+
return NextResponse.redirect(loginUrl, { status: 303 })
55+
}
56+
57+
const response = NextResponse.redirect(new URL(nextPath, request.url), { status: 303 })
58+
59+
response.cookies.set({
60+
name: ACCESS_COOKIE_NAME,
61+
value: await createAccessToken(configuredUsername, configuredPassword),
62+
httpOnly: true,
63+
sameSite: 'lax',
64+
secure: request.nextUrl.protocol === 'https:' || process.env.NODE_ENV === 'production',
65+
path: '/',
66+
maxAge: ACCESS_COOKIE_MAX_AGE,
67+
})
68+
69+
return response
70+
}

0 commit comments

Comments
 (0)