Add question ownership and sharing
Questions now have a created_by field linking to the user who created them. Users only see questions they own or that have been shared with them. Includes share dialog, user search, bulk sharing, and export/import respects ownership. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,11 @@ import {
|
||||
downloadJobsAPI,
|
||||
} from "../../services/api";
|
||||
import AdminNavbar from "../common/AdminNavbar";
|
||||
import ShareDialog from "./ShareDialog";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
export default function QuestionBankView() {
|
||||
const { dbUser } = useAuth();
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -28,18 +31,20 @@ export default function QuestionBankView() {
|
||||
const [bulkCategory, setBulkCategory] = useState("");
|
||||
const [downloadJob, setDownloadJob] = useState(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [shareTarget, setShareTarget] = useState(null); // question or array of ids for sharing
|
||||
|
||||
// Filter and sort state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterOwner, setFilterOwner] = useState("");
|
||||
const [sortBy, setSortBy] = useState("created_at");
|
||||
const [sortOrder, setSortOrder] = useState("desc");
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions();
|
||||
loadCategories();
|
||||
}, [searchTerm, filterCategory, filterType, sortBy, sortOrder]);
|
||||
}, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]);
|
||||
|
||||
const loadQuestions = async () => {
|
||||
try {
|
||||
@@ -50,6 +55,7 @@ export default function QuestionBankView() {
|
||||
if (searchTerm) params.search = searchTerm;
|
||||
if (filterCategory) params.category = filterCategory;
|
||||
if (filterType) params.type = filterType;
|
||||
if (filterOwner) params.owner = filterOwner;
|
||||
|
||||
const response = await questionsAPI.getAll(params);
|
||||
setQuestions(response.data);
|
||||
@@ -688,13 +694,32 @@ export default function QuestionBankView() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Owner Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filterOwner}
|
||||
onChange={(e) => setFilterOwner(e.target.value)}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
minWidth: "130px",
|
||||
}}
|
||||
>
|
||||
<option value="">All Questions</option>
|
||||
<option value="mine">My Questions</option>
|
||||
<option value="shared">Shared with Me</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && (
|
||||
{(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setFilterCategory("");
|
||||
setFilterType("");
|
||||
setFilterOwner("");
|
||||
setSortBy("created_at");
|
||||
setSortOrder("desc");
|
||||
}}
|
||||
@@ -742,6 +767,19 @@ export default function QuestionBankView() {
|
||||
>
|
||||
Assign Category
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShareTarget(selectedQuestions)}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
background: "#9C27B0",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Share Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
style={{
|
||||
@@ -880,12 +918,21 @@ export default function QuestionBankView() {
|
||||
Answer
|
||||
<SortIndicator column="answer" />
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
...sortableHeaderStyle,
|
||||
width: "100px",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
Creator
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
textAlign: "center",
|
||||
borderBottom: "2px solid #ddd",
|
||||
width: "150px",
|
||||
width: "200px",
|
||||
}}
|
||||
>
|
||||
Actions
|
||||
@@ -954,6 +1001,16 @@ export default function QuestionBankView() {
|
||||
<td style={{ padding: "0.75rem", fontWeight: "bold" }}>
|
||||
{q.answer}
|
||||
</td>
|
||||
<td style={{ padding: "0.75rem" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: q.created_by === dbUser?.id ? "#1976d2" : "#666",
|
||||
}}
|
||||
>
|
||||
{q.created_by === dbUser?.id ? "You" : (q.creator_name || "Unknown")}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "0.75rem", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
@@ -962,34 +1019,52 @@ export default function QuestionBankView() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleEdit(q)}
|
||||
style={{
|
||||
padding: "0.4rem 0.8rem",
|
||||
background: "#2196F3",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(q.id)}
|
||||
style={{
|
||||
padding: "0.4rem 0.8rem",
|
||||
background: "#f44336",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{q.created_by === dbUser?.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(q)}
|
||||
style={{
|
||||
padding: "0.4rem 0.8rem",
|
||||
background: "#2196F3",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShareTarget(q)}
|
||||
style={{
|
||||
padding: "0.4rem 0.8rem",
|
||||
background: "#9C27B0",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(q.id)}
|
||||
style={{
|
||||
padding: "0.4rem 0.8rem",
|
||||
background: "#f44336",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1045,6 +1120,13 @@ export default function QuestionBankView() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shareTarget && (
|
||||
<ShareDialog
|
||||
target={shareTarget}
|
||||
onClose={() => setShareTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
252
frontend/frontend/src/components/questionbank/ShareDialog.jsx
Normal file
252
frontend/frontend/src/components/questionbank/ShareDialog.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { questionsAPI, usersAPI } from "../../services/api";
|
||||
|
||||
export default function ShareDialog({ target, onClose }) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [currentShares, setCurrentShares] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
const searchTimeout = useRef(null);
|
||||
|
||||
// Determine if this is a single question or bulk share
|
||||
const isBulk = Array.isArray(target);
|
||||
const questionId = isBulk ? null : target.id;
|
||||
|
||||
// Load current shares for single question
|
||||
useEffect(() => {
|
||||
if (!isBulk && questionId) {
|
||||
loadShares();
|
||||
}
|
||||
}, [questionId, isBulk]);
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
const response = await questionsAPI.getShares(questionId);
|
||||
setCurrentShares(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error loading shares:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await usersAPI.search(value.trim());
|
||||
setSearchResults(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleShare = async (userId) => {
|
||||
try {
|
||||
if (isBulk) {
|
||||
const response = await questionsAPI.bulkShare(target, userId);
|
||||
const { shared, skipped } = response.data;
|
||||
setMessage(`Shared ${shared.length} question(s). ${skipped.length > 0 ? `${skipped.length} skipped.` : ""}`);
|
||||
} else {
|
||||
await questionsAPI.share(questionId, userId);
|
||||
setMessage("Question shared successfully.");
|
||||
loadShares();
|
||||
}
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.error || "Error sharing question";
|
||||
setMessage(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnshare = async (userId) => {
|
||||
try {
|
||||
await questionsAPI.unshare(questionId, userId);
|
||||
loadShares();
|
||||
setMessage("Share removed.");
|
||||
} catch (error) {
|
||||
console.error("Error removing share:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const overlayStyle = {
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
};
|
||||
|
||||
const dialogStyle = {
|
||||
background: "white",
|
||||
borderRadius: "12px",
|
||||
padding: "1.5rem",
|
||||
width: "450px",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{isBulk ? `Share ${target.length} Question(s)` : "Share Question"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isBulk && (
|
||||
<p style={{ fontSize: "0.9rem", color: "#666", margin: "0 0 1rem 0" }}>
|
||||
{target.question_content?.substring(0, 80)}{target.question_content?.length > 80 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* User search */}
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name, username, or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.6rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.95rem",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{ marginBottom: "1rem", border: "1px solid #eee", borderRadius: "6px", overflow: "hidden" }}>
|
||||
{searchResults.map((user) => {
|
||||
const alreadyShared = currentShares.some(s => s.shared_with_user_id === user.id);
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
padding: "0.6rem 0.8rem",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.name || user.username || "Unknown"}</div>
|
||||
{user.email && (
|
||||
<div style={{ fontSize: "0.8rem", color: "#888" }}>{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
{alreadyShared ? (
|
||||
<span style={{ fontSize: "0.85rem", color: "#4CAF50" }}>Shared</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleShare(user.id)}
|
||||
style={{
|
||||
padding: "0.3rem 0.8rem",
|
||||
background: "#9C27B0",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSearching && (
|
||||
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>Searching...</p>
|
||||
)}
|
||||
|
||||
{/* Current shares (single question only) */}
|
||||
{!isBulk && currentShares.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: "0 0 0.5rem 0", fontSize: "0.95rem" }}>Shared with</h4>
|
||||
{currentShares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
style={{
|
||||
padding: "0.5rem 0.8rem",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "#f9f9f9",
|
||||
borderRadius: "6px",
|
||||
marginBottom: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<span>{share.shared_with_user_name || `User ${share.shared_with_user_id}`}</span>
|
||||
<button
|
||||
onClick={() => handleUnshare(share.shared_with_user_id)}
|
||||
style={{
|
||||
padding: "0.2rem 0.6rem",
|
||||
background: "#f44336",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBulk && currentShares.length === 0 && searchResults.length === 0 && !isSearching && (
|
||||
<p style={{ fontSize: "0.9rem", color: "#888", textAlign: "center" }}>
|
||||
Not shared with anyone yet. Search for a user above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p style={{ fontSize: "0.9rem", color: "#4CAF50", textAlign: "center", margin: "0.5rem 0 0 0" }}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [dbUser, setDbUser] = useState(null); // User record from DB (has .id for ownership checks)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
|
||||
// Fetch the DB user record (includes numeric id for ownership comparisons)
|
||||
const fetchDbUser = useCallback(async (idToken) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${idToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDbUser(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch DB user:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is already logged in (token in localStorage)
|
||||
const storedToken = localStorage.getItem('access_token');
|
||||
@@ -28,6 +44,7 @@ export function AuthProvider({ children }) {
|
||||
setAccessToken(storedToken);
|
||||
setUser({ profile: decoded });
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(storedIdToken);
|
||||
} else {
|
||||
// Token expired, clear storage
|
||||
console.log('AuthContext: Tokens expired');
|
||||
@@ -43,7 +60,7 @@ export function AuthProvider({ children }) {
|
||||
console.log('AuthContext: No tokens found in storage');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
}, [fetchDbUser]);
|
||||
|
||||
const login = () => {
|
||||
// Redirect to backend login endpoint
|
||||
@@ -84,6 +101,7 @@ export function AuthProvider({ children }) {
|
||||
setAccessToken(access_token);
|
||||
setUser({ profile: decoded });
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(id_token);
|
||||
|
||||
console.log('handleCallback: Auth state updated, isAuthenticated=true');
|
||||
|
||||
@@ -104,6 +122,7 @@ export function AuthProvider({ children }) {
|
||||
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
setDbUser(null);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
// Redirect to backend logout to clear cookies and get Authelia logout URL
|
||||
@@ -138,6 +157,7 @@ export function AuthProvider({ children }) {
|
||||
|
||||
const value = {
|
||||
user,
|
||||
dbUser,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
accessToken,
|
||||
|
||||
@@ -66,6 +66,12 @@ export const questionsAPI = {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
delete: (id) => api.delete(`/questions/${id}`),
|
||||
// Sharing
|
||||
getShares: (id) => api.get(`/questions/${id}/shares`),
|
||||
share: (id, userId) => api.post(`/questions/${id}/share`, { user_id: userId }),
|
||||
unshare: (id, userId) => api.delete(`/questions/${id}/share/${userId}`),
|
||||
bulkShare: (questionIds, userId) =>
|
||||
api.post("/questions/bulk-share", { question_ids: questionIds, user_id: userId }),
|
||||
};
|
||||
|
||||
// Games API
|
||||
@@ -135,4 +141,9 @@ export const audioControlAPI = {
|
||||
api.post(`/admin/game/${gameId}/audio/seek`, { position }),
|
||||
};
|
||||
|
||||
// Users API (for sharing)
|
||||
export const usersAPI = {
|
||||
search: (query) => api.get("/auth/users/search", { params: { q: query } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user