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)
|
||||
- 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`.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default function PendingAdminPage() {
|
|||
const { t } = useI18n();
|
||||
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
|
||||
const [pendingListings, setPendingListings] = useState<PendingListing[]>([]);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [message, setMessage] = 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');
|
||||
return;
|
||||
}
|
||||
setRole(data.role ?? null);
|
||||
setPendingUsers(data.users ?? []);
|
||||
setPendingListings(data.listings ?? []);
|
||||
} catch (e) {
|
||||
|
|
@ -34,11 +36,16 @@ export default function PendingAdminPage() {
|
|||
fetch('/api/auth/me', { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.user?.role === 'ADMIN') {
|
||||
loadPending();
|
||||
} else {
|
||||
setRole(data.user?.role ?? null);
|
||||
if (!data.user?.role) {
|
||||
setError(t('adminRequired'));
|
||||
return;
|
||||
}
|
||||
if (['ADMIN', 'USER_MODERATOR', 'LISTING_MODERATOR'].includes(data.user.role)) {
|
||||
loadPending();
|
||||
return;
|
||||
}
|
||||
setError(t('adminRequired'));
|
||||
})
|
||||
.catch(() => setError(t('adminRequired')));
|
||||
}, [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')}
|
||||
</div>
|
||||
<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')}
|
||||
</button>
|
||||
<button className="button secondary" onClick={() => approveUser(u.id, true)}>
|
||||
<button className="button secondary" onClick={() => approveUser(u.id, true)} disabled={role !== 'ADMIN'}>
|
||||
{t('approveAdmin')}
|
||||
</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')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -147,13 +154,13 @@ export default function PendingAdminPage() {
|
|||
{t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')}
|
||||
</div>
|
||||
<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')}
|
||||
</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')}
|
||||
</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')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const roleOptions = ['USER', 'USER_MODERATOR', 'LISTING_MODERATOR', 'ADMIN'];
|
|||
export default function AdminUsersPage() {
|
||||
const { t } = useI18n();
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -38,7 +39,17 @@ export default function AdminUsersPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
fetch('/api/auth/me', { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setRole(data.user?.role ?? null);
|
||||
if (data.user?.role === 'ADMIN') {
|
||||
load();
|
||||
} else {
|
||||
setError(t('adminRequired'));
|
||||
}
|
||||
})
|
||||
.catch(() => setError(t('adminRequired')));
|
||||
}, []);
|
||||
|
||||
async function setRole(userId: string, role: string) {
|
||||
|
|
@ -161,7 +172,7 @@ export default function AdminUsersPage() {
|
|||
<tr key={u.id} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 8 }}>{u.email}</td>
|
||||
<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) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
|
|
@ -175,14 +186,14 @@ export default function AdminUsersPage() {
|
|||
<td style={{ padding: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{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')}
|
||||
</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')}
|
||||
</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')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true, listing: updated });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Admin listing approval error', error);
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Pending count error', error);
|
||||
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 });
|
||||
} catch (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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export async function POST(req: Request) {
|
|||
|
||||
return NextResponse.json({ ok: true, user: updated });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Admin approve user error', error);
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Admin reject user error', error);
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Admin remove user error', error);
|
||||
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 });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('Update role error', error);
|
||||
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) });
|
||||
} catch (error) {
|
||||
if (String(error).includes('Unauthorized')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
console.error('List users error', error);
|
||||
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 canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR;
|
||||
const canModerate = auth.role === Role.ADMIN;
|
||||
|
||||
if (!isOwner && !canModerate) {
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function requireAuth(request: Request | NextRequest) {
|
||||
export async function getAuthFromRequest(request: Request | NextRequest) {
|
||||
let token: string | null = null;
|
||||
|
||||
const header = request.headers.get('authorization');
|
||||
|
|
@ -40,9 +40,21 @@ export async function requireAuth(request: Request | NextRequest) {
|
|||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return verifyAccessToken(token);
|
||||
return auth;
|
||||
}
|
||||
|
||||
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