feat: restrict admin access to club operations and rollout test environment
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user