Initial Commit
This commit is contained in:
60
frontend/src/pages/ChangePassword.jsx
Normal file
60
frontend/src/pages/ChangePassword.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function ChangePassword() {
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setMustChangePassword } = useAuth();
|
||||
const toast = useToast();
|
||||
const nav = useNavigate();
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (next !== confirm) return toast('Passwords do not match', 'error');
|
||||
if (next.length < 8) return toast('Password must be at least 8 characters', 'error');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.changePassword({ currentPassword: current, newPassword: next });
|
||||
setMustChangePassword(false);
|
||||
toast('Password changed!', 'success');
|
||||
nav('/');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--background)', padding: 20 }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: 420 }}>
|
||||
<h2 style={{ marginBottom: 8, fontSize: 22, fontWeight: 700 }}>Change Password</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||
You must set a new password before continuing.
|
||||
</p>
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
|
||||
<input className="input" type="password" value={current} onChange={e => setCurrent(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
|
||||
<input className="input" type="password" value={next} onChange={e => setNext(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
|
||||
<input className="input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Set New Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
frontend/src/pages/Chat.css
Normal file
12
frontend/src/pages/Chat.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.chat-layout {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
171
frontend/src/pages/Chat.jsx
Normal file
171
frontend/src/pages/Chat.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Sidebar from '../components/Sidebar.jsx';
|
||||
import ChatWindow from '../components/ChatWindow.jsx';
|
||||
import ProfileModal from '../components/ProfileModal.jsx';
|
||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||
import SettingsModal from '../components/SettingsModal.jsx';
|
||||
import NewChatModal from '../components/NewChatModal.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Set());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
if (!mobile) setShowSidebar(true);
|
||||
};
|
||||
window.addEventListener('resize', handle);
|
||||
return () => window.removeEventListener('resize', handle);
|
||||
}, []);
|
||||
|
||||
const loadGroups = useCallback(() => {
|
||||
api.getGroups().then(setGroups).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadGroups(); }, [loadGroups]);
|
||||
|
||||
// Register push subscription
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
(async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (existing) {
|
||||
// Re-register to keep subscription fresh
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||
body: JSON.stringify(existing.toJSON())
|
||||
});
|
||||
return;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
});
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||
body: JSON.stringify(sub.toJSON())
|
||||
});
|
||||
console.log('[Push] Subscribed');
|
||||
} catch (e) {
|
||||
console.warn('[Push] Subscription failed:', e.message);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Socket message events to update group previews
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNewMsg = (msg) => {
|
||||
setGroups(prev => {
|
||||
const updateGroup = (g) => g.id === msg.group_id
|
||||
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at }
|
||||
: g;
|
||||
return {
|
||||
publicGroups: prev.publicGroups.map(updateGroup),
|
||||
privateGroups: prev.privateGroups.map(updateGroup),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotification = (notif) => {
|
||||
if (notif.type === 'private_message') {
|
||||
// Show unread dot on private group in sidebar (if not currently viewing it)
|
||||
setUnreadGroups(prev => {
|
||||
if (notif.groupId === activeGroupId) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(notif.groupId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNewMsg);
|
||||
socket.on('notification:new', handleNotification);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMsg);
|
||||
socket.off('notification:new', handleNotification);
|
||||
};
|
||||
}, [socket, toast]);
|
||||
|
||||
const selectGroup = (id) => {
|
||||
setActiveGroupId(id);
|
||||
if (isMobile) setShowSidebar(false);
|
||||
// Clear notifications for this group
|
||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||
setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; });
|
||||
};
|
||||
|
||||
const activeGroup = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
].find(g => g.id === activeGroupId);
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{(!isMobile || showSidebar) && (
|
||||
<Sidebar
|
||||
groups={groups}
|
||||
activeGroupId={activeGroupId}
|
||||
onSelectGroup={selectGroup}
|
||||
notifications={notifications}
|
||||
unreadGroups={unreadGroups}
|
||||
onNewChat={() => setModal('newchat')}
|
||||
onProfile={() => setModal('profile')}
|
||||
onUsers={() => setModal('users')}
|
||||
onSettings={() => setModal('settings')}
|
||||
onGroupsUpdated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!isMobile || !showSidebar) && (
|
||||
<ChatWindow
|
||||
group={activeGroup}
|
||||
onBack={isMobile ? () => setShowSidebar(true) : null}
|
||||
onGroupUpdated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/pages/Login.css
Normal file
106
frontend/src/pages/Login.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e8f0fe 0%, #f1f3f4 50%, #e8f0fe 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.default-logo svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #202124;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-logo p {
|
||||
color: #5f6368;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remember-me input[type="checkbox"] {
|
||||
accent-color: #1a73e8;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #9aa0a6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.support-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.support-link:hover {
|
||||
color: var(--primary-dark, #1557b0);
|
||||
}
|
||||
129
frontend/src/pages/Login.jsx
Normal file
129
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import './Login.css';
|
||||
import SupportModal from '../components/SupportModal.jsx';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSupport, setShowSupport] = useState(false);
|
||||
const [settings, setSettings] = useState({});
|
||||
const { login } = useAuth();
|
||||
const toast = useToast();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await login(email, password, rememberMe);
|
||||
if (data.mustChangePassword) {
|
||||
nav('/change-password');
|
||||
} else {
|
||||
nav('/');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message === 'suspended') {
|
||||
toast(`Your account has been suspended. Contact: ${err.adminEmail || 'your admin'} for assistance.`, 'error', 8000);
|
||||
} else {
|
||||
toast(err.message || 'Login failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle suspension error from API directly
|
||||
const handleLoginError = async (email, password, rememberMe) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, rememberMe })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.error === 'suspended') {
|
||||
toast(`Your account has been suspended. Contact ${data.adminEmail || 'your administrator'} for assistance.`, 'error', 8000);
|
||||
} else {
|
||||
toast(data.error || 'Login failed', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Success handled by login function above
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const appName = settings.app_name || 'TeamChat';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="logo-img" />
|
||||
) : (
|
||||
<div className="default-logo">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h1>{appName}</h1>
|
||||
<p>Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner" style={{ marginBottom: 16 }}>
|
||||
<span>⚠️</span>
|
||||
<span><strong>PW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable PW_RESET in your environment to stop this behavior.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="field">
|
||||
<label>Email</label>
|
||||
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Password</label>
|
||||
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<label className="remember-me">
|
||||
<input type="checkbox" checked={rememberMe} onChange={e => setRememberMe(e.target.checked)} />
|
||||
<span>Remember me</span>
|
||||
</label>
|
||||
|
||||
<button className="btn btn-primary w-full" type="submit" disabled={loading}>
|
||||
{loading ? <span className="spinner" style={{ width: 18, height: 18 }} /> : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<button className="support-link" onClick={() => setShowSupport(true)}>
|
||||
Need help? Contact Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSupport && <SupportModal onClose={() => setShowSupport(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user