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
29 changes: 23 additions & 6 deletions app/api/auth/claim/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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)',
});
}
16 changes: 14 additions & 2 deletions app/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -479,6 +490,7 @@ export default function Home() {
<ComparePanel devs={compareDevs} onClose={handleCloseCompare} />
)}
{showAddMe && <AddMeModal onClose={handleCloseAddMe} />}
{showClaimPending && <ClaimStatusModal onClose={() => setShowClaimPending(false)} />}
{showAiProfile && (
<AiProfileModal
onClose={() => setShowAiProfile(false)}
Expand Down
29 changes: 29 additions & 0 deletions components/ClaimStatusModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import React from 'react';

export default function ClaimStatusModal({ onClose }) {
return (
<div className="card-modal-backdrop" onClick={onClose}>
<div
className="claim-status-modal"
role="dialog"
aria-modal="true"
aria-labelledby="claim-status-title"
onClick={event => event.stopPropagation()}
>
<button className="card-modal__close" onClick={onClose} aria-label="Close">&times;</button>
<div className="claim-status-modal__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
</div>
<h2 id="claim-status-title">Profile claimed and pending review</h2>
<p>Your profile is still being reviewed. We&apos;ll email you when it is approved and visible on the globe, usually within a week.</p>
<p>Your identity card will be available after approval.</p>
<button type="button" className="btn btn--primary" onClick={onClose}>Done</button>
</div>
</div>
);
}
15 changes: 11 additions & 4 deletions components/DetailPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -623,19 +630,19 @@ function CardModal({ dev, claimSuccess, onClose }) {

<div className="card-modal__preview">
{loading && !error && <div className="card-modal__loading">Generating card...</div>}
{error && <div className="card-modal__error">Failed to generate card</div>}
{error && <div className="card-modal__error" role="alert">Card unavailable. Please try again later.</div>}
<img
src={cardUrl}
alt={`DevAgent card for ${name}`}
className="card-modal__image"
style={{ display: loading ? 'none' : 'block' }}
style={{ display: loading || error ? 'none' : 'block' }}
onLoad={() => setLoading(false)}
onError={() => { setLoading(false); setError(true); }}
/>
</div>

<div className="card-modal__actions">
<button className="card-modal__btn card-modal__btn--download" onClick={handleDownload}>
<button className="card-modal__btn card-modal__btn--download" onClick={handleDownload} disabled={loading || error}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
Expand Down
9 changes: 9 additions & 0 deletions components/UserMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
)}
</>
)}
{claimStatus === 'pending' && (
<div className="user-menu__item user-menu__item--pending">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
Profile pending review
</div>
)}
{claimStatus === 'no_match' && (
<div className="user-menu__item user-menu__item--no-match">
No matching profile found
Expand Down
17 changes: 17 additions & 0 deletions lib/lifecycle-email.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions lib/nominate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
Expand Down
27 changes: 27 additions & 0 deletions tests/claim-auto-approval.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 10 additions & 0 deletions tests/lifecycle-email.test.js
Original file line number Diff line number Diff line change
@@ -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: '<Dev & Co>' });

Expand Down
Loading