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: 5 additions & 0 deletions client-next/public/logo-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions client-next/public/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 129 additions & 0 deletions client-next/src/app/auth/complete/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client'

import { Suspense, useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { getApiUrl } from '@/lib/config'
import { useAuth } from '@/context/AuthContext'
import Logo from '@/components/Logo'
import { AuthBrandPanel } from '@/components/AuthLayout'
import styles from '../../login/Auth.module.css'

const USERNAME_REGEX = /^[a-z0-9_]{3,30}$/

function AuthCompleteInner() {
const searchParams = useSearchParams()
const router = useRouter()
const { refreshUser } = useAuth()
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)

const token = searchParams.get('token')
const prefilledName = searchParams.get('name')

useEffect(() => {
if (prefilledName) setDisplayName(prefilledName)
}, [prefilledName])

if (!token) {
return (
<div className={styles.page}>
<div className={styles.formPanel} style={{ width: '100%' }}>
<div className={styles.card}>
<p className={styles.error}>Missing completion token. Please sign in with ORCID again.</p>
<p className={styles.switchLink}><Link href="/login">Back to sign in</Link></p>
</div>
</div>
</div>
)
}

async function handleSubmit(e) {
e.preventDefault()
setError(null)

if (!USERNAME_REGEX.test(username)) {
setError('Username must be 3–30 characters: lowercase letters, numbers, underscores')
return
}

setLoading(true)
try {
const res = await fetch(`${getApiUrl()}/auth/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
token,
username,
display_name: displayName || undefined,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Could not complete signup')
await refreshUser()
router.push('/')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}

return (
<div className={styles.page}>
<AuthBrandPanel />
<div className={styles.formPanel}>
<div className={styles.card}>
<div className={styles.header}>
<Logo variant="full" href="/" className={styles.mobileLogo} />
<h1 className={styles.title}>Complete your profile</h1>
<p className={styles.subtitle}>Choose a username for your ORCID account</p>
</div>

<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label} htmlFor="display_name">Display name</label>
<p className={styles.hint}>Optional — how your name appears on comments</p>
<input
className={styles.input}
id="display_name"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={50}
/>
</div>

<div className={styles.field}>
<label className={styles.label} htmlFor="username">Username</label>
<p className={styles.hint}>Used in your profile URL and @mentions</p>
<input
className={styles.input}
id="username"
value={username}
onChange={e => setUsername(e.target.value.toLowerCase())}
required
/>
</div>

{error && <p className={styles.error}>{error}</p>}

<button className={styles.submitBtn} type="submit" disabled={loading}>
{loading ? 'Creating account…' : 'Continue'}
</button>
</form>
</div>
</div>
</div>
)
}

export default function AuthCompletePage() {
return (
<Suspense fallback={<div className={styles.page} />}>
<AuthCompleteInner />
</Suspense>
)
}
63 changes: 63 additions & 0 deletions client-next/src/app/auth/github/callback/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client'

import { Suspense, useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { getApiUrl } from '@/lib/config'
import { useAuth } from '@/context/AuthContext'
import styles from '../../../login/Auth.module.css'

function GitHubCallbackInner() {
const searchParams = useSearchParams()
const router = useRouter()
const { refreshUser } = useAuth()
const [message, setMessage] = useState('Signing in with GitHub…')

useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
if (!code || !state) {
setMessage('Invalid callback — missing code or state.')
return
}

async function exchange() {
try {
const res = await fetch(`${getApiUrl()}/auth/github/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state }),
})
const data = await res.json()
if (!res.ok) {
setMessage(data.error || 'Sign-in failed')
return
}
await refreshUser()
router.push('/')
} catch {
setMessage('Failed to reach server')
}
}

exchange()
}, [searchParams, router, refreshUser])

return (
<div className={styles.page}>
<div className={styles.formPanel} style={{ width: '100%' }}>
<div className={styles.card}>
<p className={styles.subtitle}>{message}</p>
</div>
</div>
</div>
)
}

export default function GitHubCallbackPage() {
return (
<Suspense fallback={<div className={styles.page} />}>
<GitHubCallbackInner />
</Suspense>
)
}
67 changes: 67 additions & 0 deletions client-next/src/app/auth/google/callback/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'

import { Suspense, useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { getApiUrl } from '@/lib/config'
import { useAuth } from '@/context/AuthContext'
import styles from '../../../login/Auth.module.css'

function OAuthCallback({ provider, endpoint }) {
const searchParams = useSearchParams()
const router = useRouter()
const { refreshUser } = useAuth()
const [message, setMessage] = useState(`Signing in with ${provider}…`)

useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
if (!code || !state) {
setMessage('Invalid callback — missing code or state.')
return
}

async function exchange() {
try {
const res = await fetch(`${getApiUrl()}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state }),
})
const data = await res.json()
if (!res.ok) {
setMessage(data.error || 'Sign-in failed')
return
}
await refreshUser()
router.push('/')
} catch {
setMessage('Failed to reach server')
}
}

exchange()
}, [searchParams, router, refreshUser, endpoint])

return (
<div className={styles.page}>
<div className={styles.formPanel} style={{ width: '100%' }}>
<div className={styles.card}>
<p className={styles.subtitle}>{message}</p>
</div>
</div>
</div>
)
}

function GoogleCallbackInner() {
return <OAuthCallback provider="Google" endpoint="/auth/google/callback" />
}

export default function GoogleCallbackPage() {
return (
<Suspense fallback={<div className={styles.page} />}>
<GoogleCallbackInner />
</Suspense>
)
}
88 changes: 40 additions & 48 deletions client-next/src/app/forgot-password/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
import { useState } from 'react'
import Link from 'next/link'
import { getApiUrl } from '@/lib/config'
import styles from './Auth.module.css'
import Logo from '@/components/Logo'
import { AuthBrandPanel } from '@/components/AuthLayout'
import styles from '../login/Auth.module.css'

/**
* Forgot password page — /forgot-password
* Submits email to POST /auth/forgot-password.
* Always shows success message to prevent user enumeration.
*/
export default function ForgotPassword() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
Expand All @@ -24,7 +21,7 @@ export default function ForgotPassword() {
const res = await fetch(`${getApiUrl()}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
Expand All @@ -41,50 +38,45 @@ export default function ForgotPassword() {

return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.header}>
<Link href="/" className={styles.wordmark}>PostScholar</Link>
<h1 className={styles.title}>Reset password</h1>
<p className={styles.subtitle}>
Enter your email and we'll send a reset link.
</p>
</div>

{submitted ? (
<div className={styles.successBox}>
If that email exists, a reset link has been sent. Check your inbox.
<AuthBrandPanel />
<div className={styles.formPanel}>
<div className={styles.card}>
<div className={styles.header}>
<Logo variant="full" href="/" className={styles.mobileLogo} />
<h1 className={styles.title}>Reset password</h1>
<p className={styles.subtitle}>Enter your email and we&apos;ll send a reset link.</p>
</div>
) : (
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">Email</label>
<input
className={styles.input}
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
/>
</div>

{error && <p className={styles.error}>{error}</p>}

<button
className={styles.submitBtn}
type="submit"
disabled={loading}
>
{loading ? 'Sending...' : 'Send reset link'}
</button>
</form>
)}
{submitted ? (
<div className={styles.successBox}>
If that email exists, a reset link has been sent. Check your inbox.
</div>
) : (
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">Email</label>
<input
className={styles.input}
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<button className={styles.submitBtn} type="submit" disabled={loading}>
{loading ? 'Sending…' : 'Send reset link'}
</button>
</form>
)}

<p className={styles.switchLink}>
<Link href="/login">Back to sign in</Link>
</p>
<p className={styles.switchLink}>
<Link href="/login">Back to sign in</Link>
</p>
</div>
</div>
</div>
)
}
}
5 changes: 5 additions & 0 deletions client-next/src/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading