Add admin-only login mode, remove noVNC, simplify architecture

This commit is contained in:
Khoa.vo 2025-12-19 19:46:25 +07:00
parent dc3caed430
commit 5d1895e400
7 changed files with 489 additions and 229 deletions

View file

@ -9,17 +9,13 @@ RUN npm run build
# Production stage
FROM python:3.11-slim
# Install system dependencies
# Install system dependencies (minimal - no VNC needed)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
curl \
gnupg \
ca-certificates \
ffmpeg \
# VNC & Display dependencies
tigervnc-standalone-server \
tigervnc-common \
openbox \
# Playwright dependencies
libnss3 \
libnspr4 \
@ -39,22 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libatspi2.0-0 \
libgtk-3-0 \
fonts-liberation \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Install noVNC
RUN mkdir -p /opt/novnc \
&& wget -qO- https://github.com/novnc/noVNC/archive/v1.4.0.tar.gz | tar xz -C /opt/novnc --strip-components=1 \
&& mkdir -p /opt/novnc/utils/websockify \
&& wget -qO- https://github.com/novnc/websockify/archive/v0.11.0.tar.gz | tar xz -C /opt/novnc/utils/websockify --strip-components=1 \
&& ln -s /opt/novnc/vnc.html /opt/novnc/index.html
WORKDIR /app
# Copy backend requirements and install
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
# Install Playwright browsers (headless mode only)
RUN mkdir -p /root/.cache/ms-playwright && \
for i in 1 2 3; do \
playwright install chromium && break || \
@ -67,26 +56,22 @@ COPY backend/ ./backend/
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Create cache directory
RUN mkdir -p /app/cache && chmod 777 /app/cache
# Copy supervisor config
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Create cache and session directories
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV CACHE_DIR=/app/cache
ENV DISPLAY=:99
# Set working directory to backend for correct imports
WORKDIR /app/backend
# Expose ports (8002 = app, 6080 = noVNC)
EXPOSE 8002 6080
# Expose port (8002 = app)
EXPOSE 8002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Start services
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# Start FastAPI directly (no supervisor needed)
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]

View file

@ -146,15 +146,39 @@ purestream/
└── README.md
```
## 🔐 Authentication
## 🔐 Authentication (Admin Setup)
PureStream uses your TikTok session to fetch content. On first launch:
PureStream uses your TikTok session cookies. Once configured, users can access the feed without logging in.
1. Click **"Login with TikTok"**
2. A browser window opens - log in to TikTok normally
3. Your session is saved locally for future use
### First-Time Setup
> **Note**: Your credentials are stored locally and never sent to any external server.
1. **Set your admin password** in `docker-compose.yml`:
```yaml
environment:
- ADMIN_PASSWORD=your_secure_password
```
2. **Access the admin page**: `http://your-server-ip:8002/admin`
3. **Get your TikTok cookies**:
- Install [Cookie-Editor](https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) browser extension
- Go to [tiktok.com](https://www.tiktok.com) and login
- Click Cookie-Editor icon → **Export** → **Copy**
4. **Paste cookies** in the admin page and click **Save**
5. Your app is now ready! Access `http://your-server-ip:8002/` on any device.
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ADMIN_PASSWORD` | `admin123` | Password for admin page |
| `CACHE_DIR` | `/app/cache` | Video cache directory |
| `MAX_CACHE_SIZE_MB` | `500` | Maximum cache size |
| `CACHE_TTL_HOURS` | `24` | Cache expiration |
> **Security Note**: Cookies are stored locally in the `session/` volume. Anyone with admin access can view/update them.
## 🐛 Troubleshooting

View file

@ -142,3 +142,99 @@ async def stop_vnc_login():
"""Stop the VNC login browser."""
result = await PlaywrightManager.stop_vnc_login()
return result
# ========== ADMIN ENDPOINTS ==========
# Admin password from environment variable
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
_admin_sessions: set = set()
class AdminLoginRequest(BaseModel):
password: str
class AdminCookiesRequest(BaseModel):
cookies: list | dict # Accept both array (Cookie-Editor) or object format
@router.post("/admin-login")
async def admin_login(request: AdminLoginRequest):
"""Login as admin with password."""
if request.password == ADMIN_PASSWORD:
import secrets
session_token = secrets.token_urlsafe(32)
_admin_sessions.add(session_token)
return {"status": "success", "token": session_token}
raise HTTPException(status_code=401, detail="Invalid password")
@router.get("/admin-check")
async def admin_check(token: str = ""):
"""Check if admin session is valid."""
return {"valid": token in _admin_sessions}
@router.post("/admin-update-cookies")
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
"""Update cookies (admin only)."""
if token not in _admin_sessions:
raise HTTPException(status_code=401, detail="Unauthorized")
try:
cookies = request.cookies
# Normalize cookies to dict format
if isinstance(cookies, list):
# Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
cookie_dict = {}
for c in cookies:
if isinstance(c, dict) and "name" in c and "value" in c:
cookie_dict[c["name"]] = c["value"]
cookies = cookie_dict
if not isinstance(cookies, dict):
raise HTTPException(status_code=400, detail="Invalid cookies format")
if "sessionid" not in cookies:
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
# Save cookies
PlaywrightManager.save_credentials(cookies, None)
return {
"status": "success",
"message": f"Saved {len(cookies)} cookies",
"cookie_count": len(cookies)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/admin-get-cookies")
async def admin_get_cookies(token: str = ""):
"""Get current cookies (admin only, for display)."""
if token not in _admin_sessions:
raise HTTPException(status_code=401, detail="Unauthorized")
if os.path.exists(COOKIES_FILE):
try:
with open(COOKIES_FILE, "r") as f:
cookies = json.load(f)
# Mask sensitive values for display
masked = {}
for key, value in cookies.items():
if key == "sessionid":
masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
else:
masked[key] = value[:20] + "..." if len(str(value)) > 20 else value
return {"cookies": masked, "raw_count": len(cookies)}
except:
pass
return {"cookies": {}, "raw_count": 0}

View file

@ -7,7 +7,6 @@ services:
restart: unless-stopped
ports:
- "8002:8002"
- "6080:6080"
volumes:
- ./cache:/app/cache
- ./session:/app/backend/session
@ -16,6 +15,7 @@ services:
- CACHE_DIR=/app/cache
- MAX_CACHE_SIZE_MB=500
- CACHE_TTL_HOURS=24
- ADMIN_PASSWORD=admin123 # Change this to your secure password
shm_size: '2gb'
# IMPORTANT: You must link the service to the network
networks:

View file

@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Login } from './pages/Login';
import { Admin } from './pages/Admin';
import { useAuthStore } from './store/authStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@ -41,6 +42,7 @@ function App() {
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/admin" element={<Admin />} />
<Route
path="/"
element={

View file

@ -0,0 +1,284 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { API_BASE_URL } from '../config';
export const Admin: React.FC = () => {
const [password, setPassword] = useState('');
const [adminToken, setAdminToken] = useState<string | null>(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [cookiesJson, setCookiesJson] = useState('');
const [currentCookies, setCurrentCookies] = useState<Record<string, string>>({});
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; cookie_count: number } | null>(null);
// Check for stored admin token on mount
useEffect(() => {
const stored = localStorage.getItem('admin_token');
if (stored) {
verifyToken(stored);
}
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const res = await axios.get(`${API_BASE_URL}/auth/status`);
setAuthStatus(res.data);
} catch {
setAuthStatus({ authenticated: false, cookie_count: 0 });
}
};
const verifyToken = async (token: string) => {
try {
const res = await axios.get(`${API_BASE_URL}/auth/admin-check?token=${token}`);
if (res.data.valid) {
setAdminToken(token);
loadCurrentCookies(token);
} else {
localStorage.removeItem('admin_token');
}
} catch {
localStorage.removeItem('admin_token');
}
};
const loadCurrentCookies = async (token: string) => {
try {
const res = await axios.get(`${API_BASE_URL}/auth/admin-get-cookies?token=${token}`);
setCurrentCookies(res.data.cookies || {});
} catch {
// Ignore
}
};
const handleLogin = async () => {
if (!password.trim()) return;
setIsLoading(true);
setError('');
try {
const res = await axios.post(`${API_BASE_URL}/auth/admin-login`, { password });
if (res.data.status === 'success') {
const token = res.data.token;
setAdminToken(token);
localStorage.setItem('admin_token', token);
loadCurrentCookies(token);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed');
} finally {
setIsLoading(false);
}
};
const handleSaveCookies = async () => {
if (!cookiesJson.trim() || !adminToken) return;
setIsLoading(true);
setError('');
setSuccess('');
try {
const parsed = JSON.parse(cookiesJson);
const res = await axios.post(
`${API_BASE_URL}/auth/admin-update-cookies?token=${adminToken}`,
{ cookies: parsed }
);
if (res.data.status === 'success') {
setSuccess(`✓ Saved ${res.data.cookie_count} cookies successfully!`);
setCookiesJson('');
loadCurrentCookies(adminToken);
checkAuthStatus();
}
} catch (err: any) {
if (err.message?.includes('JSON')) {
setError('Invalid JSON format. Please paste valid cookies.');
} else {
setError(err.response?.data?.detail || 'Failed to save cookies');
}
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
setAdminToken(null);
localStorage.removeItem('admin_token');
setPassword('');
};
// Not logged in as admin - show password form
if (!adminToken) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center p-6">
<div className="max-w-sm w-full">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Admin Access</h1>
<p className="text-gray-500 text-sm">Enter password to manage cookies</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center">
{error}
</div>
)}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
placeholder="Admin password"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-cyan-500/50 mb-4"
autoFocus
/>
<button
onClick={handleLogin}
disabled={isLoading || !password.trim()}
className={`w-full py-3 rounded-xl font-semibold transition-all ${password.trim() && !isLoading
? 'bg-gradient-to-r from-cyan-500 to-pink-500 text-white'
: 'bg-white/10 text-white/40 cursor-not-allowed'
}`}
>
{isLoading ? 'Verifying...' : 'Login'}
</button>
<a
href="/"
className="block text-center text-white/40 text-sm mt-6 hover:text-white/60"
>
Back to App
</a>
</div>
</div>
);
}
// Logged in as admin - show cookie management
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Cookie Manager</h1>
<p className="text-gray-500 text-sm">Update TikTok session cookies</p>
</div>
<div className="flex gap-3">
<a
href="/"
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors"
>
App
</a>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 text-sm transition-colors"
>
Logout
</button>
</div>
</div>
{/* Status Card */}
<div className={`p-4 rounded-xl mb-6 ${authStatus?.authenticated
? 'bg-green-500/10 border border-green-500/20'
: 'bg-yellow-500/10 border border-yellow-500/20'
}`}>
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${authStatus?.authenticated ? 'bg-green-500' : 'bg-yellow-500'
}`} />
<span className={authStatus?.authenticated ? 'text-green-400' : 'text-yellow-400'}>
{authStatus?.authenticated
? `Authenticated (${authStatus.cookie_count} cookies)`
: 'Not configured - paste cookies below'}
</span>
</div>
</div>
{/* Current Cookies */}
{Object.keys(currentCookies).length > 0 && (
<div className="bg-white/5 rounded-xl p-4 mb-6">
<h3 className="text-white/60 text-sm font-medium mb-3">Current Cookies (masked)</h3>
<div className="space-y-2">
{Object.entries(currentCookies).map(([key, value]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-cyan-400 font-mono">{key}:</span>
<span className="text-white/50 font-mono truncate">{value}</span>
</div>
))}
</div>
</div>
)}
{/* Messages */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-green-400 text-sm">
{success}
</div>
)}
{/* Cookie Input */}
<div className="bg-white/5 rounded-xl p-4 mb-4">
<h3 className="text-white font-medium mb-2">Paste New Cookies</h3>
<p className="text-gray-500 text-xs mb-3">
Export cookies from <a href="https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm" target="_blank" className="text-cyan-400 hover:underline">Cookie-Editor</a> extension while logged into TikTok
</p>
<textarea
value={cookiesJson}
onChange={(e) => setCookiesJson(e.target.value)}
placeholder='[{"name": "sessionid", "value": "..."},...]'
className="w-full h-40 bg-black/50 border border-white/10 rounded-lg p-3 text-white font-mono text-sm focus:outline-none focus:border-cyan-500/50 resize-none"
/>
</div>
<button
onClick={handleSaveCookies}
disabled={isLoading || !cookiesJson.trim()}
className={`w-full py-3 rounded-xl font-semibold transition-all ${cookiesJson.trim() && !isLoading
? 'bg-gradient-to-r from-cyan-500 to-pink-500 text-white'
: 'bg-white/10 text-white/40 cursor-not-allowed'
}`}
>
{isLoading ? 'Saving...' : 'Save Cookies'}
</button>
{/* Instructions */}
<div className="mt-8 p-4 bg-white/5 rounded-xl">
<h3 className="text-white font-medium mb-3">📋 How to Get Cookies</h3>
<ol className="space-y-2 text-gray-400 text-sm">
<li className="flex gap-2">
<span className="text-cyan-400">1.</span>
Install Cookie-Editor browser extension
</li>
<li className="flex gap-2">
<span className="text-cyan-400">2.</span>
Go to tiktok.com and login to your account
</li>
<li className="flex gap-2">
<span className="text-cyan-400">3.</span>
Click Cookie-Editor icon Export Copy
</li>
<li className="flex gap-2">
<span className="text-cyan-400">4.</span>
Paste the JSON above and click Save
</li>
</ol>
</div>
</div>
</div>
);
};

View file

@ -1,237 +1,106 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { API_BASE_URL } from '../config';
export const Login: React.FC = () => {
const [sessionId, setSessionId] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showVnc, setShowVnc] = useState(false);
const [vncStatus, setVncStatus] = useState('');
const pollIntervalRef = useRef<number | null>(null);
const [isChecking, setIsChecking] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const navigate = useNavigate();
// Check if already authenticated (ssl login check)
useEffect(() => {
checkAuth();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
const checkAuth = async () => {
try {
const res = await axios.get(`${API_BASE_URL}/auth/status`);
if (res.data.authenticated) {
navigate('/');
}
} catch (err) {
// Not authenticated
}
};
const getVncUrl = () => {
const host = window.location.hostname;
// autoconnect=true, resize=remote (server resizing) for full screen mobile
return `http://${host}:6080/vnc.html?autoconnect=true&resize=remote&quality=9`;
};
const handleVncLogin = async () => {
setError('');
setIsLoading(true);
setVncStatus('Initializing secure browser...');
try {
// Start the VNC session (SSL Login flow)
const res = await axios.post(`${API_BASE_URL}/auth/start-vnc`);
if (res.data.status === 'started') {
setShowVnc(true);
setIsLoading(false);
setVncStatus('Waiting for login...');
// Poll for completion
pollIntervalRef.current = window.setInterval(async () => {
try {
const checkRes = await axios.get(`${API_BASE_URL}/auth/check-vnc`);
if (checkRes.data.logged_in) {
clearInterval(pollIntervalRef.current!);
setVncStatus('Login successful! Redirecting...');
setTimeout(() => navigate('/'), 1000);
}
} catch (err) {
console.error('VNC check error:', err);
}
}, 2000);
} else {
setError(res.data.message || 'Failed to start browser');
setIsLoading(false);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to start login session');
setIsLoading(false);
}
};
const handleCancelVnc = async () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
try {
await axios.post(`${API_BASE_URL}/auth/stop-vnc`);
} catch (err) {
console.error('Failed to stop VNC:', err);
}
setShowVnc(false);
setVncStatus('');
};
const handleManualLogin = async () => {
if (!sessionId.trim()) return;
setError('');
setIsLoading(true);
try {
const res = await axios.post(`${API_BASE_URL}/auth/credentials`, {
credentials: {
http: {
cookies: { sessionid: sessionId.trim() }
}
}
});
if (res.data.status === 'success') {
// Authenticated - redirect to feed
setIsAuthenticated(true);
navigate('/');
} else {
setError('Invalid session ID.');
setIsAuthenticated(false);
}
} catch (err: any) {
setError('Connection failed.');
} catch (err) {
setIsAuthenticated(false);
} finally {
setIsLoading(false);
setIsChecking(false);
}
};
// Full Screen VNC View
if (showVnc) {
// Loading state
if (isChecking) {
return (
<div className="fixed inset-0 z-50 bg-black">
{/* Floating Control Bar */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 bg-gray-900/90 backdrop-blur-md border border-white/10 rounded-full px-6 py-3 flex items-center gap-6 shadow-2xl">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-white text-sm font-medium">Secure Browser Active</span>
</div>
<div className="h-4 w-px bg-white/20" />
<button
onClick={handleCancelVnc}
className="text-red-400 hover:text-red-300 text-sm font-medium transition-colors"
>
Cancel Login
</button>
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/20 border-t-cyan-400 rounded-full animate-spin" />
<span className="text-white/60 text-sm">Checking session...</span>
</div>
{/* Status Toast */}
{vncStatus && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 bg-black/80 backdrop-blur text-white/90 px-4 py-2 rounded-lg text-sm border border-white/10">
{vncStatus}
</div>
)}
{/* Full Screen Iframe */}
<iframe
src={getVncUrl()}
className="w-full h-full border-0"
style={{ width: '100vw', height: '100vh' }}
title="TikTok Secure Login"
/>
</div>
);
}
// Already authenticated - this shouldn't show (redirect happens), but just in case
if (isAuthenticated) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
<p className="text-white mb-4">Already logged in!</p>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-full text-white font-medium"
>
Go to Feed
</button>
</div>
</div>
);
}
// Not authenticated - show "not configured" message
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col">
<div className="flex-shrink-0 pt-10 pb-6 px-6 text-center">
<div className="relative inline-block mb-3">
<div className="w-14 h-14 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
<div className="relative w-14 h-14 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
<svg className="w-7 h-7 text-white" viewBox="0 0 24 24" fill="currentColor">
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex items-center justify-center p-6">
<div className="max-w-sm w-full text-center">
{/* Logo */}
<div className="relative inline-block mb-6">
<div className="w-20 h-20 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
<div className="relative w-20 h-20 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
<svg className="w-10 h-10 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
</div>
<h1 className="text-xl font-bold text-white mb-0.5">PureStream</h1>
<p className="text-gray-500 text-xs">Ad-free TikTok viewing</p>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-8">
<div className="max-w-sm mx-auto">
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center">
{error}
</div>
)}
<h1 className="text-2xl font-bold text-white mb-2">PureStream</h1>
<p className="text-gray-500 text-sm mb-8">Ad-free TikTok viewing</p>
<button
onClick={handleVncLogin}
disabled={isLoading}
className={`w-full py-4 rounded-2xl font-bold text-base flex items-center justify-center gap-3 transition-all ${isLoading
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-pink-500 to-orange-500 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Starting...
</>
) : (
<>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64c.32 0 .6.05.88.13V9.4c-.3-.04-.6-.05-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
SSL Login with TikTok
</>
)}
</button>
<p className="text-gray-600 text-xs text-center mt-3">
Launch secure browser to login automatically
{/* Not Configured Message */}
<div className="bg-white/5 rounded-2xl p-6 mb-6">
<div className="w-12 h-12 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-yellow-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
</div>
<h2 className="text-white font-medium mb-2">Not Configured</h2>
<p className="text-gray-400 text-sm">
This app needs TikTok cookies to work. Please contact the administrator.
</p>
<div className="flex items-center gap-3 my-8">
<div className="flex-1 h-px bg-white/10" />
<span className="text-gray-600 text-xs">or enter manually</span>
<div className="flex-1 h-px bg-white/10" />
</div>
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-500 text-xs mb-3">
Paste your TikTok <code className="text-pink-400 bg-pink-500/20 px-1 rounded">sessionid</code> cookie:
</p>
<input
type="text"
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
placeholder="Paste sessionid..."
className="w-full bg-black border border-white/10 rounded-lg p-3 text-white text-sm font-mono focus:outline-none focus:border-pink-500/50 placeholder:text-gray-600 mb-3"
disabled={isLoading}
/>
<button
onClick={handleManualLogin}
disabled={!sessionId.trim() || isLoading}
className={`w-full py-2.5 rounded-lg font-medium text-sm transition-all ${sessionId.trim() && !isLoading
? 'bg-white/10 hover:bg-white/20 text-white'
: 'bg-white/5 text-gray-600 cursor-not-allowed'
}`}
>
Connect
</button>
</div>
</div>
{/* Admin Link (subtle) */}
<a
href="/admin"
className="text-white/30 text-xs hover:text-white/50 transition-colors"
>
Admin Access
</a>
</div>
</div>
);