From ee7d6adbf6ca728223d93ba48d67f9f5756a2380 Mon Sep 17 00:00:00 2001 From: ummaraali2 Date: Fri, 12 Jun 2026 17:32:22 -0400 Subject: [PATCH 1/3] Add professional auth, display names, and brand assets. OAuth sign-in (ORCID/Google/GitHub), email verification for local accounts, display_name in profiles, logo + auth UI, and contributor testing docs. Co-authored-by: Cursor --- client-next/public/logo-mark.svg | 5 + client-next/public/logo.svg | 7 + client-next/src/app/auth/complete/page.js | 129 +++++ .../src/app/auth/github/callback/page.js | 63 +++ .../src/app/auth/google/callback/page.js | 67 +++ client-next/src/app/forgot-password/page.js | 88 ++-- client-next/src/app/icon.svg | 5 + client-next/src/app/login/Auth.module.css | 209 +++++++- client-next/src/app/login/page.js | 86 ++-- client-next/src/app/orcid/callback/page.js | 46 +- client-next/src/app/register/page.js | 163 +++--- client-next/src/app/reset-password/page.js | 98 ++-- .../src/app/settings/Settings.module.css | 9 + client-next/src/app/settings/page.js | 22 + .../src/app/u/[username]/Profile.module.css | 6 + .../src/app/u/[username]/ProfileClient.jsx | 16 +- client-next/src/app/verify-email/page.js | 118 +++++ client-next/src/components/AuthLayout.jsx | 84 ++++ client-next/src/components/Comment.jsx | 6 +- client-next/src/components/FeedCard.jsx | 7 +- client-next/src/components/Layout.jsx | 2 + client-next/src/components/Logo.jsx | 56 +++ client-next/src/components/Logo.module.css | 34 ++ client-next/src/components/Nav.jsx | 5 +- client-next/src/components/PasswordInput.jsx | 45 ++ .../src/components/VerifyEmailBanner.jsx | 36 ++ .../components/VerifyEmailBanner.module.css | 23 + client-next/src/lib/api.js | 1 + docs/DEVELOPER_NOTES.md | 33 +- docs/TESTING.md | 153 ++++++ server/.env.example | 8 + .../migrations/021_oauth_and_verification.sql | 24 + server/db/migrations/022_display_name.sql | 1 + server/index.js | 3 + server/lib/oauthUsers.js | 46 ++ server/lib/session.js | 61 +++ server/middleware/requireVerifiedEmail.js | 37 ++ server/routes/auth.js | 471 ++++++++++-------- server/routes/discussions.js | 23 +- server/routes/explore.js | 2 + server/routes/github.js | 121 +++++ server/routes/google.js | 148 ++++++ server/routes/orcid.js | 305 ++++++------ server/routes/search.js | 3 +- server/routes/users.js | 16 +- server/tests/auth-verify.test.js | 133 +++++ server/tests/auth.test.js | 1 + server/tests/helpers.js | 13 + server/tests/papers.test.js | 2 + server/tests/social.test.js | 4 + 50 files changed, 2426 insertions(+), 618 deletions(-) create mode 100644 client-next/public/logo-mark.svg create mode 100644 client-next/public/logo.svg create mode 100644 client-next/src/app/auth/complete/page.js create mode 100644 client-next/src/app/auth/github/callback/page.js create mode 100644 client-next/src/app/auth/google/callback/page.js create mode 100644 client-next/src/app/icon.svg create mode 100644 client-next/src/app/verify-email/page.js create mode 100644 client-next/src/components/AuthLayout.jsx create mode 100644 client-next/src/components/Logo.jsx create mode 100644 client-next/src/components/Logo.module.css create mode 100644 client-next/src/components/PasswordInput.jsx create mode 100644 client-next/src/components/VerifyEmailBanner.jsx create mode 100644 client-next/src/components/VerifyEmailBanner.module.css create mode 100644 docs/TESTING.md create mode 100644 server/db/migrations/021_oauth_and_verification.sql create mode 100644 server/db/migrations/022_display_name.sql create mode 100644 server/lib/oauthUsers.js create mode 100644 server/lib/session.js create mode 100644 server/middleware/requireVerifiedEmail.js create mode 100644 server/routes/github.js create mode 100644 server/routes/google.js create mode 100644 server/tests/auth-verify.test.js create mode 100644 server/tests/helpers.js diff --git a/client-next/public/logo-mark.svg b/client-next/public/logo-mark.svg new file mode 100644 index 0000000..cfbeb7c --- /dev/null +++ b/client-next/public/logo-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client-next/public/logo.svg b/client-next/public/logo.svg new file mode 100644 index 0000000..0d67caf --- /dev/null +++ b/client-next/public/logo.svg @@ -0,0 +1,7 @@ + + + + + Post + Scholar + diff --git a/client-next/src/app/auth/complete/page.js b/client-next/src/app/auth/complete/page.js new file mode 100644 index 0000000..94fe25a --- /dev/null +++ b/client-next/src/app/auth/complete/page.js @@ -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 ( +
+
+
+

Missing completion token. Please sign in with ORCID again.

+

Back to sign in

+
+
+
+ ) + } + + 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 ( +
+ +
+
+
+ +

Complete your profile

+

Choose a username for your ORCID account

+
+ +
+
+ +

Optional — how your name appears on comments

+ setDisplayName(e.target.value)} + maxLength={50} + /> +
+ +
+ +

Used in your profile URL and @mentions

+ setUsername(e.target.value.toLowerCase())} + required + /> +
+ + {error &&

{error}

} + + +
+
+
+
+ ) +} + +export default function AuthCompletePage() { + return ( + }> + + + ) +} diff --git a/client-next/src/app/auth/github/callback/page.js b/client-next/src/app/auth/github/callback/page.js new file mode 100644 index 0000000..3613304 --- /dev/null +++ b/client-next/src/app/auth/github/callback/page.js @@ -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 ( +
+
+
+

{message}

+
+
+
+ ) +} + +export default function GitHubCallbackPage() { + return ( + }> + + + ) +} diff --git a/client-next/src/app/auth/google/callback/page.js b/client-next/src/app/auth/google/callback/page.js new file mode 100644 index 0000000..8ad9d13 --- /dev/null +++ b/client-next/src/app/auth/google/callback/page.js @@ -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 ( +
+
+
+

{message}

+
+
+
+ ) +} + +function GoogleCallbackInner() { + return +} + +export default function GoogleCallbackPage() { + return ( + }> + + + ) +} diff --git a/client-next/src/app/forgot-password/page.js b/client-next/src/app/forgot-password/page.js index 8560b2a..be4daf6 100644 --- a/client-next/src/app/forgot-password/page.js +++ b/client-next/src/app/forgot-password/page.js @@ -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) @@ -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) { @@ -41,50 +38,45 @@ export default function ForgotPassword() { return (
-
-
- PostScholar -

Reset password

-

- Enter your email and we'll send a reset link. -

-
- - {submitted ? ( -
- If that email exists, a reset link has been sent. Check your inbox. + +
+
+
+ +

Reset password

+

Enter your email and we'll send a reset link.

- ) : ( -
-
- - setEmail(e.target.value)} - autoComplete="email" - /> -
- {error &&

{error}

} - - -
- )} + {submitted ? ( +
+ If that email exists, a reset link has been sent. Check your inbox. +
+ ) : ( +
+
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+ {error &&

{error}

} + +
+ )} -

- Back to sign in -

+

+ Back to sign in +

+
) -} \ No newline at end of file +} diff --git a/client-next/src/app/icon.svg b/client-next/src/app/icon.svg new file mode 100644 index 0000000..cfbeb7c --- /dev/null +++ b/client-next/src/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client-next/src/app/login/Auth.module.css b/client-next/src/app/login/Auth.module.css index c880492..2593cfb 100644 --- a/client-next/src/app/login/Auth.module.css +++ b/client-next/src/app/login/Auth.module.css @@ -1,24 +1,72 @@ -/* Auth pages — Login and Register share this stylesheet */ +/* Auth pages — shared layout for login, register, forgot, reset */ .page { min-height: 100vh; display: flex; + background: var(--bg-base); +} + +.brandPanel { + display: none; + flex: 1; + padding: var(--space-10); + background: var(--bg-surface); + border-right: 1px solid var(--border); + flex-direction: column; + justify-content: center; + gap: var(--space-6); + max-width: 480px; +} + +.brandLogo { + margin-bottom: var(--space-2); +} + +.brandTitle { + font-family: var(--font-serif); + font-size: var(--text-2xl); + font-weight: 600; + color: var(--text-primary); + line-height: var(--leading-snug); + margin: 0; +} + +.brandList { + margin: 0; + padding-left: var(--space-5); + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); +} + +.brandFooter { + margin: 0; + font-size: var(--text-sm); + color: var(--text-muted); +} + +.brandFooter a { + color: var(--accent); + text-decoration: none; +} + +.formPanel { + flex: 1; + display: flex; align-items: center; justify-content: center; - background: var(--bg-base); padding: var(--space-6); } .card { width: 100%; - max-width: 360px; + max-width: 400px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-8); } -/* Header */ .header { margin-bottom: var(--space-6); display: flex; @@ -26,33 +74,81 @@ gap: var(--space-2); } -.wordmark { - font-family: var(--font-serif); - font-size: var(--text-base); - font-weight: 600; - color: var(--accent); - text-decoration: none; - letter-spacing: -0.01em; -} - -.wordmark:hover { - text-decoration: none; - color: var(--accent-hover); +.mobileLogo { + display: block; + margin-bottom: var(--space-2); } .title { font-size: var(--text-2xl); font-weight: 700; color: var(--text-primary); - margin: var(--space-2) 0 var(--space-3); + margin: 0; } .subtitle { font-size: var(--text-sm); color: var(--text-muted); + margin: 0; +} + +.social { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-5); +} + +.socialBtn { + width: 100%; + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: 500; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text-primary); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.socialBtn:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--bg-surface); +} + +.socialBtn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.orcidBtn { + border-color: #a6ce39; + color: #3d5c0a; +} + +.googleBtn, +.githubBtn { + /* outline style — inherits .socialBtn */ +} + +.divider { + display: flex; + align-items: center; + gap: var(--space-3); + color: var(--text-muted); + font-size: var(--text-xs); + margin-top: var(--space-2); +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); } -/* Form */ .form { display: flex; flex-direction: column; @@ -83,6 +179,15 @@ margin: 0; } +.readOnly { + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + color: var(--text-muted); + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + .forgotLink { font-size: var(--text-xs); color: var(--text-muted); @@ -115,6 +220,54 @@ color: var(--text-muted); } +.inputError { + border-color: #c0392b; +} + +.passwordWrap { + position: relative; +} + +.passwordWrap .input { + padding-right: var(--space-10); +} + +.passwordToggle { + position: absolute; + right: var(--space-2); + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: var(--space-1); + display: flex; + align-items: center; +} + +.passwordToggle:hover { + color: var(--text-secondary); +} + +.checkboxRow { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-normal); +} + +.checkboxRow input { + margin-top: 3px; +} + +.checkboxRow a { + color: var(--accent); + text-decoration: none; +} + .error { font-size: var(--text-sm); color: #c0392b; @@ -131,8 +284,8 @@ font-size: var(--text-sm); font-weight: 500; color: var(--bg-base); - background: var(--accent); - border: 1px solid var(--accent); + background: var(--brand); + border: 1px solid var(--brand); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.15s ease; @@ -140,8 +293,8 @@ } .submitBtn:hover:not(:disabled) { - background: var(--accent-hover); - border-color: var(--accent-hover); + background: var(--brand-hover, #7a4545); + border-color: var(--brand-hover, #7a4545); } .submitBtn:disabled { @@ -149,7 +302,6 @@ cursor: not-allowed; } -/* Bottom link */ .switchLink { margin-top: var(--space-6); text-align: center; @@ -165,6 +317,7 @@ .switchLink a:hover { text-decoration: underline; } + .successBox { padding: var(--space-4); background: rgba(90, 138, 106, 0.1); @@ -174,3 +327,13 @@ color: var(--text-secondary); line-height: var(--leading-normal); } + +@media (min-width: 900px) { + .brandPanel { + display: flex; + } + + .mobileLogo { + display: none; + } +} diff --git a/client-next/src/app/login/page.js b/client-next/src/app/login/page.js index 1612b43..4fccfe4 100644 --- a/client-next/src/app/login/page.js +++ b/client-next/src/app/login/page.js @@ -4,17 +4,17 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useAuth } from '@/context/AuthContext' +import Logo from '@/components/Logo' +import { AuthBrandPanel, SocialAuthButtons } from '@/components/AuthLayout' +import PasswordInput from '@/components/PasswordInput' import styles from './Auth.module.css' -/** - * Login page - * Centered card layout, inline field-level errors, link to register. - */ export default function Login() { const { login } = useAuth() const router = useRouter() const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [password, setPassword] = useState('') async function handleSubmit(e) { e.preventDefault() @@ -22,7 +22,7 @@ export default function Login() { setLoading(true) const form = e.target try { - await login(form.email.value, form.password.value) + await login(form.email.value, password) router.push('/') } catch (err) { setError(err.message) @@ -33,61 +33,57 @@ export default function Login() { return (
-
-
- PostScholar -

Sign in

-

- Continue to your account -

-
- -
-
- - + +
+
+
+ +

Sign in

+

Continue to your account

-
+ + + +
+ + +
+
Forgot password?
- setPassword(e.target.value)} /> -
- {error &&

{error}

} + {error &&

{error}

} - - + + -

- No account?{' '} - Create one -

+

+ No account? Create one +

+
) -} \ No newline at end of file +} diff --git a/client-next/src/app/orcid/callback/page.js b/client-next/src/app/orcid/callback/page.js index ac77f08..3a7efcb 100644 --- a/client-next/src/app/orcid/callback/page.js +++ b/client-next/src/app/orcid/callback/page.js @@ -3,21 +3,14 @@ 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 './OrcidCallback.module.css' -/** - * OrcidCallback — /orcid/callback - * - * ORCID redirects here after OAuth with ?code=xxx&state=xxx - * We send these to POST /auth/orcid/callback which verifies - * the author and stores the verification record. - * - * After success or failure, we redirect back to the discussion. - */ function OrcidCallbackInner() { const searchParams = useSearchParams() const router = useRouter() - const [status, setStatus] = useState('loading') // loading | success | error | no_match + const { refreshUser } = useAuth() + const [status, setStatus] = useState('loading') const [message, setMessage] = useState('') useEffect(() => { @@ -36,13 +29,30 @@ function OrcidCallbackInner() { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ code, state }) + body: JSON.stringify({ code, state }), }) const data = await res.json() if (!res.ok) { setStatus('error') - setMessage(data.error || 'Verification failed') + setMessage(data.error || 'ORCID sign-in failed') + return + } + + if (data.needs_completion) { + const params = new URLSearchParams({ + token: data.completion_token, + }) + if (data.display_name) params.set('name', data.display_name) + router.replace(`/auth/complete?${params.toString()}`) + return + } + + if (data.mode === 'login') { + await refreshUser() + setStatus('success') + setMessage('Signed in with ORCID.') + setTimeout(() => router.push('/'), 1500) return } @@ -70,7 +80,7 @@ function OrcidCallbackInner() { } exchange() - }, [searchParams, router]) + }, [searchParams, router, refreshUser]) return (
@@ -78,21 +88,21 @@ function OrcidCallbackInner() { {status === 'loading' && ( <>
-

Verifying your ORCID...

+

Connecting your ORCID account…

)} {status === 'success' && ( <>

{message}

-

Redirecting...

+

Redirecting…

)} {(status === 'error' || status === 'no_match') && ( <>

{message}

-

Redirecting...

+

Redirecting…

)}
@@ -102,8 +112,8 @@ function OrcidCallbackInner() { export default function OrcidCallback() { return ( - Loading...
}> + Loading…
}> ) -} \ No newline at end of file +} diff --git a/client-next/src/app/register/page.js b/client-next/src/app/register/page.js index 4670269..feddda1 100644 --- a/client-next/src/app/register/page.js +++ b/client-next/src/app/register/page.js @@ -5,39 +5,60 @@ import { useRouter } from 'next/navigation' import Link from 'next/link' import { useAuth } from '@/context/AuthContext' import { getApiUrl } from '@/lib/config' -import styles from './Auth.module.css' +import Logo from '@/components/Logo' +import { AuthBrandPanel, SocialAuthButtons } from '@/components/AuthLayout' +import PasswordInput from '@/components/PasswordInput' +import styles from '../login/Auth.module.css' + +const USERNAME_REGEX = /^[a-z0-9_]{3,30}$/ -/** - * Register page - * Same card layout as Login, three fields, inline errors. - */ export default function Register() { const { refreshUser } = useAuth() const router = useRouter() const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [termsAccepted, setTermsAccepted] = useState(false) + const [fieldErrors, setFieldErrors] = useState({}) + + function validate() { + const errors = {} + if (!USERNAME_REGEX.test(username)) { + errors.username = '3–30 chars: lowercase letters, numbers, underscores' + } + if (password.length < 8) { + errors.password = 'At least 8 characters' + } + if (!termsAccepted) { + errors.terms = 'You must agree to the Terms and Privacy Policy' + } + setFieldErrors(errors) + return Object.keys(errors).length === 0 + } async function handleSubmit(e) { e.preventDefault() setError(null) + if (!validate()) return + setLoading(true) const form = e.target try { - // Register then immediately log in const res = await fetch(`${getApiUrl()}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ email: form.email.value, - username: form.username.value, - password: form.password.value - }) + username, + password, + }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'Registration failed') await refreshUser() - router.push('/') + router.push('/verify-email?sent=1') } catch (err) { setError(err.message) } finally { @@ -47,70 +68,84 @@ export default function Register() { return (
-
-
- PostScholar -

Create account

-

- Join the discussion -

-
- -
-
- - + +
+
+
+ +

Create account

+

Join paper discussions

-
- -

Lowercase letters, numbers, underscores. 3–30 characters.

- -
+ + + +
+ + +
+ +
+ +

Lowercase letters, numbers, underscores. 3–30 characters.

+ setUsername(e.target.value.toLowerCase())} + required + /> + {fieldErrors.username &&

{fieldErrors.username}

} +
-
- -

At least 8 characters.

- setPassword(e.target.value)} /> -
+ {fieldErrors.password &&

{fieldErrors.password}

} + + + {fieldErrors.terms &&

{fieldErrors.terms}

} - {error &&

{error}

} + {error &&

{error}

} - - + + -

- Already have an account?{' '} - Sign in -

+

+ Already have an account? Sign in +

+
) -} \ No newline at end of file +} diff --git a/client-next/src/app/reset-password/page.js b/client-next/src/app/reset-password/page.js index eea64de..a8dcb4c 100644 --- a/client-next/src/app/reset-password/page.js +++ b/client-next/src/app/reset-password/page.js @@ -4,13 +4,11 @@ import { Suspense, useState } from 'react' import { useSearchParams, useRouter } from 'next/navigation' 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 PasswordInput from '@/components/PasswordInput' +import styles from '../login/Auth.module.css' -/** - * Reset password page — /reset-password?token=xxx - * Reads the token from the URL query string. - * Submits to POST /auth/reset-password. - */ function ResetPasswordInner() { const searchParams = useSearchParams() const token = searchParams.get('token') @@ -40,7 +38,7 @@ function ResetPasswordInner() { const res = await fetch(`${getApiUrl()}/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, password }) + body: JSON.stringify({ token, password }), }) const data = await res.json() if (!res.ok) { @@ -59,9 +57,11 @@ function ResetPasswordInner() { if (!token) { return (
-
-

Invalid reset link. Please request a new one.

-

Request reset link

+
+
+

Invalid reset link. Please request a new one.

+

Request reset link

+
) @@ -69,59 +69,47 @@ function ResetPasswordInner() { return (
-
-
- PostScholar -

Set new password

-
- - {done ? ( -
- Password updated. Redirecting to sign in... + +
+
+
+ +

Set new password

- ) : ( -
-
- - + Password updated. Redirecting to sign in… +
+ ) : ( + + setPassword(e.target.value)} - autoComplete="new-password" /> -
- -
- - setConfirm(e.target.value)} - autoComplete="new-password" /> -
+ {error &&

{error}

} + + + )} - {error &&

{error}

} - - - - )} - -

- Back to sign in -

+

+ Back to sign in +

+
) @@ -129,8 +117,8 @@ function ResetPasswordInner() { export default function ResetPassword() { return ( - Loading...
}> + }> ) -} \ No newline at end of file +} diff --git a/client-next/src/app/settings/Settings.module.css b/client-next/src/app/settings/Settings.module.css index 9e3d92b..9633bba 100644 --- a/client-next/src/app/settings/Settings.module.css +++ b/client-next/src/app/settings/Settings.module.css @@ -155,6 +155,15 @@ cursor: not-allowed; } +.readOnly { + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + color: var(--text-muted); + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + .topicsLink { font-size: var(--text-sm); font-weight: 500; diff --git a/client-next/src/app/settings/page.js b/client-next/src/app/settings/page.js index c00bc22..2b59ef0 100644 --- a/client-next/src/app/settings/page.js +++ b/client-next/src/app/settings/page.js @@ -12,6 +12,7 @@ export default function SettingsPage() { const { user, isLoading, refreshUser } = useAuth() const router = useRouter() const [bio, setBio] = useState('') + const [displayName, setDisplayName] = useState('') const [affiliation, setAffiliation] = useState('') const [location, setLocation] = useState('') const [websiteUrl, setWebsiteUrl] = useState('') @@ -35,6 +36,7 @@ export default function SettingsPage() { getMyProfile() .then(data => { setBio(data.bio || '') + setDisplayName(data.display_name || '') setAffiliation(data.affiliation || '') setLocation(data.location || '') setWebsiteUrl(data.website_url || '') @@ -53,6 +55,7 @@ export default function SettingsPage() { try { await updateProfile({ bio, + display_name: displayName, affiliation, location, website_url: websiteUrl, @@ -81,6 +84,25 @@ export default function SettingsPage() {

Profile

+
+ + setDisplayName(e.target.value)} + placeholder="How your name appears on comments" + maxLength={50} + /> + Optional — @username stays your handle for URLs and mentions +
+ +
+ +
@{user.username}
+ Used in your profile URL and @mentions +
+