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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user