Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ const rateLimit = require('express-rate-limit');
const app = express();

app.use(helmet());
app.use(cors({ origin: process.env.FRONTEND_URL || 'http://localhost:5173' }));
app.use(
cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
})
);
app.use(express.json({ limit: '50kb' }));
app.use(cookieParser());
app.use(requestIdMiddleware);
Expand Down
5 changes: 1 addition & 4 deletions backend/src/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ function hashApiKey(rawKey) {

async function authenticate(req) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
throw new Error('Missing token');
}
const token = header.slice(7).trim();
const token = req.cookies?.cp_token || (header && header.startsWith('Bearer ') ? header.slice(7).trim() : null);
if (!token) throw new Error('Missing token');

if (token.startsWith('cp_live_')) {
Expand Down
42 changes: 37 additions & 5 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,42 @@ const {
* description: User registration and login
*/

const ACCESS_TOKEN_COOKIE_NAME = 'cp_token';
const REFRESH_TOKEN_COOKIE_NAME = 'cp_refresh_token';
const REFRESH_TOKEN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
const FORGOT_PASSWORD_MESSAGE =
'If that email exists, a password reset link has been sent.';

function parseJwtExpiresIn(value) {
const match = String(value).match(/^(\d+)([smhd])$/);
if (!match) return 15 * 60;
const num = parseInt(match[1], 10);
const unit = match[2];
if (unit === 's') return num;
if (unit === 'm') return num * 60;
if (unit === 'h') return num * 60 * 60;
if (unit === 'd') return num * 24 * 60 * 60;
return 15 * 60;
}

function setAccessTokenCookie(res, token) {
res.cookie(ACCESS_TOKEN_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: parseJwtExpiresIn(process.env.JWT_EXPIRES_IN || '15m') * 1000,
});
}

function clearAccessTokenCookie(res) {
res.clearCookie(ACCESS_TOKEN_COOKIE_NAME, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
}

const isTest = process.env.NODE_ENV === 'test';
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
Expand Down Expand Up @@ -252,6 +282,7 @@ router.post('/register', registerLimiter, registerValidation, validateRequest, a
const { token: refreshToken, expiresAt } = await createRefreshToken(user.id);

setRefreshTokenCookie(res, refreshToken, expiresAt);
setAccessTokenCookie(res, accessToken);

const requestId = req.id;
setImmediate(() => {
Expand All @@ -269,7 +300,7 @@ router.post('/register', registerLimiter, registerValidation, validateRequest, a
});
});

res.status(201).json({ token: accessToken, user });
res.status(201).json({ user });
});

router.post('/login', loginLimiter, loginValidation, validateRequest, async (req, res) => {
Expand Down Expand Up @@ -349,9 +380,9 @@ router.post('/login', loginLimiter, loginValidation, validateRequest, async (req
const { token: refreshToken, expiresAt } = await createRefreshToken(user.id);

setRefreshTokenCookie(res, refreshToken, expiresAt);
setAccessTokenCookie(res, accessToken);

res.json({
token: accessToken,
user: {
id: user.id,
email: user.email,
Expand Down Expand Up @@ -381,9 +412,9 @@ router.post('/refresh', async (req, res) => {
const { token: newRefreshToken, expiresAt } = await rotateRefreshToken(token, user.id);

setRefreshTokenCookie(res, newRefreshToken, expiresAt);
setAccessTokenCookie(res, accessToken);

res.json({
token: accessToken,
user: {
id: user.id,
email: user.email,
Expand All @@ -398,13 +429,14 @@ router.post('/refresh', async (req, res) => {
});
});

router.post('/logout', requireAuth, async (req, res) => {
router.post('/logout', async (req, res) => {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE_NAME];
if (token) {
await revokeRefreshToken(token);
}
clearRefreshTokenCookie(res);
res.json({ message: 'Logged out successfully' });
clearAccessTokenCookie(res);
res.json({ ok: true });
});

router.post(
Expand Down
20 changes: 5 additions & 15 deletions frontend/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,25 @@ export function AuthProvider({ children }) {
return null;
}
});
const [token, setToken] = useState(() => api.getToken());
const [ready, setReady] = useState(false);

useEffect(() => {
let active = true;

async function restoreSession() {
if (!user) {
setReady(true);
return;
}

try {
const data = await api.refresh();
if (!active) return;
setToken(data.token);
if (data.user) {
setUser(data.user);
localStorage.setItem('cp_user', JSON.stringify(data.user));
} else {
setUser(null);
localStorage.removeItem('cp_user');
}
} catch {
if (!active) return;
setUser(null);
setToken(null);
api.setToken(null);
localStorage.removeItem('cp_user');
} finally {
if (active) {
Expand All @@ -58,11 +52,9 @@ export function AuthProvider({ children }) {
};
}, []);

const login = useCallback(async (userData, jwt) => {
const login = useCallback(async (userData) => {
const normalized = { ...userData, role: userData.role || (userData.is_admin ? 'admin' : 'contributor') };
setUser(normalized);
setToken(jwt);
api.setToken(jwt);
localStorage.setItem('cp_user', JSON.stringify(normalized));
setReady(true);
}, []);
Expand All @@ -73,8 +65,6 @@ export function AuthProvider({ children }) {
} catch {
}
setUser(null);
setToken(null);
api.setToken(null);
localStorage.removeItem('cp_user');
setReady(true);
}, []);
Expand All @@ -85,7 +75,7 @@ export function AuthProvider({ children }) {
}, []);

return (
<AuthContext.Provider value={{ user, token, ready, login, logout, updateUser }}>
<AuthContext.Provider value={{ user, ready, login, logout, updateUser }}>
{children}
</AuthContext.Provider>
);
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/AcceptInvite.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';

export default function AcceptInvite() {
const { id, token } = useParams();
const { user, token: authToken, ready } = useAuth();
const { user, ready } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
Expand All @@ -15,7 +15,7 @@ export default function AcceptInvite() {
setLoading(true);
setError('');
try {
await api.acceptCampaignInvitation(id, { token }, authToken);
await api.acceptCampaignInvitation(id, { token });
setSuccess(true);
setLoading(false);
setTimeout(() => {
Expand All @@ -35,7 +35,7 @@ export default function AcceptInvite() {
);
}

if (!authToken) {
if (!user) {
return (
<main className="container page-narrow" style={{ paddingTop: '3rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 800, marginBottom: '1rem' }}>Join Campaign Team</h1>
Expand Down
50 changes: 25 additions & 25 deletions frontend/src/pages/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,33 @@ import { useNavigate } from 'react-router-dom';

const DISPUTE_STATUSES = ['open', 'under_review', 'resolved_creator', 'resolved_contributor', 'closed'];

function DisputeQueue({ token }) {
function DisputeQueue() {
const [disputes, setDisputes] = useState([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);

useEffect(() => {
// Load open/under_review disputes across all campaigns via admin endpoint
api.getAdminCampaigns(token)
api.getAdminCampaigns()
.then(async (campaigns) => {
const all = await Promise.all(
campaigns.map((c) =>
api.getCampaignDisputes(c.id, token)
api.getCampaignDisputes(c.id)
.then((ds) => ds.map((d) => ({ ...d, campaign_title: c.title })))
.catch(() => [])
)
);
setDisputes(all.flat().sort((a, b) => new Date(b.created_at) - new Date(a.created_at)));
})
.finally(() => setLoading(false));
}, [token]);
}, []);

async function resolve(dispute, status) {
const note = window.prompt(`Resolution note (${status}):`, '');
if (note === null) return;
setBusyId(dispute.id);
try {
const updated = await api.updateDispute(dispute.id, { status, resolution_note: note || undefined }, token);
const updated = await api.updateDispute(dispute.id, { status, resolution_note: note || undefined });
setDisputes((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
} catch (err) {
alert(err.message || 'Could not update dispute');
Expand Down Expand Up @@ -98,7 +98,7 @@ function DisputeQueue({ token }) {
}

export default function AdminDashboard() {
const { user, token, ready } = useAuth();
const { user, ready } = useAuth();
const navigate = useNavigate();
const [stats, setStats] = useState(null);
const [campaigns, setCampaigns] = useState([]);
Expand All @@ -121,11 +121,11 @@ export default function AdminDashboard() {
}

Promise.all([
api.getAdminStats(token),
api.getAdminCampaigns(token),
api.getAdminMilestones(token),
api.getAdminUsers(token),
api.getAdminAuditLog(token)
api.getAdminStats(),
api.getAdminCampaigns(),
api.getAdminMilestones(),
api.getAdminUsers(true),
api.getAdminAuditLog()
]).then(([st, camp, milestoneRows, usrs, audit]) => {
setStats(st);
setCampaigns(camp);
Expand All @@ -138,34 +138,34 @@ export default function AdminDashboard() {
navigate('/');
});

}, [ready, user, token, navigate]);
}, [ready, user, navigate]);

if (!ready || loading) return <div className="container" style={{padding:'2rem'}}>Loading admin panel...</div>;

async function refreshCampaigns() {
const camp = await api.getAdminCampaigns(token);
const camp = await api.getAdminCampaigns();
setCampaigns(camp);
}

async function refreshUsers() {
const usrs = await api.getAdminUsers(token, true);
const usrs = await api.getAdminUsers(true);
setUsers(usrs);
}

async function refreshAuditLog() {
const audit = await api.getAdminAuditLog(token);
const audit = await api.getAdminAuditLog();
setAuditLog(audit);
}

async function refreshMilestones() {
const rows = await api.getAdminMilestones(token);
const rows = await api.getAdminMilestones();
setMilestones(rows);
}

async function approveMilestone(id) {
setBusyMilestoneId(id);
try {
await api.approveMilestone(id, {}, token);
await api.approveMilestone(id, {});
await refreshMilestones();
await refreshCampaigns();
} finally {
Expand All @@ -178,7 +178,7 @@ export default function AdminDashboard() {
if (reason === null) return;
setBusyMilestoneId(id);
try {
await api.rejectMilestone(id, { reason: reason || 'Rejected by platform' }, token);
await api.rejectMilestone(id, { reason: reason || 'Rejected by platform' });
await refreshMilestones();
} finally {
setBusyMilestoneId(null);
Expand All @@ -190,7 +190,7 @@ export default function AdminDashboard() {
if (reason === null) return;
setBusyCampaignId(campaignId);
try {
await api.adminSuspendCampaign(campaignId, { reason }, token);
await api.adminSuspendCampaign(campaignId, { reason });
await refreshCampaigns();
await refreshAuditLog();
alert('Campaign suspended');
Expand All @@ -205,7 +205,7 @@ export default function AdminDashboard() {
if (!window.confirm('Restore this campaign to active?')) return;
setBusyCampaignId(campaignId);
try {
await api.adminRestoreCampaign(campaignId, token);
await api.adminRestoreCampaign(campaignId);
await refreshCampaigns();
await refreshAuditLog();
alert('Campaign restored');
Expand All @@ -222,7 +222,7 @@ export default function AdminDashboard() {
if (!window.confirm('This will permanently delete the campaign. Are you sure?')) return;
setBusyCampaignId(campaignId);
try {
await api.adminDeleteCampaign(campaignId, { reason }, token);
await api.adminDeleteCampaign(campaignId, { reason });
await refreshCampaigns();
await refreshAuditLog();
alert('Campaign deleted');
Expand All @@ -238,7 +238,7 @@ export default function AdminDashboard() {
if (reason === null) return;
setBusyUserId(userId);
try {
await api.adminBanUser(userId, { reason }, token);
await api.adminBanUser(userId, { reason });
await refreshUsers();
await refreshAuditLog();
alert('User banned');
Expand All @@ -253,7 +253,7 @@ export default function AdminDashboard() {
if (!window.confirm('Unban this user?')) return;
setBusyUserId(userId);
try {
await api.adminUnbanUser(userId, token);
await api.adminUnbanUser(userId);
await refreshUsers();
await refreshAuditLog();
alert('User unbanned');
Expand Down Expand Up @@ -453,7 +453,7 @@ export default function AdminDashboard() {
{activeTab === 'disputes' && (
<>
<h2 style={{fontSize:'1.4rem', fontWeight:700, marginBottom:'1rem'}}>Dispute Queue</h2>
<DisputeQueue token={token} />
<DisputeQueue />
</>
)}

Expand Down Expand Up @@ -499,7 +499,7 @@ export default function AdminDashboard() {
<td style={tdStyle}>{c.status}</td>
<td style={tdStyle}>
<select value={c.status} onChange={(e) => {
api.updateCampaignStatus(c.id, e.target.value, token).then(() => {
api.updateCampaignStatus(c.id, e.target.value).then(() => {
setCampaigns(campaigns.map(camp => camp.id === c.id ? {...camp, status: e.target.value} : camp));
});
}} style={{padding:'0.3rem', borderRadius:'4px', border:'1px solid var(--color-border-light)'}}>
Expand Down
Loading