ppt-tool/frontend/store/slices/adminSlice.ts
Vadym Samoilenko 69a8829750 Phase 3: Bug fixes, feature enhancements, and polish
P0 Critical: presentation isolation (client scoping), storage super_admin fix,
template selection in worker, IMAGE_PROVIDERS list fix.

P1 High: template layout management UI (delete/filter/bulk), slide-based parsing
mode, LLM model listing & connection test, settings persistence to DB (Fernet
encryption), logout button.

P2 Polish: storage improvements (master deck files, per-client breakdown, bulk
delete, hard purge, client selector), image generation error visibility
(__image_error__ badge), hamster wheel loading animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:58:52 +00:00

390 lines
13 KiB
TypeScript

import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
export interface AdminUser {
id: string;
email: string;
display_name: string;
role: "super_admin" | "client_admin" | "user";
is_active: boolean;
last_login_at: string | null;
created_at: string | null;
}
export interface AdminClient {
id: string;
name: string;
slug: string;
logo_path: string | null;
retention_days: number | null;
review_policy: string;
is_active: boolean;
created_at: string | null;
}
export interface AdminTeam {
id: string;
name: string;
client_id: string | null;
is_default: boolean;
created_at: string | null;
members?: AdminUser[];
}
export interface AuditLogEntry {
id: string;
user_id: string | null;
action: string;
resource_type: string;
resource_id: string | null;
client_id: string | null;
details: Record<string, unknown> | null;
ip_address: string | null;
created_at: string | null;
}
export interface BrandConfig {
id: string;
client_id: string;
primary_colors: string[] | null;
secondary_colors: string[] | null;
fonts: Record<string, string> | null;
logo_paths: string[] | null;
voice_rules: string | null;
voice_examples: Array<{ good: string; bad: string }> | null;
guideline_doc_path: string | null;
}
export interface MasterDeck {
id: string;
client_id: string;
name: string;
description: string | null;
thumbnail_path: string | null;
parse_mode: string;
parse_status: string;
is_active: boolean;
layouts: Array<Record<string, unknown>> | null;
created_at: string | null;
}
interface AdminState {
users: AdminUser[];
clients: AdminClient[];
teams: AdminTeam[];
auditLogs: AuditLogEntry[];
brandConfig: BrandConfig | null;
masterDecks: MasterDeck[];
isLoading: boolean;
error: string | null;
}
const initialState: AdminState = {
users: [],
clients: [],
teams: [],
auditLogs: [],
brandConfig: null,
masterDecks: [],
isLoading: false,
error: null,
};
// --- Users ---
export const fetchUsers = createAsyncThunk(
"admin/fetchUsers",
async (_, { rejectWithValue }) => {
const res = await fetch("/api/v1/admin/users");
if (!res.ok) return rejectWithValue("Failed to fetch users");
return await res.json();
}
);
export const updateUserRole = createAsyncThunk(
"admin/updateUserRole",
async ({ userId, role }: { userId: string; role: string }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/users/${userId}/role?role=${role}`, { method: "PUT" });
if (!res.ok) return rejectWithValue("Failed to update role");
return await res.json();
}
);
export const deactivateUser = createAsyncThunk(
"admin/deactivateUser",
async (userId: string, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/users/${userId}`, { method: "DELETE" });
if (!res.ok) return rejectWithValue("Failed to deactivate user");
return userId;
}
);
// --- Clients ---
export const fetchClients = createAsyncThunk(
"admin/fetchClients",
async (_, { rejectWithValue }) => {
const res = await fetch("/api/v1/admin/clients");
if (!res.ok) return rejectWithValue("Failed to fetch clients");
return await res.json();
}
);
export const createClient = createAsyncThunk(
"admin/createClient",
async ({ name, review_policy }: { name: string; review_policy?: string }, { rejectWithValue }) => {
const res = await fetch("/api/v1/admin/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, review_policy: review_policy || "self_approve" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return rejectWithValue(data.detail || "Failed to create client");
}
return await res.json();
}
);
// --- Teams ---
export const fetchTeams = createAsyncThunk(
"admin/fetchTeams",
async (clientId?: string) => {
const url = clientId
? `/api/v1/admin/teams?client_id=${clientId}`
: "/api/v1/admin/teams";
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch teams");
return await res.json();
}
);
export const fetchTeamDetail = createAsyncThunk(
"admin/fetchTeamDetail",
async (teamId: string) => {
const res = await fetch(`/api/v1/admin/teams/${teamId}`);
if (!res.ok) throw new Error("Failed to fetch team");
return await res.json();
}
);
export const addTeamMember = createAsyncThunk(
"admin/addTeamMember",
async ({ teamId, userId }: { teamId: string; userId: string }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/teams/${teamId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return rejectWithValue(data.detail || "Failed to add member");
}
return await res.json();
}
);
export const removeTeamMember = createAsyncThunk(
"admin/removeTeamMember",
async ({ teamId, userId }: { teamId: string; userId: string }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/teams/${teamId}/members/${userId}`, { method: "DELETE" });
if (!res.ok) return rejectWithValue("Failed to remove member");
return { teamId, userId };
}
);
// --- Audit Logs ---
export const fetchAuditLogs = createAsyncThunk(
"admin/fetchAuditLogs",
async (params?: Record<string, string>) => {
const query = params ? "?" + new URLSearchParams(params).toString() : "";
const res = await fetch(`/api/v1/admin/audit-log${query}`);
if (!res.ok) throw new Error("Failed to fetch audit logs");
return await res.json();
}
);
// --- Brand Config ---
export const fetchBrandConfig = createAsyncThunk(
"admin/fetchBrandConfig",
async (clientId: string) => {
const res = await fetch(`/api/v1/admin/clients/${clientId}/brand`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error("Failed to fetch brand config");
}
return await res.json();
}
);
export const updateBrandConfig = createAsyncThunk(
"admin/updateBrandConfig",
async ({ clientId, data }: { clientId: string; data: Partial<BrandConfig> }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/clients/${clientId}/brand`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return rejectWithValue("Failed to update brand config");
return await res.json();
}
);
// --- Master Decks ---
export const fetchMasterDecks = createAsyncThunk(
"admin/fetchMasterDecks",
async (clientId: string) => {
const res = await fetch(`/api/v1/admin/clients/${clientId}/master-decks`);
if (!res.ok) throw new Error("Failed to fetch master decks");
return await res.json();
}
);
export const uploadMasterDeck = createAsyncThunk(
"admin/uploadMasterDeck",
async ({ clientId, file }: { clientId: string; file: File }, { rejectWithValue }) => {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/api/v1/admin/clients/${clientId}/master-decks`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return rejectWithValue(data.detail || "Failed to upload master deck");
}
return await res.json();
}
);
export const fetchMasterDeckDetail = createAsyncThunk(
"admin/fetchMasterDeckDetail",
async (deckId: string) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}`);
if (!res.ok) throw new Error("Failed to fetch master deck detail");
return await res.json();
}
);
export const updateMasterDeck = createAsyncThunk(
"admin/updateMasterDeck",
async ({ deckId, data }: { deckId: string; data: { name?: string; description?: string; is_active?: boolean } }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return rejectWithValue("Failed to update master deck");
return await res.json();
}
);
export const updateMasterDeckLayout = createAsyncThunk(
"admin/updateMasterDeckLayout",
async (
{ deckId, layoutIndex, data }: { deckId: string; layoutIndex: number; data: { layout_name?: string; layout_type?: string; react_code?: string } },
{ rejectWithValue }
) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}/layouts/${layoutIndex}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return rejectWithValue("Failed to update layout");
return await res.json();
}
);
export const reparseMasterDeck = createAsyncThunk(
"admin/reparseMasterDeck",
async ({ deckId, parseMode }: { deckId: string; parseMode?: string }, { rejectWithValue }) => {
const params = parseMode ? `?parse_mode=${parseMode}` : "";
const res = await fetch(`/api/v1/admin/master-decks/${deckId}/reparse${params}`, { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return rejectWithValue(data.detail || "Failed to trigger reparse");
}
return await res.json();
}
);
export const deleteMasterDeck = createAsyncThunk(
"admin/deleteMasterDeck",
async (deckId: string, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}`, { method: "DELETE" });
if (!res.ok) return rejectWithValue("Failed to delete master deck");
return deckId;
}
);
export const deleteMasterDeckLayout = createAsyncThunk(
"admin/deleteMasterDeckLayout",
async ({ deckId, layoutIndex }: { deckId: string; layoutIndex: number }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}/layouts/${layoutIndex}`, {
method: "DELETE",
});
if (!res.ok) return rejectWithValue("Failed to delete layout");
return { deckId, layoutIndex };
}
);
export const bulkDeleteMasterDeckLayouts = createAsyncThunk(
"admin/bulkDeleteMasterDeckLayouts",
async ({ deckId, indices }: { deckId: string; indices: number[] }, { rejectWithValue }) => {
const res = await fetch(`/api/v1/admin/master-decks/${deckId}/layouts/bulk-delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ indices }),
});
if (!res.ok) return rejectWithValue("Failed to delete layouts");
return await res.json();
}
);
const adminSlice = createSlice({
name: "admin",
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
// Users
builder
.addCase(fetchUsers.pending, (state) => { state.isLoading = true; state.error = null; })
.addCase(fetchUsers.fulfilled, (state, action) => { state.users = action.payload; state.isLoading = false; })
.addCase(fetchUsers.rejected, (state, action) => { state.isLoading = false; state.error = action.payload as string; })
.addCase(deactivateUser.fulfilled, (state, action) => {
state.users = state.users.map((u) => u.id === action.payload ? { ...u, is_active: false } : u);
})
// Clients
.addCase(fetchClients.pending, (state) => { state.isLoading = true; state.error = null; })
.addCase(fetchClients.fulfilled, (state, action) => { state.clients = action.payload; state.isLoading = false; })
.addCase(fetchClients.rejected, (state, action) => { state.isLoading = false; state.error = action.payload as string; })
.addCase(createClient.fulfilled, (state, action) => {
state.clients.push(action.payload);
})
// Teams
.addCase(fetchTeams.fulfilled, (state, action) => { state.teams = action.payload; })
// Audit Logs
.addCase(fetchAuditLogs.fulfilled, (state, action) => { state.auditLogs = action.payload; })
// Brand Config
.addCase(fetchBrandConfig.fulfilled, (state, action) => { state.brandConfig = action.payload; })
.addCase(updateBrandConfig.fulfilled, (state, action) => { state.brandConfig = action.payload; })
// Master Decks
.addCase(fetchMasterDecks.fulfilled, (state, action) => { state.masterDecks = action.payload; })
.addCase(uploadMasterDeck.fulfilled, (state, action) => {
state.masterDecks.unshift(action.payload);
})
.addCase(deleteMasterDeck.fulfilled, (state, action) => {
state.masterDecks = state.masterDecks.filter((d) => d.id !== action.payload);
});
},
});
export const { clearError } = adminSlice.actions;
export default adminSlice.reducer;