Harden auth/role enforcement for admin and listings
This commit is contained in:
parent
a58b6f0f2d
commit
61fc8dc5ba
14 changed files with 112 additions and 17 deletions
|
|
@ -28,6 +28,7 @@
|
||||||
# Lomavuokraus app progress (Nov 24)
|
# Lomavuokraus app progress (Nov 24)
|
||||||
- New testing DB (`lomavuokraus_testing`) holds the previous staging/prod data; the main `lomavuokraus` DB was recreated clean with only the seeded admin user. Migration history was copied, and a schema snapshot lives at `docs/db-schema.sql`.
|
- New testing DB (`lomavuokraus_testing`) holds the previous staging/prod data; the main `lomavuokraus` DB was recreated clean with only the seeded admin user. Migration history was copied, and a schema snapshot lives at `docs/db-schema.sql`.
|
||||||
- Testing environment wiring added: dedicated namespace (`lomavuokraus-test`), deploy wrapper (`deploy/deploy-test.sh`), API host support, and a DNS updater for `test.lomavuokraus.fi` / `apitest.lomavuokraus.fi`.
|
- Testing environment wiring added: dedicated namespace (`lomavuokraus-test`), deploy wrapper (`deploy/deploy-test.sh`), API host support, and a DNS updater for `test.lomavuokraus.fi` / `apitest.lomavuokraus.fi`.
|
||||||
|
- Access control tightened: middleware now gates admin routes, admin-only pages check session/role, API handlers return proper 401/403, and listing removal is limited to owners/admins (no more moderator overrides).
|
||||||
- Backend/data: Added Prisma models (User/Listing/ListingTranslation/ListingImage), seed script creates sample listing; DB on Hetzner VM `46.62.203.202`, staging secrets set in `lomavuokraus-web-secrets`.
|
- Backend/data: Added Prisma models (User/Listing/ListingTranslation/ListingImage), seed script creates sample listing; DB on Hetzner VM `46.62.203.202`, staging secrets set in `lomavuokraus-web-secrets`.
|
||||||
- Auth: Register/login/verify flows; session cookie (`session_token`), NavBar shows email+role badge. Roles: USER, ADMIN, USER_MODERATOR (approve users), LISTING_MODERATOR (approve listings). Admin can change roles at `/admin/users`.
|
- Auth: Register/login/verify flows; session cookie (`session_token`), NavBar shows email+role badge. Roles: USER, ADMIN, USER_MODERATOR (approve users), LISTING_MODERATOR (approve listings). Admin can change roles at `/admin/users`.
|
||||||
- Listing flow: create listing (session required), pending/published with admin/moderator approvals; pages for “My listings,” “New listing,” “Profile.” Quick actions tile removed; all actions in navbar.
|
- Listing flow: create listing (session required), pending/published with admin/moderator approvals; pages for “My listings,” “New listing,” “Profile.” Quick actions tile removed; all actions in navbar.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export default function PendingAdminPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
|
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
|
||||||
const [pendingListings, setPendingListings] = useState<PendingListing[]>([]);
|
const [pendingListings, setPendingListings] = useState<PendingListing[]>([]);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ export default function PendingAdminPage() {
|
||||||
setError(data.error || 'Failed to load');
|
setError(data.error || 'Failed to load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setRole(data.role ?? null);
|
||||||
setPendingUsers(data.users ?? []);
|
setPendingUsers(data.users ?? []);
|
||||||
setPendingListings(data.listings ?? []);
|
setPendingListings(data.listings ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -34,11 +36,16 @@ export default function PendingAdminPage() {
|
||||||
fetch('/api/auth/me', { cache: 'no-store' })
|
fetch('/api/auth/me', { cache: 'no-store' })
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.user?.role === 'ADMIN') {
|
setRole(data.user?.role ?? null);
|
||||||
loadPending();
|
if (!data.user?.role) {
|
||||||
} else {
|
|
||||||
setError(t('adminRequired'));
|
setError(t('adminRequired'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (['ADMIN', 'USER_MODERATOR', 'LISTING_MODERATOR'].includes(data.user.role)) {
|
||||||
|
loadPending();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(t('adminRequired'));
|
||||||
})
|
})
|
||||||
.catch(() => setError(t('adminRequired')));
|
.catch(() => setError(t('adminRequired')));
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
@ -117,13 +124,13 @@ export default function PendingAdminPage() {
|
||||||
<strong>{u.email}</strong> — {t('statusLabel')}: {u.status} — {t('verifiedLabel')}: {u.emailVerifiedAt ? t('yes') : t('no')}
|
<strong>{u.email}</strong> — {t('statusLabel')}: {u.status} — {t('verifiedLabel')}: {u.emailVerifiedAt ? t('yes') : t('no')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||||
<button className="button" onClick={() => approveUser(u.id, false)}>
|
<button className="button" onClick={() => approveUser(u.id, false)} disabled={role !== 'ADMIN' && role !== 'USER_MODERATOR'}>
|
||||||
{t('approve')}
|
{t('approve')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={() => approveUser(u.id, true)}>
|
<button className="button secondary" onClick={() => approveUser(u.id, true)} disabled={role !== 'ADMIN'}>
|
||||||
{t('approveAdmin')}
|
{t('approveAdmin')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={() => rejectUser(u.id)}>
|
<button className="button secondary" onClick={() => rejectUser(u.id)} disabled={role !== 'ADMIN' && role !== 'USER_MODERATOR'}>
|
||||||
{t('reject')}
|
{t('reject')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,13 +154,13 @@ export default function PendingAdminPage() {
|
||||||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||||
<button className="button" onClick={() => approveListing(l.id, 'approve')}>
|
<button className="button" onClick={() => approveListing(l.id, 'approve')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||||
{t('publish')}
|
{t('publish')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={() => approveListing(l.id, 'reject')}>
|
<button className="button secondary" onClick={() => approveListing(l.id, 'reject')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||||
{t('reject')}
|
{t('reject')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={() => approveListing(l.id, 'remove')}>
|
<button className="button secondary" onClick={() => approveListing(l.id, 'remove')} disabled={role !== 'ADMIN' && role !== 'LISTING_MODERATOR'}>
|
||||||
{t('remove')}
|
{t('remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const roleOptions = ['USER', 'USER_MODERATOR', 'LISTING_MODERATOR', 'ADMIN'];
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -38,7 +39,17 @@ export default function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me', { cache: 'no-store' })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setRole(data.user?.role ?? null);
|
||||||
|
if (data.user?.role === 'ADMIN') {
|
||||||
load();
|
load();
|
||||||
|
} else {
|
||||||
|
setError(t('adminRequired'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError(t('adminRequired')));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function setRole(userId: string, role: string) {
|
async function setRole(userId: string, role: string) {
|
||||||
|
|
@ -161,7 +172,7 @@ export default function AdminUsersPage() {
|
||||||
<tr key={u.id} style={{ borderTop: '1px solid #eee' }}>
|
<tr key={u.id} style={{ borderTop: '1px solid #eee' }}>
|
||||||
<td style={{ padding: 8 }}>{u.email}</td>
|
<td style={{ padding: 8 }}>{u.email}</td>
|
||||||
<td style={{ padding: 8 }}>
|
<td style={{ padding: 8 }}>
|
||||||
<select value={u.role} onChange={(e) => setRole(u.id, e.target.value)} disabled={loading}>
|
<select value={u.role} onChange={(e) => setRole(u.id, e.target.value)} disabled={loading || role !== 'ADMIN'}>
|
||||||
{roleOptions.map((r) => (
|
{roleOptions.map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{r}
|
{r}
|
||||||
|
|
@ -175,14 +186,14 @@ export default function AdminUsersPage() {
|
||||||
<td style={{ padding: 8 }}>
|
<td style={{ padding: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{u.approvedAt ? null : (
|
{u.approvedAt ? null : (
|
||||||
<button className="button secondary" onClick={() => approve(u.id)} disabled={loading}>
|
<button className="button secondary" onClick={() => approve(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||||
{t('approve')}
|
{t('approve')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="button secondary" onClick={() => reject(u.id)} disabled={loading}>
|
<button className="button secondary" onClick={() => reject(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||||
{t('reject')}
|
{t('reject')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={() => remove(u.id)} disabled={loading}>
|
<button className="button secondary" onClick={() => remove(u.id)} disabled={loading || role !== 'ADMIN'}>
|
||||||
{t('remove')}
|
{t('remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, listing: updated });
|
return NextResponse.json({ ok: true, listing: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Admin listing approval error', error);
|
console.error('Admin listing approval error', error);
|
||||||
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ export async function GET(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ users, listings, total: users + listings });
|
return NextResponse.json({ users, listings, total: users + listings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Pending count error', error);
|
console.error('Pending count error', error);
|
||||||
return NextResponse.json({ error: 'Failed to load count' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to load count' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ export async function GET(req: Request) {
|
||||||
return NextResponse.json({ users, listings, role: auth.role });
|
return NextResponse.json({ users, listings, role: auth.role });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('List pending error', error);
|
console.error('List pending error', error);
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
return NextResponse.json({ error: 'Failed to load pending items' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to load pending items' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, user: updated });
|
return NextResponse.json({ ok: true, user: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Admin approve user error', error);
|
console.error('Admin approve user error', error);
|
||||||
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
return NextResponse.json({ error: 'Approval failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, user: updated });
|
return NextResponse.json({ ok: true, user: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Admin reject user error', error);
|
console.error('Admin reject user error', error);
|
||||||
return NextResponse.json({ error: 'Reject failed' }, { status: 500 });
|
return NextResponse.json({ error: 'Reject failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, user: updated });
|
return NextResponse.json({ ok: true, user: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Admin remove user error', error);
|
console.error('Admin remove user error', error);
|
||||||
return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
|
return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, user: updated });
|
return NextResponse.json({ ok: true, user: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('Update role error', error);
|
console.error('Update role error', error);
|
||||||
return NextResponse.json({ error: 'Failed to update role' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to update role' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ export async function GET(req: Request) {
|
||||||
|
|
||||||
return NextResponse.json({ users, roles: Object.values(Role), statuses: Object.values(UserStatus) });
|
return NextResponse.json({ users, roles: Object.values(Role), statuses: Object.values(UserStatus) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (String(error).includes('Unauthorized')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
console.error('List users error', error);
|
console.error('List users error', error);
|
||||||
return NextResponse.json({ error: 'Failed to load users' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to load users' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export async function POST(req: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = listing.ownerId === auth.userId;
|
const isOwner = listing.ownerId === auth.userId;
|
||||||
const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
|
const canModerate = auth.role === Role.ADMIN;
|
||||||
|
|
||||||
if (!isOwner && !canModerate) {
|
if (!isOwner && !canModerate) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
|
||||||
16
lib/jwt.ts
16
lib/jwt.ts
|
|
@ -23,7 +23,7 @@ export async function verifyAccessToken(token: string) {
|
||||||
return payload as { userId: string; role: string };
|
return payload as { userId: string; role: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(request: Request | NextRequest) {
|
export async function getAuthFromRequest(request: Request | NextRequest) {
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
|
|
||||||
const header = request.headers.get('authorization');
|
const header = request.headers.get('authorization');
|
||||||
|
|
@ -40,9 +40,21 @@ export async function requireAuth(request: Request | NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await verifyAccessToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(request: Request | NextRequest) {
|
||||||
|
const auth = await getAuthFromRequest(request);
|
||||||
|
if (!auth) {
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
return verifyAccessToken(token);
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSessionCookie(token: string) {
|
export function buildSessionCookie(token: string) {
|
||||||
|
|
|
||||||
40
middleware.ts
Normal file
40
middleware.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuthFromRequest } from './lib/jwt';
|
||||||
|
|
||||||
|
const ADMIN_ONLY_PATHS = ['/admin/users'];
|
||||||
|
const MODERATOR_PATHS = ['/admin/pending'];
|
||||||
|
|
||||||
|
function buildLoginRedirect(req: NextRequest) {
|
||||||
|
const url = new URL('/auth/login', req.url);
|
||||||
|
url.searchParams.set('redirect', req.nextUrl.pathname + req.nextUrl.search);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
if (!pathname.startsWith('/admin')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getAuthFromRequest(req);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(buildLoginRedirect(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = session.role;
|
||||||
|
const isAdminOnly = ADMIN_ONLY_PATHS.some((p) => pathname.startsWith(p));
|
||||||
|
if (isAdminOnly && role !== 'ADMIN') {
|
||||||
|
return NextResponse.redirect(new URL('/', req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isModeratorPath = MODERATOR_PATHS.some((p) => pathname.startsWith(p));
|
||||||
|
if (isModeratorPath && !(role === 'ADMIN' || role === 'USER_MODERATOR' || role === 'LISTING_MODERATOR')) {
|
||||||
|
return NextResponse.redirect(new URL('/', req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*'],
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue