nothingworry commited on
Commit
0122657
·
1 Parent(s): 7d2d8f1

update frontend

Browse files
frontend/README.md CHANGED
@@ -31,6 +31,12 @@ Update `.env.local` if your backend runs elsewhere:
31
  NEXT_PUBLIC_API_URL=http://localhost:8000
32
  ```
33
 
 
 
 
 
 
 
34
  ## Features
35
 
36
  ### Main Landing Page (`/`)
@@ -49,7 +55,7 @@ NEXT_PUBLIC_API_URL=http://localhost:8000
49
  - PDF file uploads
50
  - DOCX file uploads
51
  - TXT and Markdown file uploads
52
- - **Document management** with tenant isolation:
53
  - Delete individual documents by ID
54
  - Delete all documents for a tenant (with confirmation)
55
  - Real-time document list updates after operations
 
31
  NEXT_PUBLIC_API_URL=http://localhost:8000
32
  ```
33
 
34
+ ### Tenant & Role selector
35
+
36
+ - The navbar widget now stores both the tenant ID and the MCP role (Viewer, Editor, Admin, Owner) in `localStorage`.
37
+ - Every API call automatically includes `x-tenant-id` and `x-user-role` headers so the backend RBAC layer can authorize ingestion, admin rule uploads, analytics, and delete operations.
38
+ - If you see a 403 "insufficient permissions" error, switch the role dropdown to a higher privilege (e.g., Admin) before retrying the action.
39
+
40
  ## Features
41
 
42
  ### Main Landing Page (`/`)
 
55
  - PDF file uploads
56
  - DOCX file uploads
57
  - TXT and Markdown file uploads
58
+ - **Document management** with tenant + role isolation:
59
  - Delete individual documents by ID
60
  - Delete all documents for a tenant (with confirmation)
61
  - Real-time document list updates after operations
frontend/app/admin-rules/page.tsx CHANGED
@@ -12,8 +12,36 @@ const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://loc
12
 
13
  type StatusState = { tone: "info" | "success" | "error"; message: string } | null;
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  export default function AdminRulesPage() {
16
- const { tenantId } = useTenant();
17
  const [rulesInput, setRulesInput] = useState("");
18
  const [deleteInput, setDeleteInput] = useState("");
19
  const [rules, setRules] = useState<string[]>([]);
@@ -33,8 +61,9 @@ export default function AdminRulesPage() {
33
  return {
34
  "Content-Type": "application/json",
35
  "x-tenant-id": tenantId.trim(),
 
36
  };
37
- }, [tenantId]);
38
 
39
  const requireTenant = useCallback(() => {
40
  if (!tenantId.trim()) {
@@ -54,7 +83,7 @@ export default function AdminRulesPage() {
54
  headers,
55
  });
56
  if (!response.ok) {
57
- throw new Error(`Backend error ${response.status}`);
58
  }
59
  const data = await response.json();
60
  setRules(data.rules ?? []);
@@ -88,8 +117,7 @@ export default function AdminRulesPage() {
88
  body: JSON.stringify({ rules: lines }),
89
  });
90
  if (!response.ok) {
91
- const details = await response.text();
92
- throw new Error(details || `Backend error ${response.status}`);
93
  }
94
  const data = await response.json();
95
  await handleRefresh();
@@ -132,15 +160,14 @@ export default function AdminRulesPage() {
132
 
133
  // Upload rules via bulk endpoint
134
  setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` });
135
- const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, {
136
  method: "POST",
137
  headers,
138
  body: JSON.stringify({ rules: lines }),
139
  });
140
 
141
  if (!response.ok) {
142
- const details = await response.text();
143
- throw new Error(details || `Backend error ${response.status}`);
144
  }
145
 
146
  const data = await response.json();
@@ -159,13 +186,13 @@ export default function AdminRulesPage() {
159
  method: "POST",
160
  headers: {
161
  "x-tenant-id": tenantId.trim(),
 
162
  },
163
  body: formData,
164
  });
165
 
166
  if (!response.ok) {
167
- const details = await response.text();
168
- throw new Error(details || `Backend error ${response.status}`);
169
  }
170
 
171
  const data = await response.json();
@@ -238,8 +265,7 @@ export default function AdminRulesPage() {
238
  }
239
  );
240
  if (!response.ok) {
241
- const details = await response.text();
242
- throw new Error(details || `Backend error ${response.status}`);
243
  }
244
  await handleRefresh();
245
  setDeleteInput("");
 
12
 
13
  type StatusState = { tone: "info" | "success" | "error"; message: string } | null;
14
 
15
+ const RBAC_ERROR_HINT =
16
+ "Insufficient permissions for this action. Switch your role to Admin or Owner in the navbar and try again.";
17
+
18
+ async function buildErrorMessage(response: Response) {
19
+ const fallback = `Backend error ${response.status}`;
20
+ try {
21
+ const text = await response.text();
22
+ if (!text) {
23
+ return response.status === 403 ? RBAC_ERROR_HINT : fallback;
24
+ }
25
+ try {
26
+ const parsed = JSON.parse(text);
27
+ const detail = parsed.detail || parsed.message;
28
+ if (response.status === 403) {
29
+ return detail || RBAC_ERROR_HINT;
30
+ }
31
+ return detail || fallback;
32
+ } catch {
33
+ if (response.status === 403) {
34
+ return text || RBAC_ERROR_HINT;
35
+ }
36
+ return text || fallback;
37
+ }
38
+ } catch {
39
+ return response.status === 403 ? RBAC_ERROR_HINT : fallback;
40
+ }
41
+ }
42
+
43
  export default function AdminRulesPage() {
44
+ const { tenantId, role } = useTenant();
45
  const [rulesInput, setRulesInput] = useState("");
46
  const [deleteInput, setDeleteInput] = useState("");
47
  const [rules, setRules] = useState<string[]>([]);
 
61
  return {
62
  "Content-Type": "application/json",
63
  "x-tenant-id": tenantId.trim(),
64
+ "x-user-role": role,
65
  };
66
+ }, [tenantId, role]);
67
 
68
  const requireTenant = useCallback(() => {
69
  if (!tenantId.trim()) {
 
83
  headers,
84
  });
85
  if (!response.ok) {
86
+ throw new Error(await buildErrorMessage(response));
87
  }
88
  const data = await response.json();
89
  setRules(data.rules ?? []);
 
117
  body: JSON.stringify({ rules: lines }),
118
  });
119
  if (!response.ok) {
120
+ throw new Error(await buildErrorMessage(response));
 
121
  }
122
  const data = await response.json();
123
  await handleRefresh();
 
160
 
161
  // Upload rules via bulk endpoint
162
  setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` });
163
+ const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, {
164
  method: "POST",
165
  headers,
166
  body: JSON.stringify({ rules: lines }),
167
  });
168
 
169
  if (!response.ok) {
170
+ throw new Error(await buildErrorMessage(response));
 
171
  }
172
 
173
  const data = await response.json();
 
186
  method: "POST",
187
  headers: {
188
  "x-tenant-id": tenantId.trim(),
189
+ "x-user-role": role,
190
  },
191
  body: formData,
192
  });
193
 
194
  if (!response.ok) {
195
+ throw new Error(await buildErrorMessage(response));
 
196
  }
197
 
198
  const data = await response.json();
 
265
  }
266
  );
267
  if (!response.ok) {
268
+ throw new Error(await buildErrorMessage(response));
 
269
  }
270
  await handleRefresh();
271
  setDeleteInput("");
frontend/app/knowledge-base/page.tsx CHANGED
@@ -21,7 +21,7 @@ const API_BASE =
21
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
22
 
23
  export default function KnowledgeBasePage() {
24
- const { tenantId, isLoading: tenantLoading } = useTenant();
25
  const [documents, setDocuments] = useState<Document[]>([]);
26
  const [total, setTotal] = useState(0);
27
  const [loading, setLoading] = useState(false);
@@ -43,14 +43,12 @@ export default function KnowledgeBasePage() {
43
  setError(null);
44
 
45
  try {
46
- const response = await fetch(
47
- `${API_BASE}/rag/list?limit=1000&offset=0`,
48
- {
49
- headers: {
50
- "x-tenant-id": tenantId,
51
- },
52
  },
53
- );
54
 
55
  if (!response.ok) {
56
  const errorData = await response.json().catch(() => ({}));
@@ -155,6 +153,7 @@ export default function KnowledgeBasePage() {
155
  method: "DELETE",
156
  headers: {
157
  "x-tenant-id": tenantId,
 
158
  },
159
  });
160
 
@@ -193,6 +192,7 @@ export default function KnowledgeBasePage() {
193
  method: "DELETE",
194
  headers: {
195
  "x-tenant-id": tenantId,
 
196
  },
197
  });
198
 
 
21
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
22
 
23
  export default function KnowledgeBasePage() {
24
+ const { tenantId, isLoading: tenantLoading, role } = useTenant();
25
  const [documents, setDocuments] = useState<Document[]>([]);
26
  const [total, setTotal] = useState(0);
27
  const [loading, setLoading] = useState(false);
 
43
  setError(null);
44
 
45
  try {
46
+ const response = await fetch(`${API_BASE}/rag/list?limit=1000&offset=0`, {
47
+ headers: {
48
+ "x-tenant-id": tenantId,
49
+ "x-user-role": role,
 
 
50
  },
51
+ });
52
 
53
  if (!response.ok) {
54
  const errorData = await response.json().catch(() => ({}));
 
153
  method: "DELETE",
154
  headers: {
155
  "x-tenant-id": tenantId,
156
+ "x-user-role": role,
157
  },
158
  });
159
 
 
192
  method: "DELETE",
193
  headers: {
194
  "x-tenant-id": tenantId,
195
+ "x-user-role": role,
196
  },
197
  });
198
 
frontend/components/analytics-panel.tsx CHANGED
@@ -24,7 +24,7 @@ const API_BASE =
24
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
25
 
26
  export function AnalyticsPanel() {
27
- const { tenantId } = useTenant();
28
  const [loading, setLoading] = useState(false);
29
  const [error, setError] = useState<string | null>(null);
30
  const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
@@ -36,6 +36,7 @@ export function AnalyticsPanel() {
36
  const res = await fetch(`${API_BASE}/analytics/overview`, {
37
  headers: {
38
  "x-tenant-id": tenantId,
 
39
  },
40
  });
41
  if (!res.ok) {
 
24
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
25
 
26
  export function AnalyticsPanel() {
27
+ const { tenantId, role } = useTenant();
28
  const [loading, setLoading] = useState(false);
29
  const [error, setError] = useState<string | null>(null);
30
  const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
 
36
  const res = await fetch(`${API_BASE}/analytics/overview`, {
37
  headers: {
38
  "x-tenant-id": tenantId,
39
+ "x-user-role": role,
40
  },
41
  });
42
  if (!res.ok) {
frontend/components/knowledge-base-panel.tsx CHANGED
@@ -22,7 +22,7 @@ const API_BASE =
22
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
23
 
24
  export function KnowledgeBasePanel() {
25
- const { tenantId, isLoading: tenantLoading } = useTenant();
26
  const [searchQuery, setSearchQuery] = useState("");
27
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
28
  const [isSearching, setIsSearching] = useState(false);
@@ -51,6 +51,7 @@ export function KnowledgeBasePanel() {
51
  headers: {
52
  "Content-Type": "application/json",
53
  "x-tenant-id": tenantId,
 
54
  },
55
  body: JSON.stringify({ query: searchQuery }),
56
  });
@@ -115,6 +116,7 @@ export function KnowledgeBasePanel() {
115
  method: "POST",
116
  headers: {
117
  "x-tenant-id": tenantId,
 
118
  },
119
  body: formData,
120
  });
@@ -172,6 +174,7 @@ export function KnowledgeBasePanel() {
172
  headers: {
173
  "Content-Type": "application/json",
174
  "x-tenant-id": tenantId,
 
175
  },
176
  body: JSON.stringify({
177
  action: "ingest_document",
@@ -223,6 +226,7 @@ export function KnowledgeBasePanel() {
223
  method: "GET",
224
  headers: {
225
  "x-tenant-id": tenantId,
 
226
  },
227
  });
228
 
@@ -264,6 +268,7 @@ export function KnowledgeBasePanel() {
264
  method: "DELETE",
265
  headers: {
266
  "x-tenant-id": tenantId,
 
267
  },
268
  });
269
 
@@ -302,6 +307,7 @@ export function KnowledgeBasePanel() {
302
  method: "DELETE",
303
  headers: {
304
  "x-tenant-id": tenantId,
 
305
  },
306
  });
307
 
 
22
  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
23
 
24
  export function KnowledgeBasePanel() {
25
+ const { tenantId, isLoading: tenantLoading, role } = useTenant();
26
  const [searchQuery, setSearchQuery] = useState("");
27
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
28
  const [isSearching, setIsSearching] = useState(false);
 
51
  headers: {
52
  "Content-Type": "application/json",
53
  "x-tenant-id": tenantId,
54
+ "x-user-role": role,
55
  },
56
  body: JSON.stringify({ query: searchQuery }),
57
  });
 
116
  method: "POST",
117
  headers: {
118
  "x-tenant-id": tenantId,
119
+ "x-user-role": role,
120
  },
121
  body: formData,
122
  });
 
174
  headers: {
175
  "Content-Type": "application/json",
176
  "x-tenant-id": tenantId,
177
+ "x-user-role": role,
178
  },
179
  body: JSON.stringify({
180
  action: "ingest_document",
 
226
  method: "GET",
227
  headers: {
228
  "x-tenant-id": tenantId,
229
+ "x-user-role": role,
230
  },
231
  });
232
 
 
268
  method: "DELETE",
269
  headers: {
270
  "x-tenant-id": tenantId,
271
+ "x-user-role": role,
272
  },
273
  });
274
 
 
307
  method: "DELETE",
308
  headers: {
309
  "x-tenant-id": tenantId,
310
+ "x-user-role": role,
311
  },
312
  });
313
 
frontend/components/tenant-selector.tsx CHANGED
@@ -3,17 +3,33 @@
3
  import { useTenant } from "@/contexts/TenantContext";
4
 
5
  export function TenantSelector() {
6
- const { tenantId, setTenantId } = useTenant();
7
 
8
  return (
9
- <div className="flex items-center gap-3">
10
- <label className="text-sm font-semibold text-slate-200">Tenant ID:</label>
11
- <input
12
- value={tenantId}
13
- onChange={(e) => setTenantId(e.target.value)}
14
- placeholder="Enter tenant ID"
15
- className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-2 text-sm text-white outline-none focus:border-cyan-400 min-w-[150px]"
16
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  </div>
18
  );
19
  }
 
3
  import { useTenant } from "@/contexts/TenantContext";
4
 
5
  export function TenantSelector() {
6
+ const { tenantId, setTenantId, role, setRole, availableRoles } = useTenant();
7
 
8
  return (
9
+ <div className="flex flex-col gap-2 text-sm text-slate-200">
10
+ <div className="flex items-center gap-2">
11
+ <label className="font-semibold text-slate-200">Tenant ID:</label>
12
+ <input
13
+ value={tenantId}
14
+ onChange={(e) => setTenantId(e.target.value)}
15
+ placeholder="Enter tenant ID"
16
+ className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-2 text-sm text-white outline-none focus:border-cyan-400 min-w-[150px]"
17
+ />
18
+ </div>
19
+ <div className="flex items-center gap-2">
20
+ <label className="font-semibold text-slate-200">Role:</label>
21
+ <select
22
+ value={role}
23
+ onChange={(e) => setRole(e.target.value as typeof availableRoles[number])}
24
+ className="rounded-xl border border-white/10 bg-slate-900/50 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400"
25
+ >
26
+ {availableRoles.map((roleOption) => (
27
+ <option key={roleOption} value={roleOption} className="bg-slate-900 text-white">
28
+ {roleOption.charAt(0).toUpperCase() + roleOption.slice(1)}
29
+ </option>
30
+ ))}
31
+ </select>
32
+ </div>
33
  </div>
34
  );
35
  }
frontend/contexts/TenantContext.tsx CHANGED
@@ -1,11 +1,20 @@
1
  "use client";
2
 
3
  import { createContext, useContext, useState, useEffect, ReactNode } from "react";
4
- import { DEFAULT_TENANT_ID, TENANT_STORAGE_KEY } from "@/lib/constants";
 
 
 
 
 
 
5
 
6
  type TenantContextType = {
7
  tenantId: string;
8
  setTenantId: (id: string) => void;
 
 
 
9
  isLoading: boolean;
10
  };
11
 
@@ -13,12 +22,15 @@ const TenantContext = createContext<TenantContextType | undefined>(undefined);
13
 
14
  export function TenantProvider({ children }: { children: ReactNode }) {
15
  const [tenantId, setTenantIdState] = useState("");
 
16
  const [isLoading, setIsLoading] = useState(true);
17
 
18
  // Load from localStorage on mount
19
  useEffect(() => {
20
- const saved = localStorage.getItem(TENANT_STORAGE_KEY);
21
- setTenantIdState(saved || DEFAULT_TENANT_ID);
 
 
22
  setIsLoading(false);
23
  }, []);
24
 
@@ -32,8 +44,15 @@ export function TenantProvider({ children }: { children: ReactNode }) {
32
  }
33
  };
34
 
 
 
 
 
 
35
  return (
36
- <TenantContext.Provider value={{ tenantId, setTenantId, isLoading }}>
 
 
37
  {children}
38
  </TenantContext.Provider>
39
  );
 
1
  "use client";
2
 
3
  import { createContext, useContext, useState, useEffect, ReactNode } from "react";
4
+ import {
5
+ DEFAULT_TENANT_ID,
6
+ TENANT_STORAGE_KEY,
7
+ DEFAULT_USER_ROLE,
8
+ ROLE_STORAGE_KEY,
9
+ USER_ROLES,
10
+ } from "@/lib/constants";
11
 
12
  type TenantContextType = {
13
  tenantId: string;
14
  setTenantId: (id: string) => void;
15
+ role: (typeof USER_ROLES)[number];
16
+ setRole: (role: (typeof USER_ROLES)[number]) => void;
17
+ availableRoles: typeof USER_ROLES;
18
  isLoading: boolean;
19
  };
20
 
 
22
 
23
  export function TenantProvider({ children }: { children: ReactNode }) {
24
  const [tenantId, setTenantIdState] = useState("");
25
+ const [role, setRoleState] = useState<(typeof USER_ROLES)[number]>(DEFAULT_USER_ROLE);
26
  const [isLoading, setIsLoading] = useState(true);
27
 
28
  // Load from localStorage on mount
29
  useEffect(() => {
30
+ const savedTenant = localStorage.getItem(TENANT_STORAGE_KEY);
31
+ const savedRole = localStorage.getItem(ROLE_STORAGE_KEY) as (typeof USER_ROLES)[number] | null;
32
+ setTenantIdState(savedTenant || DEFAULT_TENANT_ID);
33
+ setRoleState(savedRole && USER_ROLES.includes(savedRole) ? savedRole : DEFAULT_USER_ROLE);
34
  setIsLoading(false);
35
  }, []);
36
 
 
44
  }
45
  };
46
 
47
+ const setRole = (newRole: (typeof USER_ROLES)[number]) => {
48
+ setRoleState(newRole);
49
+ localStorage.setItem(ROLE_STORAGE_KEY, newRole);
50
+ };
51
+
52
  return (
53
+ <TenantContext.Provider
54
+ value={{ tenantId, setTenantId, role, setRole, availableRoles: USER_ROLES, isLoading }}
55
+ >
56
  {children}
57
  </TenantContext.Provider>
58
  );
frontend/lib/constants.ts CHANGED
@@ -1,3 +1,6 @@
1
  export const DEFAULT_TENANT_ID = "tenant123";
2
  export const TENANT_STORAGE_KEY = "integrachat_tenant_id";
 
 
 
3
 
 
1
  export const DEFAULT_TENANT_ID = "tenant123";
2
  export const TENANT_STORAGE_KEY = "integrachat_tenant_id";
3
+ export const DEFAULT_USER_ROLE = "viewer";
4
+ export const ROLE_STORAGE_KEY = "integrachat_user_role";
5
+ export const USER_ROLES = ["viewer", "editor", "admin", "owner"] as const;
6