gg
This commit is contained in:
@@ -22,7 +22,7 @@ import { MdMail } from "react-icons/md";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import AdminContent from "@/components/admin/admin-content";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { isAdminRole } from "@/lib/auth/roles";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
@@ -10,6 +14,12 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
export default async function AdminPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!isAdminRole(session?.user?.roles)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <AdminContent />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
import { authService } from "@/lib/api/auth/service";
|
||||
import { normalizeRoles } from "@/lib/auth/roles";
|
||||
import NextAuth from "next-auth";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import type { JWT } from "next-auth/jwt";
|
||||
@@ -11,7 +12,7 @@ function randomToken() {
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||
|
||||
const authOptions: NextAuthOptions = {
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
@@ -63,6 +64,7 @@ const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data;
|
||||
const normalizedRoles = normalizeRoles(user.roles);
|
||||
|
||||
console.log("Login successful, creating user session object");
|
||||
|
||||
@@ -74,7 +76,7 @@ const authOptions: NextAuthOptions = {
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
roles: user.roles || [],
|
||||
roles: normalizedRoles,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Authorize error detailed:", error);
|
||||
@@ -98,13 +100,13 @@ const authOptions: NextAuthOptions = {
|
||||
token.accessToken = user.accessToken;
|
||||
token.refreshToken = user.refreshToken;
|
||||
token.id = user.id;
|
||||
token.roles = user.roles;
|
||||
token.roles = normalizeRoles(user.roles);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
session.user.id = token.id;
|
||||
session.user.roles = token.roles;
|
||||
session.user.roles = normalizeRoles(token.roles);
|
||||
session.accessToken = token.accessToken;
|
||||
session.refreshToken = token.refreshToken;
|
||||
return session;
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
} from "@/components/motion";
|
||||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||||
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
||||
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type AdminTab = "overview" | "users";
|
||||
|
||||
@@ -81,16 +83,21 @@ export default function AdminContent() {
|
||||
const t = useTranslations("admin");
|
||||
const tCommon = useTranslations("common");
|
||||
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||||
const canAccessAdmin = isAdminRole(session?.user?.roles);
|
||||
|
||||
const { data: analyticsData, isLoading: analyticsLoading } =
|
||||
useAdminAnalytics();
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers();
|
||||
useAdminAnalytics(canAccessAdmin);
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
||||
undefined,
|
||||
canAccessAdmin,
|
||||
);
|
||||
|
||||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||||
const users = (usersData?.data as AdminUserDto[] | undefined) ?? [];
|
||||
const users = usersData?.data?.items ?? [];
|
||||
|
||||
const tabs: { key: AdminTab; label: string }[] = [
|
||||
{ key: "overview", label: t("overview") },
|
||||
@@ -104,6 +111,37 @@ export default function AdminContent() {
|
||||
return user.email.split("@")[0];
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
return (
|
||||
<SlideUp>
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body py={10}>
|
||||
<VStack gap={3}>
|
||||
<Badge colorPalette="red" variant="subtle" borderRadius="full">
|
||||
<LuShield />
|
||||
Restricted
|
||||
</Badge>
|
||||
<Heading as="h2" size="md">
|
||||
Admin access required
|
||||
</Heading>
|
||||
<Text color="fg.muted" textAlign="center" maxW="md">
|
||||
This area is only available to superadmin accounts.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</SlideUp>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideUp>
|
||||
<Box>
|
||||
@@ -156,7 +194,7 @@ export default function AdminContent() {
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-users")}
|
||||
value={analytics?.totalUsers ?? 0}
|
||||
value={analytics?.totalUsers ?? analytics?.users?.total ?? 0}
|
||||
icon={<LuUsers />}
|
||||
colorPalette="primary"
|
||||
/>
|
||||
@@ -164,7 +202,7 @@ export default function AdminContent() {
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("total-predictions")}
|
||||
value={analytics?.totalPredictions ?? 0}
|
||||
value={analytics?.totalPredictions ?? analytics?.predictions ?? 0}
|
||||
icon={<LuChartBar />}
|
||||
colorPalette="green"
|
||||
/>
|
||||
@@ -172,7 +210,7 @@ export default function AdminContent() {
|
||||
<StaggerItem>
|
||||
<AdminStat
|
||||
label={t("active-users")}
|
||||
value={analytics?.activeUsers ?? 0}
|
||||
value={analytics?.activeUsers ?? analytics?.users?.active ?? 0}
|
||||
icon={<LuActivity />}
|
||||
colorPalette="orange"
|
||||
/>
|
||||
@@ -244,14 +282,12 @@ export default function AdminContent() {
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={
|
||||
user.role === "ADMIN" ? "red" : "gray"
|
||||
}
|
||||
colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.role || "User"}
|
||||
{formatRoleLabel(user.role)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex flex={1} justify="center">
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { authConfig } from "@/config/auth";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
import { isAdminRole } from "@/lib/auth/roles";
|
||||
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
|
||||
import GlobalSearch from "@/components/search/global-search";
|
||||
|
||||
@@ -81,13 +82,12 @@ export default function Header() {
|
||||
<LuUser />
|
||||
{t("nav.profile")}
|
||||
</MenuItem>
|
||||
{session?.user &&
|
||||
session.user.roles?.includes("ADMIN") && (
|
||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||
<LuShield />
|
||||
{t("nav.admin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session?.user && isAdminRole(session.user.roles) && (
|
||||
<MenuItem value="admin" onClick={() => router.push("/admin")}>
|
||||
<LuShield />
|
||||
{t("nav.admin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
|
||||
@@ -25,12 +25,17 @@ export default function GlobalSearch() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const isApplePlatform =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||
const shortcutLabel = isApplePlatform ? "Cmd+K" : "Ctrl+K";
|
||||
const shortcutCapsule = isApplePlatform ? "⌘K" : "Ctrl+K";
|
||||
|
||||
const bg = useColorModeValue("white", "gray.900");
|
||||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||||
const hoverBg = useColorModeValue("gray.50", "gray.800");
|
||||
const inputBg = useColorModeValue("gray.50", "gray.800");
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -42,7 +47,6 @@ export default function GlobalSearch() {
|
||||
|
||||
const teams: TeamDto[] = searchData?.data ?? [];
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
@@ -56,7 +60,6 @@ export default function GlobalSearch() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: Ctrl+K to focus
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
@@ -83,8 +86,11 @@ export default function GlobalSearch() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} position="relative" w={{ base: "full", lg: "280px" }}>
|
||||
{/* Search Input */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
w={{ base: "full", lg: "280px" }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
bg={inputBg}
|
||||
@@ -110,7 +116,7 @@ export default function GlobalSearch() {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => query.length >= 2 && setIsOpen(true)}
|
||||
placeholder="Takım ara... (Ctrl+K)"
|
||||
placeholder={`Takim ara... (${shortcutLabel})`}
|
||||
variant="flushed"
|
||||
size="sm"
|
||||
px={2}
|
||||
@@ -142,11 +148,10 @@ export default function GlobalSearch() {
|
||||
borderRadius="md"
|
||||
fontFamily="mono"
|
||||
>
|
||||
⌘K
|
||||
{shortcutCapsule}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Dropdown Results */}
|
||||
{isOpen && debouncedQuery.length >= 2 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
@@ -170,7 +175,7 @@ export default function GlobalSearch() {
|
||||
) : teams.length === 0 ? (
|
||||
<Flex justify="center" py={6}>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Sonuç bulunamadı
|
||||
Sonuc bulunamadi
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
|
||||
@@ -66,10 +66,17 @@ export interface UpdateUserSubscriptionDto {
|
||||
// ========================
|
||||
|
||||
export interface AnalyticsOverviewDto {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalPredictions: number;
|
||||
totalCoupons: number;
|
||||
totalUsers?: number;
|
||||
activeUsers?: number;
|
||||
totalPredictions?: number;
|
||||
totalCoupons?: number;
|
||||
users?: {
|
||||
total: number;
|
||||
active: number;
|
||||
premium: number;
|
||||
};
|
||||
matches?: number;
|
||||
predictions?: number;
|
||||
aiHealth?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@ export const AdminQueryKeys = {
|
||||
};
|
||||
|
||||
// Analytics
|
||||
export const useAdminAnalytics = () => {
|
||||
export const useAdminAnalytics = (enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: AdminQueryKeys.analytics(),
|
||||
queryFn: () => adminService.getAnalyticsOverview(),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -66,10 +67,14 @@ export const useResetAllUsageLimits = () => {
|
||||
};
|
||||
|
||||
// Users
|
||||
export const useAdminUsers = (params?: AdminPaginationParams) => {
|
||||
export const useAdminUsers = (
|
||||
params?: AdminPaginationParams,
|
||||
enabled = true,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: AdminQueryKeys.users(params),
|
||||
queryFn: () => adminService.getAllUsers(params),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { apiRequest } from "@/lib/api/api-service";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import {
|
||||
LoginDto,
|
||||
AuthResponse,
|
||||
RegisterDto,
|
||||
RefreshTokenDto,
|
||||
} from "./types";
|
||||
|
||||
const login = (data: LoginDto) => {
|
||||
return apiRequest<ApiResponse<AuthResponse>>({
|
||||
url: "/auth/login",
|
||||
client: "auth",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const register = (data: RegisterDto) => {
|
||||
return apiRequest<ApiResponse<AuthResponse>>({
|
||||
url: "/auth/register",
|
||||
client: "auth",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const refreshToken = (data: RefreshTokenDto) => {
|
||||
return apiRequest<ApiResponse<AuthResponse>>({
|
||||
url: "/auth/refresh",
|
||||
client: "auth",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
return apiRequest<ApiResponse<null>>({
|
||||
url: "/auth/logout",
|
||||
client: "auth",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
export const authService = {
|
||||
login,
|
||||
register,
|
||||
refreshToken,
|
||||
logout,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface LoginDto {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterDto {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenDto {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isActive?: boolean;
|
||||
roles: string[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import { authService } from "./service";
|
||||
import { LoginDto, RegisterDto, RefreshTokenDto, AuthResponse } from "./types";
|
||||
|
||||
export const AuthQueryKeys = {
|
||||
all: ["auth"] as const,
|
||||
session: () => [...AuthQueryKeys.all, "session"] as const,
|
||||
};
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<AuthResponse>,
|
||||
Error,
|
||||
LoginDto
|
||||
>({
|
||||
mutationFn: (credentials: LoginDto) => authService.login(credentials),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AuthQueryKeys.session() });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<AuthResponse>,
|
||||
Error,
|
||||
RegisterDto
|
||||
>({
|
||||
mutationFn: (userData: RegisterDto) => authService.register(userData),
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useRefreshToken() {
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<AuthResponse>,
|
||||
Error,
|
||||
RefreshTokenDto
|
||||
>({
|
||||
mutationFn: (tokenData: RefreshTokenDto) =>
|
||||
authService.refreshToken(tokenData),
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<ApiResponse<null>, Error, void>({
|
||||
mutationFn: () => authService.logout(),
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
@@ -99,13 +99,26 @@ export function createApiClient(baseURL: string): AxiosInstance {
|
||||
) {
|
||||
const errorMessage = extractApiErrorMessage(data, "Bir hata oluştu");
|
||||
|
||||
const errorStatus =
|
||||
"status" in data && typeof data.status === "number"
|
||||
? data.status
|
||||
: response.status;
|
||||
|
||||
// Handle 429 in success: false body
|
||||
if (data.status === 429) {
|
||||
if (errorStatus === 429) {
|
||||
show429Toast(errorMessage);
|
||||
}
|
||||
|
||||
// Use API-level status (data.status) if available, otherwise fall back to HTTP status
|
||||
const errorStatus = data.status || response.status;
|
||||
if (errorStatus === 401 && typeof window !== "undefined") {
|
||||
const isAuthPath =
|
||||
window.location.pathname.includes("/api/auth") ||
|
||||
window.location.pathname === "/";
|
||||
|
||||
if (!isAuthPath) {
|
||||
void signOut({ redirect: true, callbackUrl: "/" });
|
||||
}
|
||||
}
|
||||
|
||||
const apiError = new ApiError(errorMessage, errorStatus, data);
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const ADMIN_ROLES = new Set(["superadmin"]);
|
||||
|
||||
export function normalizeRole(role: string | null | undefined): string {
|
||||
return role?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export function normalizeRoles(
|
||||
roles: Array<string | null | undefined> | null | undefined,
|
||||
): string[] {
|
||||
if (!roles?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(roles.map((role) => normalizeRole(role)).filter(Boolean)),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRole(
|
||||
roles: Array<string | null | undefined> | null | undefined,
|
||||
expectedRole: string,
|
||||
): boolean {
|
||||
return normalizeRoles(roles).includes(normalizeRole(expectedRole));
|
||||
}
|
||||
|
||||
export function isAdminRole(
|
||||
roles: Array<string | null | undefined> | null | undefined,
|
||||
): boolean {
|
||||
return normalizeRoles(roles).some((role) => ADMIN_ROLES.has(role));
|
||||
}
|
||||
|
||||
export function formatRoleLabel(role: string | null | undefined): string {
|
||||
const normalizedRole = normalizeRole(role);
|
||||
|
||||
switch (normalizedRole) {
|
||||
case "superadmin":
|
||||
return "Superadmin";
|
||||
case "user":
|
||||
return "User";
|
||||
default:
|
||||
return role?.trim() || "User";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user