first commit
This commit is contained in:
109
lib/api/fetchClient.ts
Normal file
109
lib/api/fetchClient.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// .env'deki NEXT_PUBLIC_API_BASE kullanılır (örn: http://127.0.0.1:8080). /v1 prefix'i burada eklenir.
|
||||
const getBaseUrl = () => {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8080";
|
||||
const normalized = base.replace(/\/$/, "");
|
||||
return `${normalized}/v1`;
|
||||
};
|
||||
const BASE_URL = getBaseUrl();
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export const fetchClient = async (endpoint: string, options: FetchOptions = {}) => {
|
||||
const getAccessToken = () => Cookies.get("access_token");
|
||||
const getRefreshToken = () => Cookies.get("refresh_token");
|
||||
|
||||
const setTokens = (access: string, refresh: string) => {
|
||||
Cookies.set("access_token", access, { secure: true, sameSite: 'strict' });
|
||||
Cookies.set("refresh_token", refresh, { secure: true, sameSite: 'strict' });
|
||||
// Dispatch event for other tabs or parts of the app to know (optional)
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
}
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
Cookies.remove("access_token");
|
||||
Cookies.remove("refresh_token");
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
localStorage.removeItem("user");
|
||||
} catch (e) { }
|
||||
window.location.href = "/login";
|
||||
}
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
let response = await fetch(`${BASE_URL}${endpoint}`, config);
|
||||
|
||||
// Handle 401 - Token Expired
|
||||
if (response.status === 401) {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
clearTokens();
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to refresh token
|
||||
const refreshResponse = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error("Refresh failed");
|
||||
}
|
||||
|
||||
const data: AuthResponse = await refreshResponse.json();
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
|
||||
// Retry original request with new token
|
||||
headers["Authorization"] = `Bearer ${data.access_token}`;
|
||||
response = await fetch(`${BASE_URL}${endpoint}`, { ...options, headers });
|
||||
} catch (error) {
|
||||
clearTokens();
|
||||
throw new Error("Session expired. Please login again.");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error || errorData.message || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Return json if content type is json, otherwise text or null
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
300
lib/features/auth/authSlice.ts
Normal file
300
lib/features/auth/authSlice.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { fetchClient } from "@/lib/api/fetchClient";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// Types
|
||||
export interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions?: Permission[] | null;
|
||||
}
|
||||
|
||||
export interface SocialAccount {
|
||||
id: number;
|
||||
provider: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
roles: Role[];
|
||||
avatar_url?: string;
|
||||
email_verified?: boolean;
|
||||
is_oauth_user?: boolean;
|
||||
social_accounts?: SocialAccount[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
roles: Role[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
email_verified: boolean;
|
||||
message: string;
|
||||
roles: Role[];
|
||||
verification_token: string;
|
||||
}
|
||||
|
||||
// Initial State
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async Thunks
|
||||
export const login = createAsyncThunk(
|
||||
"auth/login",
|
||||
async (credentials: any, { rejectWithValue }) => {
|
||||
try {
|
||||
const response: LoginResponse = await fetchClient("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
// Validate Check: Ensure tokens exist
|
||||
if (!response.access_token || !response.refresh_token) {
|
||||
return rejectWithValue("Giriş başarısız: Token alınamadı. Lütfen emailinizi doğruladığınızdan emin olun.");
|
||||
}
|
||||
|
||||
// Store tokens in cookies
|
||||
Cookies.set("access_token", response.access_token, { secure: true, sameSite: 'strict' });
|
||||
Cookies.set("refresh_token", response.refresh_token, { secure: true, sameSite: 'strict' });
|
||||
|
||||
// Store user info in localStorage for convenience (optional, can be removed if strict cookie only)
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("user", JSON.stringify({
|
||||
id: response.user_id,
|
||||
username: response.username,
|
||||
email: response.email,
|
||||
roles: response.roles,
|
||||
avatar_url: response.avatar
|
||||
}));
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const register = createAsyncThunk(
|
||||
"auth/register",
|
||||
async (credentials: any, { rejectWithValue }) => {
|
||||
try {
|
||||
const response: RegisterResponse = await fetchClient("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
// NOTE: Register does NOT return tokens anymore.
|
||||
// We do NOT set cookies here.
|
||||
// We do NOT set localStorage user here.
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProfile = createAsyncThunk(
|
||||
"auth/fetchProfile",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/profile");
|
||||
const data = response as any;
|
||||
// Map API response to User type, specifically avatar -> avatar_url
|
||||
return {
|
||||
...data,
|
||||
avatar_url: data.avatar,
|
||||
roles: data.roles || []
|
||||
} as User;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProfile = createAsyncThunk(
|
||||
"auth/updateProfile",
|
||||
async (formData: FormData, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/profile", {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = (response as any).user;
|
||||
|
||||
// Map API response to User type, specifically avatar -> avatar_url
|
||||
return {
|
||||
...data,
|
||||
avatar_url: data.avatar,
|
||||
roles: data.roles || []
|
||||
} as User;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const changePassword = createAsyncThunk(
|
||||
"auth/changePassword",
|
||||
async (data: any, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/profile/password", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const changeEmail = createAsyncThunk(
|
||||
"auth/changeEmail",
|
||||
async (data: any, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/profile/email", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Slice
|
||||
const authSlice = createSlice({
|
||||
name: "auth",
|
||||
initialState,
|
||||
reducers: {
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
Cookies.remove("access_token");
|
||||
Cookies.remove("refresh_token");
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("user");
|
||||
}
|
||||
},
|
||||
restoreSession: (state) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const userStr = localStorage.getItem("user");
|
||||
const token = Cookies.get("access_token");
|
||||
if (userStr && token) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(userStr);
|
||||
// Migration for legacy data: map avatar to avatar_url if needed
|
||||
if (parsedUser.avatar && !parsedUser.avatar_url) {
|
||||
parsedUser.avatar_url = parsedUser.avatar;
|
||||
}
|
||||
state.user = parsedUser;
|
||||
state.isAuthenticated = true;
|
||||
} catch (e) {
|
||||
// Corrupt user data
|
||||
localStorage.removeItem("user");
|
||||
Cookies.remove("access_token");
|
||||
Cookies.remove("refresh_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// Login
|
||||
builder.addCase(login.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(login.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.isAuthenticated = true;
|
||||
state.user = {
|
||||
id: action.payload.user_id,
|
||||
username: action.payload.username,
|
||||
email: action.payload.email,
|
||||
roles: action.payload.roles,
|
||||
avatar_url: action.payload.avatar
|
||||
};
|
||||
});
|
||||
builder.addCase(login.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Register
|
||||
builder.addCase(register.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(register.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
// isAuthenticated remains false because we need email verification
|
||||
state.isAuthenticated = false;
|
||||
// We do not set the user state effectively until they login
|
||||
state.user = null;
|
||||
});
|
||||
builder.addCase(register.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Fetch Profile
|
||||
builder.addCase(fetchProfile.fulfilled, (state, action) => {
|
||||
state.user = action.payload;
|
||||
// Sync with localStorage so next refresh has fresh data
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("user", JSON.stringify(action.payload));
|
||||
}
|
||||
});
|
||||
|
||||
// Update Profile
|
||||
builder.addCase(updateProfile.fulfilled, (state, action) => {
|
||||
state.user = action.payload;
|
||||
// Sync with localStorage so next refresh has fresh data
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("user", JSON.stringify(action.payload));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { logout, restoreSession } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
215
lib/features/cors/corsSlice.ts
Normal file
215
lib/features/cors/corsSlice.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { fetchClient } from "@/lib/api/fetchClient";
|
||||
|
||||
// Types
|
||||
export interface CorsEntry {
|
||||
id: string;
|
||||
origin: string;
|
||||
description?: string; // For whitelist
|
||||
reason?: string; // For blacklist
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CorsState {
|
||||
whitelist: CorsEntry[];
|
||||
blacklist: CorsEntry[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: CorsState = {
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async Thunks
|
||||
|
||||
// --- WHITELIST ---
|
||||
|
||||
export const fetchWhitelists = createAsyncThunk(
|
||||
"cors/fetchWhitelists",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchClient("/settings/cors/whitelist");
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createWhitelist = createAsyncThunk(
|
||||
"cors/createWhitelist",
|
||||
async (data: { origin: string; description: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchClient("/settings/cors/whitelist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateWhitelist = createAsyncThunk(
|
||||
"cors/updateWhitelist",
|
||||
async ({ id, data }: { id: string; data: Partial<CorsEntry> }, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetchClient(`/settings/cors/whitelist/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return { id, data };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteWhitelist = createAsyncThunk(
|
||||
"cors/deleteWhitelist",
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetchClient(`/settings/cors/whitelist/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return id;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- BLACKLIST ---
|
||||
|
||||
export const fetchBlacklists = createAsyncThunk(
|
||||
"cors/fetchBlacklists",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchClient("/settings/cors/blacklist");
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createBlacklist = createAsyncThunk(
|
||||
"cors/createBlacklist",
|
||||
async (data: { origin: string; reason: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchClient("/settings/cors/blacklist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateBlacklist = createAsyncThunk(
|
||||
"cors/updateBlacklist",
|
||||
async ({ id, data }: { id: string; data: Partial<CorsEntry> }, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetchClient(`/settings/cors/blacklist/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return { id, data };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteBlacklist = createAsyncThunk(
|
||||
"cors/deleteBlacklist",
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetchClient(`/settings/cors/blacklist/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return id;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const corsSlice = createSlice({
|
||||
name: "cors",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
// Fetch Whitelists
|
||||
builder.addCase(fetchWhitelists.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchWhitelists.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.whitelist = action.payload as CorsEntry[];
|
||||
});
|
||||
builder.addCase(fetchWhitelists.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Create Whitelist
|
||||
builder.addCase(createWhitelist.fulfilled, (state, action) => {
|
||||
state.whitelist.push(action.payload as CorsEntry);
|
||||
});
|
||||
|
||||
// Update Whitelist
|
||||
builder.addCase(updateWhitelist.fulfilled, (state, action) => {
|
||||
const index = state.whitelist.findIndex((item) => item.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.whitelist[index] = { ...state.whitelist[index], ...action.payload.data };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Whitelist
|
||||
builder.addCase(deleteWhitelist.fulfilled, (state, action) => {
|
||||
state.whitelist = state.whitelist.filter((item) => item.id !== action.payload);
|
||||
});
|
||||
|
||||
// Fetch Blacklists
|
||||
builder.addCase(fetchBlacklists.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchBlacklists.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.blacklist = action.payload as CorsEntry[];
|
||||
});
|
||||
builder.addCase(fetchBlacklists.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Create Blacklist
|
||||
builder.addCase(createBlacklist.fulfilled, (state, action) => {
|
||||
state.blacklist.push(action.payload as CorsEntry);
|
||||
});
|
||||
|
||||
// Update Blacklist
|
||||
builder.addCase(updateBlacklist.fulfilled, (state, action) => {
|
||||
const index = state.blacklist.findIndex((item) => item.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.blacklist[index] = { ...state.blacklist[index], ...action.payload.data };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Blacklist
|
||||
builder.addCase(deleteBlacklist.fulfilled, (state, action) => {
|
||||
state.blacklist = state.blacklist.filter((item) => item.id !== action.payload);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default corsSlice.reducer;
|
||||
256
lib/features/users/usersSlice.ts
Normal file
256
lib/features/users/usersSlice.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
// Users Slice for managing users and deleted users
|
||||
import { fetchClient } from "@/lib/api/fetchClient";
|
||||
import { User } from "@/lib/features/auth/authSlice";
|
||||
|
||||
/** API'den gelen kullanıcıyı frontend User tipine çevirir (avatar -> avatar_url, permissions null ise []). */
|
||||
function mapApiUserToUser(apiUser: Record<string, unknown>): User & { deleted_at?: string } {
|
||||
const roles = (apiUser.roles as User["roles"]) || [];
|
||||
return {
|
||||
id: String(apiUser.id),
|
||||
username: String(apiUser.username ?? ""),
|
||||
email: String(apiUser.email ?? ""),
|
||||
roles: roles.map((r) => ({ ...r, permissions: r.permissions ?? [] })),
|
||||
avatar_url: apiUser.avatar ? String(apiUser.avatar) : (apiUser.avatar_url as string | undefined),
|
||||
deleted_at: apiUser.deleted_at ? String(apiUser.deleted_at) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
roles?: string[];
|
||||
avatar?: File;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
id: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
roles?: string[];
|
||||
avatar?: File;
|
||||
}
|
||||
|
||||
interface UsersState {
|
||||
users: User[]; // Active users
|
||||
deletedUsers: (User & { deleted_at?: string })[]; // Soft-deleted users
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: UsersState = {
|
||||
users: [],
|
||||
deletedUsers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async Thunks
|
||||
export const fetchUsers = createAsyncThunk(
|
||||
"users/fetchAll",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/admin/users") as { users?: unknown[]; data?: unknown[]; pagination?: unknown };
|
||||
const rawList = response.users ?? response.data ?? (Array.isArray(response) ? response : null);
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
return list.map((u) => mapApiUserToUser(u as Record<string, unknown>));
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchDeletedUsers = createAsyncThunk(
|
||||
"users/fetchDeleted",
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetchClient("/admin/users/deleted") as { users?: unknown[]; data?: unknown[]; pagination?: unknown };
|
||||
const rawList = response.users ?? response.data ?? (Array.isArray(response) ? response : null);
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
return list.map((u) => mapApiUserToUser(u as Record<string, unknown>));
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createUser = createAsyncThunk(
|
||||
"users/create",
|
||||
async (userData: CreateUserRequest, { rejectWithValue }) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("user_name", userData.username);
|
||||
formData.append("email", userData.email);
|
||||
if (userData.password) formData.append("password", userData.password);
|
||||
|
||||
if (userData.roles && userData.roles.length > 0) {
|
||||
userData.roles.forEach((r) => formData.append("roles", r));
|
||||
}
|
||||
|
||||
if (userData.avatar) {
|
||||
formData.append("avatar", userData.avatar);
|
||||
}
|
||||
|
||||
const response = await fetchClient("/admin/users", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const raw = (response as { user?: Record<string, unknown> }).user ?? response;
|
||||
return mapApiUserToUser(raw as Record<string, unknown>);
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateUser = createAsyncThunk(
|
||||
"users/update",
|
||||
async (userData: UpdateUserRequest, { rejectWithValue }) => {
|
||||
try {
|
||||
const { id, username, email, password, roles, avatar } = userData;
|
||||
|
||||
const formData = new FormData();
|
||||
if (username !== undefined) formData.append("user_name", username);
|
||||
if (email !== undefined) formData.append("email", email);
|
||||
if (password) formData.append("password", password);
|
||||
if (roles && roles.length > 0) {
|
||||
roles.forEach((r) => formData.append("roles", r));
|
||||
}
|
||||
if (avatar) {
|
||||
formData.append("avatar", avatar);
|
||||
}
|
||||
|
||||
const response = await fetchClient(`/admin/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
const raw = (response as { user?: Record<string, unknown> }).user ?? response;
|
||||
return mapApiUserToUser(raw as Record<string, unknown>);
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteUser = createAsyncThunk(
|
||||
"users/delete",
|
||||
async (payload: string | { id: string; hard: boolean }, { rejectWithValue }) => {
|
||||
try {
|
||||
const id = typeof payload === "string" ? payload : payload.id;
|
||||
const isHard = typeof payload === "object" && payload.hard;
|
||||
|
||||
const endpoint = `/admin/users/${id}${isHard ? "?hard=true" : ""}`;
|
||||
|
||||
await fetchClient(endpoint, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return { id, isHard };
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const restoreUser = createAsyncThunk(
|
||||
"users/restore",
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await fetchClient(`/admin/users/${id}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
return id;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const usersSlice = createSlice({
|
||||
name: "users",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
// Fetch Active
|
||||
builder.addCase(fetchUsers.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
|
||||
state.isLoading = false;
|
||||
state.users = action.payload;
|
||||
});
|
||||
builder.addCase(fetchUsers.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Fetch Deleted
|
||||
builder.addCase(fetchDeletedUsers.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(fetchDeletedUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
|
||||
state.isLoading = false;
|
||||
state.deletedUsers = action.payload;
|
||||
});
|
||||
|
||||
// Create
|
||||
builder.addCase(createUser.fulfilled, (state, action: PayloadAction<User>) => {
|
||||
state.isLoading = false;
|
||||
if (action.payload && action.payload.id) {
|
||||
state.users.push(action.payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Update
|
||||
builder.addCase(updateUser.fulfilled, (state, action: PayloadAction<User>) => {
|
||||
state.isLoading = false;
|
||||
if (action.payload && action.payload.id) {
|
||||
const index = state.users.findIndex(u => u.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.users[index] = action.payload;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delete
|
||||
builder.addCase(deleteUser.fulfilled, (state, action: PayloadAction<{ id: string, isHard: boolean }>) => {
|
||||
state.isLoading = false;
|
||||
const { id, isHard } = action.payload;
|
||||
|
||||
// Remove from active users list (always happens)
|
||||
const deletedUser = state.users.find(u => u.id === id);
|
||||
state.users = state.users.filter(u => u.id !== id);
|
||||
|
||||
// If Soft Delete, add to deletedUsers list (mechanically we should re-fetch, but optimistically we can add if we had the full object)
|
||||
if (!isHard && deletedUser) {
|
||||
state.deletedUsers.unshift({ ...deletedUser, deleted_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
// If Hard Delete, remove from deletedUsers list too (checking if it was there)
|
||||
if (isHard) {
|
||||
state.deletedUsers = state.deletedUsers.filter(u => u.id !== id);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore
|
||||
builder.addCase(restoreUser.fulfilled, (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload;
|
||||
const restoredUser = state.deletedUsers.find(u => u.id === id);
|
||||
|
||||
// Remove from deleted list
|
||||
state.deletedUsers = state.deletedUsers.filter(u => u.id !== id);
|
||||
|
||||
// Add to active list
|
||||
if (restoredUser) {
|
||||
const { deleted_at, ...user } = restoredUser;
|
||||
state.users.push(user);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default usersSlice.reducer;
|
||||
8
lib/hooks.ts
Normal file
8
lib/hooks.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useDispatch, useSelector, useStore } from "react-redux";
|
||||
import type { TypedUseSelectorHook } from "react-redux";
|
||||
import type { AppDispatch, AppStore, RootState } from "./store";
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
export const useAppStore: () => AppStore = useStore;
|
||||
8
lib/schemas/login.ts
Normal file
8
lib/schemas/login.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("Geçerli bir email adresi giriniz"),
|
||||
password: z.string().min(1, "Şifre alanı zorunludur"),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
13
lib/schemas/register.ts
Normal file
13
lib/schemas/register.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const registerSchema = z.object({
|
||||
username: z.string().min(3, "Kullanıcı adı en az 3 karakter olmalıdır"),
|
||||
email: z.string().email("Geçerli bir email adresi giriniz"),
|
||||
password: z.string().min(6, "Şifre en az 6 karakter olmalıdır"),
|
||||
confirmPassword: z.string()
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Şifreler eşleşmiyor",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
20
lib/store.ts
Normal file
20
lib/store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import authReducer from "./features/auth/authSlice";
|
||||
import usersReducer from "./features/users/usersSlice";
|
||||
import corsReducer from "./features/cors/corsSlice";
|
||||
|
||||
export const makeStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
users: usersReducer,
|
||||
cors: corsReducer,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Infer the type of makeStore
|
||||
export type AppStore = ReturnType<typeof makeStore>;
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<AppStore["getState"]>;
|
||||
export type AppDispatch = AppStore["dispatch"];
|
||||
15
lib/utils.ts
Normal file
15
lib/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Backend'den gelen avatar_url tam veya relative olabilir; görüntülenebilir URL döner. */
|
||||
export function getAvatarUrl(avatarUrl: string | undefined): string | undefined {
|
||||
if (!avatarUrl) return undefined
|
||||
if (avatarUrl.startsWith("http://") || avatarUrl.startsWith("https://")) return avatarUrl
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE || ""
|
||||
const normalized = base.replace(/\/$/, "")
|
||||
return `${normalized}${avatarUrl.startsWith("/") ? "" : "/"}${avatarUrl}`
|
||||
}
|
||||
Reference in New Issue
Block a user