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>
390 lines
13 KiB
TypeScript
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;
|