feat: restrict admin access to club operations and rollout test environment
CI Pipeline / Backend Build & Test (pull_request) Successful in 53s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Failing after 16s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 3s

This commit is contained in:
WorkClub Automation
2026-03-18 09:08:45 +01:00
parent 9cb80e4517
commit 821459966c
22 changed files with 507 additions and 203 deletions
@@ -0,0 +1,12 @@
import { ClubManagement } from '@/components/admin/club-management';
export default function AdminClubsPage() {
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Club Management</h1>
</div>
<ClubManagement />
</div>
);
}
+29 -15
View File
@@ -1,13 +1,19 @@
'use client';
import { AuthGuard } from '@/components/auth-guard';
import { ClubSwitcher } from '@/components/club-switcher';
import Link from 'next/link';
import { SignOutButton } from '@/components/sign-out-button';
import { useSession } from 'next-auth/react';
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { data } = useSession();
const isAdmin = (data?.user as any)?.isAdmin;
return (
<AuthGuard>
<div className="flex min-h-screen bg-gray-50">
@@ -15,26 +21,34 @@ export default function ProtectedLayout({
<div className="p-4 border-b">
<h1 className="text-xl font-bold">WorkClub</h1>
</div>
<nav className="flex-1 p-4 space-y-2">
<Link href="/dashboard" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Dashboard
</Link>
<Link href="/tasks" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Tasks
</Link>
<Link href="/shifts" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Shifts
</Link>
<Link href="/members" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Members
</Link>
</nav>
{isAdmin ? (
<nav className="flex-1 p-4 space-y-2">
<Link href="/admin/clubs" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Club Management
</Link>
</nav>
) : (
<nav className="flex-1 p-4 space-y-2">
<Link href="/dashboard" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Dashboard
</Link>
<Link href="/tasks" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Tasks
</Link>
<Link href="/shifts" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Shifts
</Link>
<Link href="/members" className="flex items-center px-4 py-2 text-sm font-medium rounded-md hover:bg-gray-100">
Members
</Link>
</nav>
)}
</aside>
<div className="flex-1 flex flex-col">
<header className="bg-white border-b h-16 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<ClubSwitcher />
{!isAdmin && <ClubSwitcher />}
</div>
<div className="flex items-center gap-4">
<SignOutButton />
+16 -5
View File
@@ -9,6 +9,7 @@ declare module "next-auth" {
email?: string | null
image?: string | null
clubs?: Record<string, string>
isAdmin?: boolean
}
accessToken?: string
}
@@ -16,6 +17,7 @@ declare module "next-auth" {
interface JWT {
clubs?: Record<string, string>
accessToken?: string
isAdmin?: boolean
}
}
@@ -43,19 +45,28 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
],
callbacks: {
async jwt({ token, account }) {
if (account) {
if (account && account.access_token) {
// Add clubs claim from Keycloak access token
token.clubs = (account as Record<string, unknown>).clubs as Record<string, string> || {}
token.accessToken = account.access_token
token.clubs = (account as any).clubs as Record<string, string> || {}
token.accessToken = account.access_token as string
try {
const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString());
const roles = payload.realm_access?.roles || [];
token.isAdmin = roles.includes('admin');
} catch (e) {
token.isAdmin = false;
}
}
return token
},
async session({ session, token }) {
// Expose clubs to client
if (session.user) {
session.user.clubs = token.clubs as Record<string, string> | undefined
session.user.clubs = (token as any).clubs as Record<string, string> | undefined
session.user.isAdmin = (token as any).isAdmin as boolean | undefined
}
session.accessToken = token.accessToken as string | undefined
session.accessToken = (token as any).accessToken as string | undefined
return session
}
}
@@ -0,0 +1,152 @@
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
type Club = {
id: string;
name: string;
sportType: string;
description?: string;
};
export function ClubManagement() {
const { data: session } = useSession();
const [clubs, setClubs] = useState<Club[]>([]);
const [loading, setLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [newClub, setNewClub] = useState({ name: '', sportType: 'Tennis', description: '' });
const fetchClubs = async () => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs`, {
headers: { Authorization: `Bearer ${(session as any)?.accessToken}` },
});
if (res.ok) {
const data = await res.json();
setClubs(data);
}
} catch (error) {
console.error('Failed to fetch clubs', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (session) fetchClubs();
}, [session]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${(session as any)?.accessToken}`,
},
body: JSON.stringify({
name: newClub.name,
sportType: newClub.sportType === 'Tennis' ? 0 : 1, // Mapping Enum or keep string if api accepts
description: newClub.description,
}),
});
if (res.ok) {
setNewClub({ name: '', sportType: 'Tennis', description: '' });
setIsCreating(false);
fetchClubs();
}
} catch (e) {
console.error(e);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this club?')) return;
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/admin/clubs/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${(session as any)?.accessToken}` },
});
if (res.ok) {
fetchClubs();
}
} catch (e) {
console.error(e);
}
};
if (loading) return <div>Loading clubs...</div>;
return (
<div className="space-y-6">
<div className="flex justify-between">
<h2 className="text-xl font-semibold">All Clubs</h2>
<button
onClick={() => setIsCreating(true)}
className="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700"
>
Create New Club
</button>
</div>
{isCreating && (
<form onSubmit={handleCreate} className="bg-white p-4 rounded shadow space-y-4 border">
<h3 className="font-semibold text-lg">New Club</h3>
<div>
<label className="block text-sm font-medium">Name</label>
<input
required
className="mt-1 block w-full p-2 border rounded"
value={newClub.name}
onChange={e => setNewClub({ ...newClub, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium">Sport Type</label>
<select
className="mt-1 block w-full p-2 border rounded"
value={newClub.sportType}
onChange={e => setNewClub({ ...newClub, sportType: e.target.value })}
>
<option value="Tennis">Tennis</option>
<option value="Cycling">Cycling</option>
</select>
</div>
<div>
<label className="block text-sm font-medium">Description</label>
<textarea
className="mt-1 block w-full p-2 border rounded"
value={newClub.description}
onChange={e => setNewClub({ ...newClub, description: e.target.value })}
/>
</div>
<div className="flex gap-2">
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">Save</button>
<button type="button" onClick={() => setIsCreating(false)} className="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
</div>
</form>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{clubs.map(club => (
<div key={club.id} className="bg-white p-4 rounded shadow border">
<h3 className="font-bold text-lg">{club.name}</h3>
<p className="text-sm text-gray-500 mb-2">{club.sportType}</p>
<p className="text-sm line-clamp-2 mb-4">{club.description || 'No description'}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => handleDelete(club.id)}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Delete
</button>
</div>
</div>
))}
{clubs.length === 0 && <p className="text-gray-500 col-span-full">No clubs found.</p>}
</div>
</div>
);
}
+23 -9
View File
@@ -6,7 +6,7 @@ import { ReactNode, useEffect } from 'react';
import { useTenant } from '../contexts/tenant-context';
export function AuthGuard({ children }: { children: ReactNode }) {
const { status } = useSession();
const { data, status } = useSession();
const { activeClubId, clubs, setActiveClub, clubsLoading } = useTenant();
const router = useRouter();
@@ -17,14 +17,27 @@ export function AuthGuard({ children }: { children: ReactNode }) {
}, [status, router]);
useEffect(() => {
if (status === 'authenticated' && clubs.length > 0) {
if (clubs.length === 1 && !activeClubId) {
setActiveClub(clubs[0].id);
} else if (clubs.length > 1 && !activeClubId) {
router.push('/select-club');
if (status === 'authenticated') {
const isAdmin = (data?.user as any)?.isAdmin;
// Admin routing
if (isAdmin) {
if (!window.location.pathname.startsWith('/admin')) {
router.push('/admin/clubs');
}
return;
}
// Normal user routing
if (clubs.length > 0) {
if (clubs.length === 1 && !activeClubId) {
setActiveClub(clubs[0].id);
} else if (clubs.length > 1 && !activeClubId) {
router.push('/select-club');
}
}
}
}, [status, clubs, activeClubId, router, setActiveClub]);
}, [status, clubs, activeClubId, router, setActiveClub, data]);
if (status === 'loading') {
return (
@@ -46,7 +59,8 @@ export function AuthGuard({ children }: { children: ReactNode }) {
);
}
if (clubs.length === 0 && status === 'authenticated') {
const isAdmin = (data?.user as any)?.isAdmin;
if (clubs.length === 0 && status === 'authenticated' && !isAdmin) {
const handleSwitchAccount = () => {
const keycloakLogoutUrl = `${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || 'http://localhost:8080/realms/workclub'}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin + '/login')}`;
signOut({ redirect: false }).then(() => {
@@ -68,7 +82,7 @@ export function AuthGuard({ children }: { children: ReactNode }) {
);
}
if (clubs.length > 1 && !activeClubId) {
if (clubs.length > 1 && !activeClubId && !isAdmin) {
return null;
}