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:
2026-04-03 09:43:04 -04:00
parent 02fcbad9ba
commit 69992f1be9
15 changed files with 836 additions and 70 deletions

View File

@@ -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)}
/>
)}
</>
);
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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;