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
5 changes: 3 additions & 2 deletions app/api/auth/github/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { buildGitHubAuthorizationUrl } from '../../../../lib/github-oauth.js';

const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;

export async function GET() {
export async function GET(request) {
if (!GITHUB_CLIENT_ID) {
return NextResponse.json(
{ error: 'GitHub OAuth is not configured' },
{ status: 503 }
);
}

return NextResponse.redirect(buildGitHubAuthorizationUrl(GITHUB_CLIENT_ID));
const login = new URL(request.url).searchParams.get('login') || '';
return NextResponse.redirect(buildGitHubAuthorizationUrl(GITHUB_CLIENT_ID, login));
}
33 changes: 31 additions & 2 deletions app/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { enrichWithCollaborators } from '../lib/collaboration.js';
import dynamic from 'next/dynamic';

const Globe = dynamic(() => import('../components/Globe.jsx'), { ssr: false });
const PENDING_CLAIM_KEY = 'devglobe-pending-claim';

export default function Home() {
const [developers, setDevelopers] = useState([]);
Expand All @@ -39,6 +40,7 @@ export default function Home() {
const [cardRequest, setCardRequest] = useState(0);
const [cardContext, setCardContext] = useState(null);
const [showAddMe, setShowAddMe] = useState(false);
const [verificationUsername, setVerificationUsername] = useState('');
const [showClaimPending, setShowClaimPending] = useState(false);
const [showAiProfile, setShowAiProfile] = useState(false);
const [showIntroductions, setShowIntroductions] = useState(false);
Expand Down Expand Up @@ -118,7 +120,7 @@ export default function Home() {
setCardRequest(0);
setShowClaimPending(true);
setSidebarOpen(false);
return;
return { ok: false, ...result };
}
setClaimStatus('claimed');
setClaimedLogins(prev => new Set(prev).add(user.login));
Expand Down Expand Up @@ -152,15 +154,34 @@ export default function Home() {
setCardContext('claim');
setCardRequest(request => request + 1);
setSidebarOpen(false);
return { ok: true, ...result };
} else {
const data = await res.json();
console.error('Claim failed:', data.error);
return { ok: false, ...data };
}
} catch (err) {
console.error('Claim error:', err);
return { ok: false, error: err.message };
}
}, [user, developers]);

useEffect(() => {
if (!user) return;
let pendingUsername = '';
try { pendingUsername = localStorage.getItem(PENDING_CLAIM_KEY) || ''; } catch { return; }
if (!pendingUsername) return;

if (pendingUsername.toLowerCase() !== user.login.toLowerCase()) {
setVerificationUsername(pendingUsername);
setShowAddMe(true);
return;
}

localStorage.removeItem(PENDING_CLAIM_KEY);
void handleClaim();
}, [user, handleClaim]);

const handleToggleTheme = useCallback(() => {
setTheme(prev => {
const next = prev === 'dark' ? 'light' : 'dark';
Expand Down Expand Up @@ -369,6 +390,7 @@ export default function Home() {
}, []);

const handleAddMe = useCallback(() => {
setVerificationUsername('');
setShowAddMe(true);
}, []);

Expand Down Expand Up @@ -490,7 +512,14 @@ export default function Home() {
{compareDevs.length === 2 && (
<ComparePanel devs={compareDevs} onClose={handleCloseCompare} />
)}
{showAddMe && <AddMeModal onClose={handleCloseAddMe} />}
{showAddMe && (
<AddMeModal
onClose={handleCloseAddMe}
user={user}
onVerify={handleClaim}
verificationUsername={verificationUsername}
/>
)}
{showClaimPending && <ClaimStatusModal onClose={() => setShowClaimPending(false)} />}
{showAiProfile && (
<AiProfileModal
Expand Down
73 changes: 65 additions & 8 deletions components/AddMeModal.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import React, { useState, useEffect, useRef } from 'react';
import styles from './AddMeModal.module.css';

const SUCCESS_MESSAGE = "Your profile is pending review. We'll email you when it is approved and visible on the globe, usually within a week.";
const PENDING_CLAIM_KEY = 'devglobe-pending-claim';

export default function AddMeModal({ onClose }) {
const [username, setUsername] = useState('');
export default function AddMeModal({ onClose, user, onVerify, verificationUsername = '' }) {
const [username, setUsername] = useState(verificationUsername);
const [location, setLocation] = useState('');
const [email, setEmail] = useState('');
const [emailConsent, setEmailConsent] = useState(false);
const [status, setStatus] = useState('idle'); // idle | submitting | success | error
const [status, setStatus] = useState(verificationUsername ? 'success' : 'idle'); // idle | submitting | success | verifying | error
const [error, setError] = useState('');
const inputRef = useRef(null);
const normalizedUsername = username.trim().replace(/^@/, '');
const identityMatches = user?.login?.toLowerCase() === normalizedUsername.toLowerCase();

useEffect(() => {
inputRef.current?.focus();
Expand Down Expand Up @@ -56,30 +58,85 @@ export default function AddMeModal({ onClose }) {
setError(data.error || 'Something went wrong. Please try again.');
return;
}
setUsername(data.username || clean);
setStatus('success');
} catch (err) {
setStatus('error');
setError('Network error. Please try again.');
}
};

const handleVerify = async () => {
try {
localStorage.setItem(PENDING_CLAIM_KEY, normalizedUsername);
} catch { /* Continue with the current session when storage is unavailable. */ }

if (!user) {
window.location.assign(`/api/auth/github?login=${encodeURIComponent(normalizedUsername)}`);
return;
}

if (!identityMatches) return;

setStatus('verifying');
setError('');
const result = await onVerify();
if (!result?.ok) {
setStatus('success');
setError('We could not verify your profile. Please try again.');
return;
}
try { localStorage.removeItem(PENDING_CLAIM_KEY); } catch { /* Ignore storage cleanup failures. */ }
onClose();
};

return (
<div className={styles['modal-overlay']} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add me to DevGlobe">
<button className={styles['modal__close']} onClick={onClose} aria-label="Close" type="button">✕</button>

{status === 'success' ? (
<div className={styles['modal__success']}>
<div className={styles['modal__success-icon']}>🎉</div>
<div className={styles['modal__success-icon']} aria-hidden="true">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</div>
<h2 className={styles['modal__title']}>Nomination received</h2>
<p className={styles['modal__message']}>{SUCCESS_MESSAGE}</p>
<button className="btn btn--primary" onClick={onClose} type="button">Done</button>
{user && !identityMatches ? (
<div className={styles['modal__identity-warning']} role="alert">
<strong>Sign in as @{normalizedUsername}</strong>
<span>You are currently signed in as @{user.login}. We will keep this nomination pending rather than verify the wrong account.</span>
</div>
) : (
<p className={styles['modal__message']}>
Verify ownership of <strong>@{normalizedUsername}</strong> with GitHub to publish your profile now. Otherwise, it will remain in the review queue.
</p>
)}
{error && <div className={styles['modal__error']}>{error}</div>}
{(!user || identityMatches) && (
<button className={`btn btn--primary ${styles['modal__verify']}`} onClick={handleVerify} type="button">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8z" />
</svg>
Verify with GitHub
</button>
)}
<button className={styles['modal__later']} onClick={onClose} type="button">
{user && !identityMatches ? 'Close' : 'I will verify later'}
</button>
</div>
) : status === 'verifying' ? (
<div className={styles['modal__success']} aria-live="polite">
<div className={styles['modal__spinner']} aria-hidden="true" />
<h2 className={styles['modal__title']}>Publishing your profile</h2>
<p className={styles['modal__message']}>Confirming your GitHub identity and adding you to the globe.</p>
</div>
) : (
<>
<h2 className={styles['modal__title']}>Add me to DevGlobe</h2>
<p className={styles['modal__subtitle']}>
Submit your GitHub username to be featured on the globe. We'll review and add you within a week.
Submit your profile, then verify with GitHub to publish instantly. Unverified nominations stay in the review queue.
</p>
<form className={styles['modal__form']} onSubmit={handleSubmit}>
<label className={styles['modal__label']} htmlFor="nominate-username">
Expand Down
68 changes: 67 additions & 1 deletion components/AddMeModal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,14 @@
}

.modal__success-icon {
font-size: 44px;
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 50%;
color: #34d399;
background: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.32);
}

.modal__success .modal__title {
Expand All @@ -159,3 +166,62 @@
color: var(--text-secondary);
line-height: 1.5;
}

.modal__message strong {
color: var(--text-primary);
}

.modal__identity-warning {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
padding: 12px;
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
background: rgba(245, 158, 11, 0.1);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.45;
text-align: left;
}

.modal__identity-warning strong {
color: var(--text-primary);
font-size: 14px;
}

.modal__verify {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 42px;
}

.modal__later {
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 13px;
cursor: pointer;
}

.modal__later:hover {
color: var(--text-primary);
}

.modal__spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: modalSpin 0.8s linear infinite;
}

@keyframes modalSpin {
to { transform: rotate(360deg); }
}
5 changes: 4 additions & 1 deletion lib/github-oauth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export function buildGitHubAuthorizationUrl(clientId) {
const GITHUB_LOGIN_RE = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;

export function buildGitHubAuthorizationUrl(clientId, login = '') {
const url = new URL('https://github.com/login/oauth/authorize');
url.searchParams.set('client_id', clientId);
url.searchParams.set('scope', 'read:user user:email');
if (GITHUB_LOGIN_RE.test(login)) url.searchParams.set('login', login);
return url.toString();
}
8 changes: 8 additions & 0 deletions tests/github-oauth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,12 @@ test('uses the callback registered on the GitHub OAuth application', () => {
assert.equal(url.searchParams.get('client_id'), 'client-id');
assert.equal(url.searchParams.get('scope'), 'read:user user:email');
assert.equal(url.searchParams.has('redirect_uri'), false);
});

test('suggests a valid nominated login without forwarding invalid input', () => {
const hintedUrl = new URL(buildGitHubAuthorizationUrl('client-id', 'octo-cat'));
const invalidUrl = new URL(buildGitHubAuthorizationUrl('client-id', 'not a login'));

assert.equal(hintedUrl.searchParams.get('login'), 'octo-cat');
assert.equal(invalidUrl.searchParams.has('login'), false);
});