From 3047a22a58da267cd12bb8422244d5b7e4c877ab Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 14:10:39 -0700 Subject: [PATCH] feat(auth): passwordless /signup via magic-link (drop broken credential form) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential Signup form called signup/checkAvailability/resendVerificationEmail GraphQL ops the cloud Worker never implemented (Unknown type "SignupInput" etc.), so /signup was non-functional. Passwordless makes sign-up and sign-in the SAME action (email → link → account, new or returning), so: - /signup now opens Signin's working magic-link flow (new initialMagicLink prop). - Deleted the dead 740-line Signup.tsx (no longer imported). - Magic-link success screen states you'll stay signed in on this device (keep-me-logged-in: the Worker now mints a long-lived USER token). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/App.tsx | 5 +- packages/web/src/pages/Signin.tsx | 7 +- packages/web/src/pages/Signup.tsx | 741 ------------------------------ 3 files changed, 8 insertions(+), 745 deletions(-) delete mode 100644 packages/web/src/pages/Signup.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 9c8e0c54..221943e1 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -8,7 +8,6 @@ import { Settings } from './pages/Settings'; import { Admin } from './pages/Admin'; import { Backend } from './pages/Backend'; import { Signin } from './pages/Signin'; -import { Signup } from './pages/Signup'; import { ForgotPassword } from './pages/ForgotPassword'; import { ResetPassword } from './pages/ResetPassword'; import { InteractiveGraphVisualization } from './components/InteractiveGraphVisualization'; @@ -86,7 +85,9 @@ function AuthenticatedApp() { } /> } /> - } /> + {/* Passwordless: signing up and signing in are the same action (email → link → + account, new or returning). /signup opens the magic-link flow directly. */} + } /> } /> } /> } /> diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index f0f49caf..823bf719 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -79,7 +79,7 @@ const GET_SYSTEM_SETTINGS = gql` } `; -export function Signin() { +export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolean } = {}) { const navigate = useNavigate(); const { login: setAuthUser } = useAuth(); const [searchParams] = useSearchParams(); @@ -93,7 +93,7 @@ export function Signin() { const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState(null); const [showPassword, setShowPassword] = useState(false); - const [useMagicLink, setUseMagicLink] = useState(false); + const [useMagicLink, setUseMagicLink] = useState(initialMagicLink); const [magicLinkSent, setMagicLinkSent] = useState(false); const [magicLinkLoading, setMagicLinkLoading] = useState(false); const [errors, setErrors] = useState>({}); @@ -582,6 +582,9 @@ export function Signin() {

📂 Don't see it? Check your spam folder after 3 minutes

+

+ ✅ Once you click it you'll stay signed in on this device — no need to log in again +

diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx deleted file mode 100644 index 86e18738..00000000 --- a/packages/web/src/pages/Signup.tsx +++ /dev/null @@ -1,741 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useMutation, gql } from '@apollo/client'; -import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react'; -import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { PasswordRequirements } from '../components/PasswordRequirements'; -import { isValidEmail, getPasswordStrength } from '../utils/validation'; -import { CodeCaptcha } from '../components/CodeCaptcha'; - -const SIGNUP_MUTATION = gql` - mutation Signup($input: SignupInput!) { - signup(input: $input) { - token - user { - id - email - username - name - role - isEmailVerified - } - } - } -`; - -const CHECK_AVAILABILITY = gql` - query CheckAvailability($email: String, $username: String) { - checkAvailability(email: $email, username: $username) { - success - message - } - } -`; - -const RESEND_VERIFICATION_EMAIL = gql` - mutation ResendVerificationEmail($email: String!) { - resendVerificationEmail(email: $email) { - success - message - } - } -`; - -export function Signup() { - const navigate = useNavigate(); - - const [formData, setFormData] = useState({ - email: '', - username: '', - password: '', - confirmPassword: '', - name: '' - }); - const [captchaPayload, setCaptchaPayload] = useState(null); - - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [errors, setErrors] = useState>({}); - const [isChecking, setIsChecking] = useState>({}); - const [availability, setAvailability] = useState>({}); - const [emailValid, setEmailValid] = useState(null); - const [passwordsMatch, setPasswordsMatch] = useState(null); - const [signupComplete, setSignupComplete] = useState(false); - const [resendLoading, setResendLoading] = useState(false); - const [resendMessage, setResendMessage] = useState(''); - const [resendCooldown, setResendCooldown] = useState(0); - const [rateLimitError, setRateLimitError] = useState(null); - const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState(null); - const [oauthConfig, setOauthConfig] = useState<{ - google: { enabled: boolean; configured: boolean }; - github: { enabled: boolean; configured: boolean }; - linkedin: { enabled: boolean; configured: boolean }; - } | null>(null); - - const [signup, { loading }] = useMutation(SIGNUP_MUTATION, { - onCompleted: (data) => { - setSignupComplete(true); - }, - onError: (error) => { - if (error.graphQLErrors?.[0]?.extensions?.rateLimitExceeded) { - const retryAfter = error.graphQLErrors[0].extensions.retryAfter as number; - setRateLimitError(error.message); - setRateLimitRetryAfter(retryAfter); - } else { - setErrors({ submit: error.message }); - } - } - }); - - const [resendVerificationEmail] = useMutation(RESEND_VERIFICATION_EMAIL, { - onCompleted: (data) => { - setResendLoading(false); - if (data.resendVerificationEmail.success) { - setResendMessage('Verification email sent! Check your inbox.'); - } else { - setResendMessage(data.resendVerificationEmail.message || 'Failed to send email. Please try again.'); - } - }, - onError: () => { - setResendLoading(false); - setResendMessage('Failed to send email. Please try again.'); - } - }); - - const checkAvailability = async (field: 'email' | 'username', value: string) => { - if (!value) return; - - setIsChecking({ ...isChecking, [field]: true }); - - try { - const response = await fetch('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: CHECK_AVAILABILITY, - variables: { [field]: value } - }) - }); - - const data = await response.json(); - if (data.data?.checkAvailability) { - setAvailability({ - ...availability, - [field]: data.data.checkAvailability.success - }); - - if (!data.data.checkAvailability.success) { - setErrors({ - ...errors, - [field]: data.data.checkAvailability.message - }); - } else { - const newErrors = { ...errors }; - delete newErrors[field]; - setErrors(newErrors); - } - } - } finally { - setIsChecking({ ...isChecking, [field]: false }); - } - }; - - const validateForm = () => { - const newErrors: Record = {}; - - // Email validation - if (!formData.email) { - newErrors.email = 'Email is required'; - } else if (!isValidEmail(formData.email)) { - newErrors.email = 'Invalid email format'; - } - - // Username validation - if (!formData.username) { - newErrors.username = 'Username is required'; - } else if (formData.username.length < 3) { - newErrors.username = 'Username must be at least 3 characters'; - } else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) { - newErrors.username = 'Username can only contain letters, numbers, _ and -'; - } - - // Name validation - if (!formData.name) { - newErrors.name = 'Name is required'; - } - - // Password validation - if (!formData.password) { - newErrors.password = 'Password is required'; - } else if (formData.password.length < 8) { - newErrors.password = 'Password must be at least 8 characters'; - } - - // Confirm password validation - if (formData.password !== formData.confirmPassword) { - newErrors.confirmPassword = 'Passwords do not match'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) { - return; - } - - await signup({ - variables: { - input: { - email: formData.email, - username: formData.username, - password: formData.password, - name: formData.name, - captchaPayload: captchaPayload - } - } - }); - }; - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData({ ...formData, [name]: value }); - - if (name === 'email') { - if (value.length === 0) { - setEmailValid(null); - } else { - setEmailValid(isValidEmail(value)); - } - } - - if (name === 'password' || name === 'confirmPassword') { - const pwd = name === 'password' ? value : formData.password; - const confirmPwd = name === 'confirmPassword' ? value : formData.confirmPassword; - - if (pwd && confirmPwd) { - setPasswordsMatch(pwd === confirmPwd); - } else { - setPasswordsMatch(null); - } - } - - if (errors[name]) { - const newErrors = { ...errors }; - delete newErrors[name]; - setErrors(newErrors); - } - }; - - const handleBlur = (field: 'email' | 'username') => { - if (formData[field]) { - checkAvailability(field, formData[field]); - } - }; - - const handleResendVerificationEmail = async () => { - setResendLoading(true); - setResendMessage(''); - - await resendVerificationEmail({ - variables: { - email: formData.email - } - }); - setResendCooldown(60); - }; - - useEffect(() => { - const fetchOAuthConfig = async () => { - try { - const apiUrl = import.meta.env.VITE_API_URL || 'https://localhost:4128'; - const response = await fetch(`${apiUrl}/config`); - if (response.ok) { - const config = await response.json(); - if (config.oauth && config.oauth.providers) { - setOauthConfig(config.oauth.providers); - } - } - } catch { - console.debug('OAuth config unavailable; hiding social sign-up.'); - } - }; - fetchOAuthConfig(); - }, []); - - useEffect(() => { - if (resendCooldown > 0) { - const timer = setTimeout(() => setResendCooldown(prev => prev - 1), 1000); - return () => clearTimeout(timer); - } - return undefined; - }, [resendCooldown]); - - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !loading && !Object.values(isChecking).some(checking => checking)) { - const submitEvent = e as unknown as React.FormEvent; - void handleSubmit(submitEvent); - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [formData, loading, isChecking, handleSubmit]); - - const passwordStrength = getPasswordStrength(formData.password); - - return ( -
- {/* Static gradient background - optimized for all browsers */} -
-
- {/* Header */} -
- - GraphDone Logo - GraphDone - -

Create Your Account

-

Join the decentralized management revolution

-
- - {/* Email Verification Screen or Signup Form */} - {signupComplete ? ( -
-
-
- -
-

Check Your Email!

-

- We've sent a verification link to {formData.email} -

-

- Click the link in the email to verify your account and complete registration. The link expires in 24 hours. -

- - - - {resendMessage && ( -

- {resendMessage} -

- )} -
- -
-

- Didn't receive the email? Check your spam folder or click the button above to resend. -

-
- -
- - Back to login - -
-
- ) : ( -
- {/* Social Signup Buttons — only when at least one provider is configured; - otherwise the whole block (and divider) is hidden so no setup hints leak. */} - {(oauthConfig?.google?.configured || oauthConfig?.linkedin?.configured || oauthConfig?.github?.configured) && ( - <> -
- - - - - -
- -
-
-
-
-
- Or sign up with your credentials -
-
- - )} - - {/* Name Field */} -
- - - {errors.name && } -
- - {/* Email Field */} -
- -
- handleBlur('email')} - autoComplete="email" - className={`w-full px-4 py-3 bg-gray-700/50 backdrop-blur-sm border rounded-xl text-gray-100 focus:outline-none focus:ring-2 pr-10 transition-all ${ - emailValid === false - ? 'border-red-500/50 focus:ring-red-500/50' - : emailValid === true - ? 'border-teal-500/50 focus:ring-teal-500/50 focus:border-teal-500/50' - : errors.email - ? 'border-red-500/50 focus:ring-red-500/50' - : 'border-gray-600/50 focus:ring-teal-500/50 focus:border-teal-500/50' - }`} - placeholder="john@example.com" - /> - {isChecking.email ? ( -
-
-
- ) : emailValid !== null ? ( -
- {emailValid ? ( - - ) : ( - - )} -
- ) : availability.email && !isChecking.email ? ( - - ) : null} -
- {errors.email &&

{errors.email}

} -
- - {/* Username Field */} -
- -
- handleBlur('username')} - autoComplete="username" - className={`w-full px-4 py-3 bg-gray-700/50 backdrop-blur-sm border rounded-xl text-gray-100 focus:outline-none focus:ring-2 pr-10 transition-all ${ - errors.username ? 'border-red-500/50 focus:ring-red-500/50' : 'border-gray-600/50 focus:ring-teal-500/50 focus:border-teal-500/50' - }`} - placeholder="johndoe" - /> - {isChecking.username && ( -
-
-
- )} - {availability.username && !isChecking.username && ( - - )} -
- {errors.username &&

{errors.username}

} - {!errors.username && ( -

- - 3-20 characters, letters, numbers, _ and - only -

- )} -
- - {/* Password Field */} -
- -
- - -
- {errors.password &&

{errors.password}

} - - {/* Password Strength Indicator */} - {formData.password && ( -
-
- Password strength: - {passwordStrength.label} -
-
-
-
-
- )} - - -
- - {/* Confirm Password Field */} -
- -
- - {passwordsMatch !== null && formData.confirmPassword && ( -
- {passwordsMatch ? ( - - ) : ( - - )} -
- )} - -
- {errors.confirmPassword &&

{errors.confirmPassword}

} - {passwordsMatch === false && formData.confirmPassword && !errors.confirmPassword && ( -

Passwords do not match

- )} - {passwordsMatch === true && formData.confirmPassword && ( -

Passwords match!

- )} -
- - {/* CAPTCHA */} -
- setCaptchaPayload(code)} - className="w-full" - /> -
- - {/* Rate Limit Error */} - {rateLimitError && ( -
-
- -
-

- 🛡️ Rate Limit Exceeded -

-

- {rateLimitError} -

- {rateLimitRetryAfter && ( -

- Please try again in {Math.ceil(rateLimitRetryAfter / 60)} minute(s). -

- )} -
-
-
- )} - - {/* Submit Error */} - {errors.submit && !rateLimitError && ( -
-

{errors.submit}

-
- )} - - {/* Submit Button */} - - - {/* Terms */} -

- By creating an account, you agree to participate in
- the decentralized graph network and contribute
- to the collective intelligence. -

- - )} - - {!signupComplete && ( - <> - {/* Login Link */} -
-

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

-
- - )} -
- - {/* Insecure-connection warning (top strip, only over HTTP) */} - -
- ); -} \ No newline at end of file