Session 2026-05-30: tasks/projects unification, code consolidation, file renames

- Unified Tasks page: 3 role render blocks → 1, normalized row shape, single renderRow/sort/tabs
- Fixed role scoping: external = all tasks in member projects, client = all tasks in company projects
- Fixed client bug: was scoped by submitted_by, now company→project→tasks
- Aligned dashboard client scope to match tasks page
- Hot tasks dashboard: now filters to active statuses only (not completed/invoiced/paid)
- Removed Projects.jsx (dead), fixed /project/ → /projects/ links
- Renamed all page files to match folder path (Team*, External*, Client* prefixes)
- Renamed: RequestsPage→Tasks, Settings→Profile, CompaniesPage→Companies, ProjectDetailPage→ProjectDetail
- Dropped dead fetches from Tasks page (invoices, invoice_items, subcontractor_invoice_items)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-30 10:19:23 -04:00
parent 6fe7ab1059
commit 13ef1f4ded
30 changed files with 1177 additions and 1258 deletions
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
function emptyForm() {
return {
service_name: '',
service_url: '',
username: '',
password: '',
notes: '',
};
}
function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
}
function formatDate(value) {
if (!value) return 'Unknown';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
return `https://${raw}`;
}
async function invokeCrypto(action, payload) {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
const response = await fetch('/api/fourge-passwords-crypto', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ action, ...payload }),
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || `Vault operation failed (${response.status})`);
if (data?.error) throw new Error(data.error);
return data;
}
export default function FourgePasswords() {
const { currentUser } = useAuth();
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState('');
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [revealed, setRevealed] = useState({});
async function loadEntries() {
setLoading(true);
const { data, error } = await supabase
.from('fourge_passwords')
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.order('service_name', { ascending: true });
if (error) {
setStatus(`Failed to load passwords: ${error.message}`);
setEntries([]);
setLoading(false);
return;
}
setEntries(sortEntries(data || []));
setLoading(false);
setStatus('');
}
useEffect(() => {
loadEntries();
}, []);
function beginCreate() {
setEditingId('new');
setForm(emptyForm());
setStatus('');
}
function beginEdit(entry) {
setEditingId(entry.id);
setForm({
service_name: entry.service_name || '',
service_url: entry.service_url || '',
username: entry.username || '',
password: '',
notes: entry.notes || '',
});
setStatus('');
}
function cancelEdit() {
setEditingId(null);
setForm(emptyForm());
}
async function handleSave(event) {
event.preventDefault();
if (!form.service_name.trim() || !form.username.trim()) {
setStatus('Service and username are required.');
return;
}
if (editingId === 'new' && !form.password) {
setStatus('Password is required for new entries.');
return;
}
setSaving(true);
setStatus('');
try {
let encryptedPayload = null;
if (form.password) {
encryptedPayload = await invokeCrypto('encrypt', { plaintext: form.password });
}
const payload = {
service_name: form.service_name.trim(),
service_url: form.service_url.trim(),
username: form.username.trim(),
notes: form.notes.trim(),
updated_at: new Date().toISOString(),
};
if (encryptedPayload) {
payload.encrypted_password = encryptedPayload.ciphertext;
payload.password_iv = encryptedPayload.iv;
}
if (editingId === 'new') {
payload.created_by = currentUser?.id || null;
const { data, error } = await supabase
.from('fourge_passwords')
.insert(payload)
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.single();
if (error) throw error;
setEntries(prev => sortEntries([...prev, data]));
setStatus('Password saved.');
} else {
const { data, error } = await supabase
.from('fourge_passwords')
.update(payload)
.eq('id', editingId)
.select('id, service_name, service_url, username, notes, created_at, updated_at, created_by')
.single();
if (error) throw error;
setEntries(prev => sortEntries(prev.map(entry => entry.id === editingId ? data : entry)));
setStatus('Password updated.');
}
cancelEdit();
} catch (error) {
setStatus(`Save failed: ${error.message}`);
} finally {
setSaving(false);
}
}
async function handleDelete(entry) {
if (!window.confirm(`Delete the password entry for "${entry.service_name}"?`)) return;
setStatus('');
const { error } = await supabase.from('fourge_passwords').delete().eq('id', entry.id);
if (error) {
setStatus(`Delete failed: ${error.message}`);
return;
}
setEntries(prev => prev.filter(item => item.id !== entry.id));
setRevealed(prev => {
const next = { ...prev };
delete next[entry.id];
return next;
});
setStatus('Password deleted.');
}
async function handleReveal(entry) {
if (revealed[entry.id]) {
setRevealed(prev => {
const next = { ...prev };
delete next[entry.id];
return next;
});
return;
}
setStatus('');
try {
const { data, error } = await supabase
.from('fourge_passwords')
.select('encrypted_password, password_iv')
.eq('id', entry.id)
.single();
if (error) throw error;
const decrypted = await invokeCrypto('decrypt', {
ciphertext: data.encrypted_password,
iv: data.password_iv,
});
setRevealed(prev => ({ ...prev, [entry.id]: decrypted.plaintext }));
} catch (error) {
setStatus(`Reveal failed: ${error.message}`);
}
}
async function handleCopy(entry) {
try {
let password = revealed[entry.id];
if (!password) {
const { data, error } = await supabase
.from('fourge_passwords')
.select('encrypted_password, password_iv')
.eq('id', entry.id)
.single();
if (error) throw error;
const decrypted = await invokeCrypto('decrypt', {
ciphertext: data.encrypted_password,
iv: data.password_iv,
});
password = decrypted.plaintext;
setRevealed(prev => ({ ...prev, [entry.id]: password }));
}
await navigator.clipboard.writeText(password);
setStatus(`Copied password for ${entry.service_name}.`);
} catch (error) {
setStatus(`Copy failed: ${error.message}`);
}
}
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Fourge Passwords</div>
<div className="page-subtitle">Encrypted team-only vault entries for services, logins, and internal access.</div>
</div>
<button className="btn btn-primary btn-sm" onClick={beginCreate}>
+ New Password
</button>
</div>
{(editingId || status) && (
<div className="card" style={{ marginBottom: 20 }}>
{editingId && (
<form onSubmit={handleSave} style={{ display: 'grid', gap: 14 }}>
<div className="card-title">{editingId === 'new' ? 'Add Password' : 'Edit Password'}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<input className="input" placeholder="Service name" value={form.service_name} onChange={(e) => setForm(prev => ({ ...prev, service_name: e.target.value }))} />
<input className="input" placeholder="Service URL" value={form.service_url} onChange={(e) => setForm(prev => ({ ...prev, service_url: e.target.value }))} />
<input className="input" placeholder="Username / email" value={form.username} onChange={(e) => setForm(prev => ({ ...prev, username: e.target.value }))} />
<input className="input" placeholder={editingId === 'new' ? 'Password' : 'New password (leave blank to keep current)'} value={form.password} onChange={(e) => setForm(prev => ({ ...prev, password: e.target.value }))} />
</div>
<textarea className="input" rows="4" placeholder="Notes" value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} />
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button className="btn btn-outline btn-sm" type="button" onClick={cancelEdit}>
Cancel
</button>
</div>
</form>
)}
{status && <div style={{ marginTop: editingId ? 12 : 0, fontSize: 13, color: 'var(--text-secondary)' }}>{status}</div>}
</div>
)}
<div className="card">
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
) : entries.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
<p>Add encrypted password entries for internal services and team access.</p>
</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{entries.map(entry => (
<div
key={entry.id}
className="interactive-row"
style={{
padding: '16px',
border: '1px solid var(--border)',
borderRadius: 4,
display: 'grid',
gap: 10,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Updated {formatDate(entry.updated_at || entry.created_at)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-outline btn-sm" onClick={() => handleReveal(entry)}>
{revealed[entry.id] ? 'Hide' : 'Reveal'}
</button>
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
Copy
</button>
<button className="btn-icon" title="Edit" onClick={() => beginEdit(entry)}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDelete(entry)}>
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Username</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{entry.username}</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Password</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, fontFamily: 'monospace' }}>
{revealed[entry.id] || '••••••••••••'}
</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Service URL</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4, wordBreak: 'break-word' }}>
{entry.service_url ? <a href={normalizeUrl(entry.service_url)} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>{entry.service_url}</a> : '—'}
</div>
</div>
</div>
{entry.notes && (
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 }}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, whiteSpace: 'pre-wrap' }}>{entry.notes}</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</Layout>
);
}