Initial Commit

This commit is contained in:
2026-03-06 11:54:19 -05:00
parent ee68c4704f
commit 4517746692
36 changed files with 4262 additions and 0 deletions

View 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>
);
}

View 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
View 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>
);
}

View 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);
}

View 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>
);
}