diff --git a/app/api/auth/claim/route.js b/app/api/auth/claim/route.js index f3dc4d6..ecf73fd 100644 --- a/app/api/auth/claim/route.js +++ b/app/api/auth/claim/route.js @@ -3,8 +3,9 @@ import { NextResponse } from 'next/server'; import { getSession } from '../../../../lib/auth.js'; import { promises as fs } from 'fs'; import path from 'path'; -import { buildClaimWelcomeEmail, sendLifecycleEmail } from '../../../../lib/lifecycle-email.js'; -import { saveDeveloperContact } from '../../../../lib/developer-contact-store.js'; +import { buildClaimApprovedEmail, buildClaimWelcomeEmail, sendLifecycleEmail } from '../../../../lib/lifecycle-email.js'; +import { getDeveloperContact, saveDeveloperContact } from '../../../../lib/developer-contact-store.js'; +import { approvePendingNominationFromClaim, isPublicDeveloper } from '../../../../lib/nominate.js'; const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; const COSMOS_KEY = process.env.COSMOS_KEY; @@ -120,6 +121,10 @@ export async function POST() { } } + const autoApprovedDev = approvePendingNominationFromClaim(dev); + const autoApproved = Boolean(autoApprovedDev); + if (autoApprovedDev) dev = autoApprovedDev; + await container.items.upsert(dev); if (session.email) { @@ -136,12 +141,21 @@ export async function POST() { } } - if (!wasClaimed) { + if (!wasClaimed || autoApproved) { try { + let recipient = session.email; + if (!recipient && autoApproved) { + const contact = await getDeveloperContact(dev.login); + if (contact?.transactionalEmailsEnabled) recipient = contact.email; + } await sendLifecycleEmail({ - to: session.email, - message: buildClaimWelcomeEmail({ login: dev.login, name: dev.name }), - idempotencyKey: `profile-claimed-${dev.login.toLowerCase()}`, + to: recipient, + message: autoApproved + ? buildClaimApprovedEmail({ login: dev.login, name: dev.name }) + : buildClaimWelcomeEmail({ login: dev.login, name: dev.name }), + idempotencyKey: autoApproved + ? `nomination-auto-approved-${dev.login.toLowerCase()}-${Date.parse(dev.nomination.submittedAt)}` + : `profile-claimed-${dev.login.toLowerCase()}`, }); } catch (emailError) { console.error('Claim email delivery failed:', emailError.message); @@ -153,6 +167,8 @@ export async function POST() { login, created: resources.length === 0, claimedAt: dev.claimedAt, + profileStatus: isPublicDeveloper(dev) ? 'public' : dev.nomination.status, + autoApproved, }); } catch (err) { console.error('Claim error:', err); @@ -171,6 +187,7 @@ export async function POST() { login, created: !dev, claimedAt: new Date().toISOString(), + profileStatus: 'public', note: 'Claim recorded (dev mode — not persisted without Cosmos DB)', }); } diff --git a/app/page.jsx b/app/page.jsx index 7e374bc..380febf 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -8,6 +8,7 @@ import DetailPanel from '../components/DetailPanel.jsx'; import ComparePanel from '../components/ComparePanel.jsx'; import LoadingOverlay from '../components/LoadingOverlay.jsx'; import AddMeModal from '../components/AddMeModal.jsx'; +import ClaimStatusModal from '../components/ClaimStatusModal.jsx'; import AiProfileModal from '../components/AiProfileModal.jsx'; import IntroductionInboxModal from '../components/IntroductionInboxModal.jsx'; import QuickTour from '../components/QuickTour.jsx'; @@ -31,13 +32,14 @@ export default function Home() { const [compareDevs, setCompareDevs] = useState([]); const [theme, setTheme] = useState('dark'); const [user, setUser] = useState(null); - const [claimStatus, setClaimStatus] = useState('unclaimed'); // 'unclaimed' | 'claimed' | 'no_match' + const [claimStatus, setClaimStatus] = useState('unclaimed'); // 'unclaimed' | 'pending' | 'claimed' | 'no_match' const [claimedLogins, setClaimedLogins] = useState(new Set()); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarView, setSidebarView] = useState('leaderboard'); const [cardRequest, setCardRequest] = useState(0); const [cardContext, setCardContext] = useState(null); const [showAddMe, setShowAddMe] = useState(false); + const [showClaimPending, setShowClaimPending] = useState(false); const [showAiProfile, setShowAiProfile] = useState(false); const [showIntroductions, setShowIntroductions] = useState(false); const [agentGlobeLayerVisible, setAgentGlobeLayerVisible] = useState(false); @@ -109,11 +111,20 @@ export default function Home() { const res = await fetch('/api/auth/claim', { method: 'POST' }); if (res.ok) { const result = await res.json(); + if (result.profileStatus !== 'public') { + setClaimStatus('pending'); + setSelectedDev(null); + setCardContext(null); + setCardRequest(0); + setShowClaimPending(true); + setSidebarOpen(false); + return; + } setClaimStatus('claimed'); setClaimedLogins(prev => new Set(prev).add(user.login)); let claimedDeveloper = developers.find(developer => developer.login === user.login); // If a new profile was created, reload developers to include it - if (result.created) { + if (result.created || result.autoApproved) { const devRes = await fetch('/api/developers', { cache: 'no-store' }); if (devRes.ok) { const raw = await devRes.json(); @@ -479,6 +490,7 @@ export default function Home() { )} {showAddMe && } + {showClaimPending && setShowClaimPending(false)} />} {showAiProfile && ( setShowAiProfile(false)} diff --git a/components/ClaimStatusModal.jsx b/components/ClaimStatusModal.jsx new file mode 100644 index 0000000..13f2727 --- /dev/null +++ b/components/ClaimStatusModal.jsx @@ -0,0 +1,29 @@ +'use client'; + +import React from 'react'; + +export default function ClaimStatusModal({ onClose }) { + return ( + + event.stopPropagation()} + > + × + + + + + + + Profile claimed and pending review + Your profile is still being reviewed. We'll email you when it is approved and visible on the globe, usually within a week. + Your identity card will be available after approval. + Done + + + ); +} \ No newline at end of file diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx index 3f5951d..bb8918b 100644 --- a/components/DetailPanel.jsx +++ b/components/DetailPanel.jsx @@ -567,14 +567,21 @@ function CardModal({ dev, claimSuccess, onClose }) { const handleDownload = async () => { try { const res = await fetch(cardUrl); + if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) { + throw new Error('Card image is unavailable'); + } const blob = await res.blob(); + if (!blob.size) throw new Error('Card image is empty'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `devglobe-${login}.png`; a.click(); URL.revokeObjectURL(url); - } catch { /* ignore */ } + } catch { + setLoading(false); + setError(true); + } }; const handleCopyLink = () => { @@ -623,19 +630,19 @@ function CardModal({ dev, claimSuccess, onClose }) { {loading && !error && Generating card...} - {error && Failed to generate card} + {error && Card unavailable. Please try again later.} setLoading(false)} onError={() => { setLoading(false); setError(true); }} /> - + diff --git a/components/UserMenu.jsx b/components/UserMenu.jsx index 741271b..97f6e8c 100644 --- a/components/UserMenu.jsx +++ b/components/UserMenu.jsx @@ -184,6 +184,15 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO )} > )} + {claimStatus === 'pending' && ( + + + + + + Profile pending review + + )} {claimStatus === 'no_match' && ( No matching profile found diff --git a/lib/lifecycle-email.js b/lib/lifecycle-email.js index 721e722..3bac642 100644 --- a/lib/lifecycle-email.js +++ b/lib/lifecycle-email.js @@ -71,6 +71,23 @@ export function buildClaimWelcomeEmail({ login, name }) { }; } +export function buildClaimApprovedEmail({ login, name }) { + const greeting = name || login; + const url = profileUrl(login); + return { + subject: 'Your DevGlobe profile is claimed and live', + text: `DevGlobe - ${TAGLINE}\n\nHi ${greeting},\n\nYour GitHub ownership was verified, so your nomination was approved automatically. Your DevGlobe profile is now public and under your control.\n\nOpen your profile: ${url}\nGenerate identity card: ${getSiteUrl()}/share/${encodeURIComponent(login)}\nStar DevGlobe on GitHub: ${REPOSITORY_URL}\n\nDevGlobe`, + html: emailLayout({ + preview: 'Your DevGlobe profile is claimed, approved, and live.', + heading: 'Your profile is live', + greeting, + body: 'Your GitHub ownership was verified, so your nomination was approved automatically. Your DevGlobe profile is now public and under your control.', + login, + action: 'Explore your profile', + }), + }; +} + export function buildNominationApprovedEmail({ login, name }) { const greeting = name || login; const url = profileUrl(login); diff --git a/lib/nominate.js b/lib/nominate.js index 573ef8e..00bc0ab 100644 --- a/lib/nominate.js +++ b/lib/nominate.js @@ -74,6 +74,21 @@ export function isPublicDeveloper(doc) { return !doc.nomination || doc.nomination.status === 'approved'; } +export function approvePendingNominationFromClaim(doc, now = new Date().toISOString()) { + if (doc?.nomination?.status !== 'pending') return null; + + return { + ...doc, + nomination: { + ...doc.nomination, + status: 'approved', + reviewedAt: now, + reviewedBy: 'github-ownership-claim', + rejectionReason: null, + }, + }; +} + /** * Resolves the location to store on a new nomination document. This value * becomes the Cosmos partition key for the item's entire lifecycle, so it is diff --git a/styles/main.css b/styles/main.css index a403e46..9a3e314 100644 --- a/styles/main.css +++ b/styles/main.css @@ -3468,6 +3468,11 @@ body { cursor: default; } +.user-menu__item--pending { + color: #d97706; + cursor: default; +} + .user-menu__item--no-match { color: var(--text-muted); cursor: default; @@ -4457,6 +4462,11 @@ body { cursor: pointer; } +.card-modal__btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .badge-card__format-btn--active { background: #161f33; color: #f8fafc; @@ -4625,6 +4635,42 @@ body { font-size: 11px; } +.claim-status-modal { + position: relative; + width: min(440px, 90vw); + padding: 32px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + box-shadow: var(--shadow-strong); + text-align: center; +} + +.claim-status-modal__icon { + display: inline-flex; + padding: 12px; + border-radius: 50%; + background: rgba(217, 119, 6, 0.12); + color: #d97706; +} + +.claim-status-modal h2 { + margin: 16px 24px 10px; + color: var(--text-primary); + font-size: 20px; +} + +.claim-status-modal p { + margin: 0 0 10px; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.55; +} + +.claim-status-modal .btn { + margin-top: 12px; +} + @media (prefers-reduced-motion: reduce) { .global-activity__item--new { animation: none; } } diff --git a/tests/claim-auto-approval.test.js b/tests/claim-auto-approval.test.js new file mode 100644 index 0000000..c7977fc --- /dev/null +++ b/tests/claim-auto-approval.test.js @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { approvePendingNominationFromClaim } from '../lib/nominate.js'; + +test('authenticated ownership claim approves a pending nomination', () => { + const submittedAt = '2026-08-15T10:00:00.000Z'; + const reviewedAt = '2026-08-16T10:00:00.000Z'; + const developer = { + id: 'octocat', + login: 'octocat', + nomination: { status: 'pending', submittedAt, reviewedAt: null, reviewedBy: null }, + }; + + const approved = approvePendingNominationFromClaim(developer, reviewedAt); + + assert.equal(approved.nomination.status, 'approved'); + assert.equal(approved.nomination.reviewedAt, reviewedAt); + assert.equal(approved.nomination.reviewedBy, 'github-ownership-claim'); + assert.equal(approved.nomination.submittedAt, submittedAt); + assert.equal(developer.nomination.status, 'pending'); +}); + +test('ownership claim does not automatically approve rejected or public profiles', () => { + assert.equal(approvePendingNominationFromClaim({ nomination: { status: 'rejected' } }), null); + assert.equal(approvePendingNominationFromClaim({ nomination: { status: 'approved' } }), null); + assert.equal(approvePendingNominationFromClaim({ login: 'legacy-profile' }), null); +}); \ No newline at end of file diff --git a/tests/lifecycle-email.test.js b/tests/lifecycle-email.test.js index ea39586..130e543 100644 --- a/tests/lifecycle-email.test.js +++ b/tests/lifecycle-email.test.js @@ -1,12 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + buildClaimApprovedEmail, buildClaimWelcomeEmail, buildEmailVerificationEmail, buildNominationApprovedEmail, sendLifecycleEmail, } from '../lib/lifecycle-email.js'; +test('builds combined claim and automatic approval email', () => { + const message = buildClaimApprovedEmail({ login: 'octocat', name: 'Octocat' }); + + assert.match(message.subject, /claimed and live/i); + assert.match(message.text, /approved automatically/i); + assert.match(message.text, /Generate identity card/); + assert.match(message.html, /Your profile is live/); +}); + test('builds claim email with an encoded profile link and escaped HTML', () => { const message = buildClaimWelcomeEmail({ login: 'dev user', name: '' });
Your profile is still being reviewed. We'll email you when it is approved and visible on the globe, usually within a week.
Your identity card will be available after approval.