| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useEffect, useState } from 'react' |
| import { |
| User, |
| LogIn, |
| UserPlus, |
| ArrowRight, |
| Eye, |
| EyeOff, |
| Mail, |
| AlertCircle, |
| CheckCircle2, |
| ChevronRight, |
| } from 'lucide-react' |
| import type { AuthUser } from './AuthGate' |
| import type { RecentUser } from './AuthGate' |
| import { resolveFileUrl } from '../resolveFileUrl' |
|
|
| interface AuthScreenProps { |
| backendUrl: string |
| onAuthenticated: (user: AuthUser, token: string) => void |
| |
| logoutMessage?: string |
| |
| recentUsers?: RecentUser[] |
| } |
|
|
| export default function AuthScreen({ |
| backendUrl, |
| onAuthenticated, |
| logoutMessage, |
| recentUsers, |
| }: AuthScreenProps) { |
| const [mode, setMode] = useState<'login' | 'register'>('login') |
| const [username, setUsername] = useState('') |
| const [password, setPassword] = useState('') |
| const [email, setEmail] = useState('') |
| const [displayName, setDisplayName] = useState('') |
| const [showPassword, setShowPassword] = useState(false) |
| const [error, setError] = useState('') |
| const [loading, setLoading] = useState(false) |
| const [showLogoutBanner, setShowLogoutBanner] = useState(!!logoutMessage) |
|
|
| |
| useEffect(() => { |
| if (showLogoutBanner) { |
| const timer = setTimeout(() => setShowLogoutBanner(false), 5000) |
| return () => clearTimeout(timer) |
| } |
| }, [showLogoutBanner]) |
|
|
| const handleSubmit = async (e: React.FormEvent) => { |
| e.preventDefault() |
| setError('') |
| setLoading(true) |
|
|
| try { |
| const endpoint = mode === 'login' ? '/v1/auth/login' : '/v1/auth/register' |
| const body = mode === 'login' |
| ? { username, password } |
| : { username, password, email, display_name: displayName || username } |
|
|
| const res = await fetch(`${backendUrl}${endpoint}`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| credentials: 'include', |
| body: JSON.stringify(body), |
| }) |
|
|
| const data = await res.json() |
|
|
| if (!res.ok) { |
| setError(data.detail || 'Something went wrong') |
| return |
| } |
|
|
| if (data.ok && data.token && data.user) { |
| onAuthenticated(data.user, data.token) |
| } |
| } catch (err) { |
| setError('Could not connect to the backend') |
| } finally { |
| setLoading(false) |
| } |
| } |
|
|
| |
| const handleQuickSelect = (recentUser: RecentUser) => { |
| setMode('login') |
| setUsername(recentUser.username) |
| setPassword('') |
| setError('') |
| setShowLogoutBanner(false) |
| setTimeout(() => { |
| const pwInput = document.getElementById('auth-password-input') |
| pwInput?.focus() |
| }, 50) |
| } |
|
|
| const filteredRecent = (recentUsers || []).filter(u => u.username !== username) |
| const showRecentUsers = mode === 'login' && filteredRecent.length > 0 |
|
|
| |
| const T = { |
| pageBg: '#050506', |
| cardBg: '#141414', |
| inputBg: 'rgba(255, 255, 255, 0.035)', |
| inputBgFocus: 'rgba(255, 255, 255, 0.055)', |
| border: 'rgba(255, 255, 255, 0.08)', |
| borderFocus: 'rgba(255, 255, 255, 0.18)', |
| borderSubtle: 'rgba(255, 255, 255, 0.05)', |
| |
| textPrimary: 'rgba(255, 255, 255, 0.95)', |
| textSecondary: 'rgba(255, 255, 255, 0.70)', |
| textMuted: 'rgba(255, 255, 255, 0.45)', |
| textFaint: 'rgba(255, 255, 255, 0.30)', |
| textPlaceholder: 'rgba(255, 255, 255, 0.22)', |
| |
| blue: '#2563eb', |
| blueHover: '#3b82f6', |
| blueSubtle: 'rgba(37, 99, 235, 0.12)', |
| blueBorder: 'rgba(37, 99, 235, 0.25)', |
| blueText: 'rgba(147, 197, 253, 0.90)', |
| |
| green: 'rgba(34, 197, 94, 0.12)', |
| greenBorder: 'rgba(34, 197, 94, 0.25)', |
| greenText: '#86efac', |
| red: 'rgba(239, 68, 68, 0.10)', |
| redBorder: 'rgba(239, 68, 68, 0.20)', |
| redText: '#fca5a5', |
| |
| recentBg: '#111111', |
| tabBg: 'rgba(255, 255, 255, 0.03)', |
| tabActive: 'rgba(37, 99, 235, 0.15)', |
| |
| icon: 'rgba(255, 255, 255, 0.25)', |
| font: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif', |
| } |
|
|
| const inputStyle: React.CSSProperties = { |
| width: '100%', |
| padding: '11px 12px 11px 38px', |
| borderRadius: 10, |
| border: `1px solid ${T.border}`, |
| background: T.inputBg, |
| color: T.textPrimary, |
| fontSize: 14, |
| outline: 'none', |
| boxSizing: 'border-box', |
| transition: 'border-color 0.2s, background 0.2s', |
| fontFamily: T.font, |
| } |
|
|
| const inputStyleNoIcon: React.CSSProperties = { |
| ...inputStyle, |
| paddingLeft: 12, |
| } |
|
|
| const focusHandlers = { |
| onFocus: (e: React.FocusEvent<HTMLInputElement>) => { |
| e.currentTarget.style.borderColor = T.borderFocus |
| e.currentTarget.style.background = T.inputBgFocus |
| }, |
| onBlur: (e: React.FocusEvent<HTMLInputElement>) => { |
| e.currentTarget.style.borderColor = T.border |
| e.currentTarget.style.background = T.inputBg |
| }, |
| } |
|
|
| return ( |
| <div style={{ |
| minHeight: '100vh', |
| display: 'flex', |
| flexDirection: 'column', |
| alignItems: 'center', |
| justifyContent: 'center', |
| background: T.pageBg, |
| fontFamily: T.font, |
| padding: '20px', |
| }}> |
| {/* Subtle decorative glow — behind card, not competing */} |
| <div style={{ |
| position: 'fixed', |
| top: '35%', |
| left: '50%', |
| transform: 'translate(-50%, -50%)', |
| width: 600, |
| height: 400, |
| background: 'radial-gradient(ellipse, rgba(37, 99, 235, 0.04) 0%, transparent 70%)', |
| pointerEvents: 'none', |
| zIndex: 0, |
| }} /> |
| |
| {/* Logo / Title */} |
| <div style={{ marginBottom: 36, textAlign: 'center', position: 'relative', zIndex: 1 }}> |
| <h1 style={{ |
| fontSize: 30, |
| fontWeight: 700, |
| color: T.textPrimary, |
| margin: 0, |
| letterSpacing: '-0.5px', |
| }}> |
| HomePilot |
| </h1> |
| <p style={{ color: T.textMuted, fontSize: 14, marginTop: 8 }}> |
| Your AI-Powered Creative Studio |
| </p> |
| </div> |
| |
| {/* Logout success banner */} |
| {showLogoutBanner && logoutMessage && ( |
| <div style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: 10, |
| padding: '12px 20px', |
| borderRadius: 12, |
| background: T.green, |
| border: `1px solid ${T.greenBorder}`, |
| marginBottom: 20, |
| fontSize: 14, |
| color: T.greenText, |
| maxWidth: 420, |
| width: '100%', |
| position: 'relative', |
| zIndex: 1, |
| animation: 'fadeIn 300ms ease-out', |
| }}> |
| <CheckCircle2 size={18} style={{ flexShrink: 0 }} /> |
| <span>{logoutMessage}</span> |
| <button |
| type="button" |
| onClick={() => setShowLogoutBanner(false)} |
| style={{ |
| marginLeft: 'auto', |
| background: 'none', |
| border: 'none', |
| color: T.greenText, |
| cursor: 'pointer', |
| padding: 2, |
| fontSize: 18, |
| lineHeight: 1, |
| opacity: 0.6, |
| }} |
| aria-label="Dismiss" |
| > |
| × |
| </button> |
| </div> |
| )} |
| |
| {/* Recent accounts — quick switch */} |
| {showRecentUsers && ( |
| <div style={{ |
| width: '100%', |
| maxWidth: 420, |
| marginBottom: 12, |
| background: T.recentBg, |
| border: `1px solid ${T.borderSubtle}`, |
| borderRadius: 14, |
| overflow: 'hidden', |
| position: 'relative', |
| zIndex: 1, |
| }}> |
| <div style={{ |
| padding: '12px 16px 8px', |
| fontSize: 11, |
| fontWeight: 600, |
| color: T.textFaint, |
| letterSpacing: '0.5px', |
| textTransform: 'uppercase', |
| }}> |
| Recent Accounts |
| </div> |
| {filteredRecent.map((recent) => ( |
| <button |
| key={recent.username} |
| type="button" |
| onClick={() => handleQuickSelect(recent)} |
| style={{ |
| width: '100%', |
| display: 'flex', |
| alignItems: 'center', |
| gap: 12, |
| padding: '10px 16px', |
| border: 'none', |
| background: 'transparent', |
| cursor: 'pointer', |
| transition: 'background 0.15s', |
| textAlign: 'left', |
| }} |
| onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.04)')} |
| onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} |
| > |
| {/* Avatar */} |
| {recent.avatar_url ? ( |
| <img |
| src={resolveFileUrl(recent.avatar_url)} |
| alt="" |
| style={{ |
| width: 36, |
| height: 36, |
| borderRadius: '50%', |
| objectFit: 'cover', |
| border: `2px solid ${T.border}`, |
| }} |
| /> |
| ) : ( |
| <div style={{ |
| width: 36, |
| height: 36, |
| borderRadius: '50%', |
| background: T.blue, |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| color: '#fff', |
| fontSize: 14, |
| fontWeight: 700, |
| flexShrink: 0, |
| }}> |
| {(recent.display_name || recent.username).charAt(0).toUpperCase()} |
| </div> |
| )} |
| <div style={{ flex: 1, minWidth: 0 }}> |
| <div style={{ |
| fontSize: 14, |
| fontWeight: 600, |
| color: T.textPrimary, |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| whiteSpace: 'nowrap', |
| }}> |
| {recent.display_name || recent.username} |
| </div> |
| <div style={{ |
| fontSize: 12, |
| color: T.textMuted, |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| whiteSpace: 'nowrap', |
| }}> |
| @{recent.username} |
| </div> |
| </div> |
| <ChevronRight size={16} style={{ color: T.textFaint, flexShrink: 0 }} /> |
| </button> |
| ))} |
| </div> |
| )} |
| |
| {/* Card — primary interaction surface */} |
| <div style={{ |
| width: '100%', |
| maxWidth: 420, |
| background: T.cardBg, |
| border: `1px solid ${T.border}`, |
| borderRadius: 16, |
| padding: '32px 28px', |
| position: 'relative', |
| zIndex: 1, |
| boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 1px 4px rgba(0, 0, 0, 0.3)', |
| }}> |
| {/* Tab switcher */} |
| <div style={{ |
| display: 'flex', |
| gap: 4, |
| marginBottom: 28, |
| background: T.tabBg, |
| borderRadius: 10, |
| padding: 4, |
| border: `1px solid ${T.borderSubtle}`, |
| }}> |
| {(['login', 'register'] as const).map(m => ( |
| <button |
| key={m} |
| onClick={() => { setMode(m); setError('') }} |
| style={{ |
| flex: 1, |
| padding: '10px 0', |
| borderRadius: 8, |
| border: 'none', |
| fontSize: 13, |
| fontWeight: 600, |
| cursor: 'pointer', |
| transition: 'all 0.2s', |
| background: mode === m ? T.tabActive : 'transparent', |
| color: mode === m ? T.blueText : T.textMuted, |
| }} |
| > |
| {m === 'login' ? ( |
| <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> |
| <LogIn size={14} /> Sign In |
| </span> |
| ) : ( |
| <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> |
| <UserPlus size={14} /> Create Account |
| </span> |
| )} |
| </button> |
| ))} |
| </div> |
| |
| {/* Error */} |
| {error && ( |
| <div style={{ |
| display: 'flex', |
| alignItems: 'center', |
| gap: 8, |
| padding: '10px 14px', |
| borderRadius: 10, |
| background: T.red, |
| border: `1px solid ${T.redBorder}`, |
| marginBottom: 16, |
| fontSize: 13, |
| color: T.redText, |
| }}> |
| <AlertCircle size={14} style={{ flexShrink: 0 }} /> |
| {error} |
| </div> |
| )} |
| |
| <form onSubmit={handleSubmit}> |
| {/* Username */} |
| <div style={{ marginBottom: 18 }}> |
| <label style={{ display: 'block', fontSize: 12, color: T.textMuted, marginBottom: 6, fontWeight: 500 }}> |
| Username |
| </label> |
| <div style={{ position: 'relative' }}> |
| <User size={16} style={{ position: 'absolute', left: 12, top: 12, color: T.icon }} /> |
| <input |
| type="text" |
| value={username} |
| onChange={e => setUsername(e.target.value)} |
| placeholder="Enter username" |
| required |
| autoFocus={!showRecentUsers} |
| minLength={2} |
| maxLength={32} |
| style={inputStyle} |
| {...focusHandlers} |
| /> |
| </div> |
| </div> |
| |
| {/* Display Name (register only) */} |
| {mode === 'register' && ( |
| <div style={{ marginBottom: 18 }}> |
| <label style={{ display: 'block', fontSize: 12, color: T.textMuted, marginBottom: 6, fontWeight: 500 }}> |
| Display Name <span style={{ color: T.textFaint }}>(optional)</span> |
| </label> |
| <input |
| type="text" |
| value={displayName} |
| onChange={e => setDisplayName(e.target.value)} |
| placeholder="How should we call you?" |
| maxLength={64} |
| style={inputStyleNoIcon} |
| {...focusHandlers} |
| /> |
| </div> |
| )} |
| |
| {/* Password */} |
| <div style={{ marginBottom: mode === 'register' ? 18 : 24 }}> |
| <label style={{ display: 'block', fontSize: 12, color: T.textMuted, marginBottom: 6, fontWeight: 500 }}> |
| Password {mode === 'register' && <span style={{ color: T.textFaint }}>(optional — skip for passwordless)</span>} |
| </label> |
| <div style={{ position: 'relative' }}> |
| <input |
| id="auth-password-input" |
| type={showPassword ? 'text' : 'password'} |
| value={password} |
| onChange={e => setPassword(e.target.value)} |
| placeholder={mode === 'register' ? 'Leave empty for no password' : 'Enter password'} |
| maxLength={128} |
| style={{ |
| ...inputStyleNoIcon, |
| paddingRight: 40, |
| }} |
| {...focusHandlers} |
| /> |
| <button |
| type="button" |
| onClick={() => setShowPassword(!showPassword)} |
| style={{ |
| position: 'absolute', |
| right: 8, |
| top: 8, |
| background: 'none', |
| border: 'none', |
| color: T.icon, |
| cursor: 'pointer', |
| padding: 4, |
| }} |
| > |
| {showPassword ? <EyeOff size={16} /> : <Eye size={16} />} |
| </button> |
| </div> |
| </div> |
| |
| {/* Email (register only) */} |
| {mode === 'register' && ( |
| <div style={{ marginBottom: 24 }}> |
| <label style={{ display: 'block', fontSize: 12, color: T.textMuted, marginBottom: 6, fontWeight: 500 }}> |
| Email <span style={{ color: T.textFaint }}>(optional — for password recovery)</span> |
| </label> |
| <div style={{ position: 'relative' }}> |
| <Mail size={16} style={{ position: 'absolute', left: 12, top: 12, color: T.icon }} /> |
| <input |
| type="email" |
| value={email} |
| onChange={e => setEmail(e.target.value)} |
| placeholder="your@email.com" |
| maxLength={256} |
| style={inputStyle} |
| {...focusHandlers} |
| /> |
| </div> |
| </div> |
| )} |
| |
| {/* Submit — single authority blue */} |
| <button |
| type="submit" |
| disabled={loading || !username.trim()} |
| style={{ |
| width: '100%', |
| padding: '12px', |
| borderRadius: 10, |
| border: 'none', |
| background: T.blue, |
| color: '#fff', |
| fontSize: 15, |
| fontWeight: 600, |
| cursor: loading ? 'wait' : 'pointer', |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| gap: 8, |
| transition: 'all 0.15s', |
| opacity: (!username.trim() || loading) ? 0.4 : 1, |
| fontFamily: T.font, |
| letterSpacing: '0.01em', |
| }} |
| onMouseEnter={e => { if (username.trim() && !loading) e.currentTarget.style.background = T.blueHover }} |
| onMouseLeave={e => { e.currentTarget.style.background = T.blue }} |
| > |
| {loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account'} |
| {!loading && <ArrowRight size={16} />} |
| </button> |
| </form> |
| |
| {/* Helpful hint when coming from logout */} |
| {mode === 'login' && logoutMessage && ( |
| <div style={{ |
| marginTop: 16, |
| padding: '10px 14px', |
| borderRadius: 10, |
| background: T.blueSubtle, |
| border: `1px solid ${T.blueBorder}`, |
| fontSize: 12, |
| color: T.textSecondary, |
| textAlign: 'center', |
| lineHeight: 1.5, |
| }}> |
| Sign in with a different account or{' '} |
| <button |
| type="button" |
| onClick={() => { setMode('register'); setError('') }} |
| style={{ |
| background: 'none', |
| border: 'none', |
| color: T.blueText, |
| cursor: 'pointer', |
| fontWeight: 600, |
| fontSize: 12, |
| textDecoration: 'underline', |
| padding: 0, |
| fontFamily: T.font, |
| }} |
| > |
| create a new account |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* Footer */} |
| <p style={{ color: T.textFaint, fontSize: 12, marginTop: 24, position: 'relative', zIndex: 1 }}> |
| Self-hosted · Private · Open Source |
| </p> |
| |
| {/* Keyframe animation for the logout banner */} |
| <style>{` |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(-8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| input:-webkit-autofill, |
| input:-webkit-autofill:hover, |
| input:-webkit-autofill:focus { |
| -webkit-box-shadow: 0 0 0 1000px #141414 inset !important; |
| -webkit-text-fill-color: rgba(255, 255, 255, 0.95) !important; |
| caret-color: rgba(255, 255, 255, 0.95) !important; |
| transition: background-color 5000s ease-in-out 0s; |
| } |
| |
| input::placeholder { |
| color: rgba(255, 255, 255, 0.22) !important; |
| } |
| `}</style> |
| </div> |
| ) |
| } |
|
|