feat: add security headers to nginx configurations and improve environment variable handling
This commit is contained in:
@@ -96,6 +96,8 @@ DEFAULT_SECONDARY_COLOR="#8B5CF6"
|
|||||||
# false = Production (certificados reales)
|
# false = Production (certificados reales)
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
LETSENCRYPT_STAGING=true
|
LETSENCRYPT_STAGING=true
|
||||||
|
# Email para notificaciones de Let's Encrypt (renovación, errores)
|
||||||
|
ACME_EMAIL=admin@example.com
|
||||||
|
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
# Frontend URLs (para producción con SSL)
|
# Frontend URLs (para producción con SSL)
|
||||||
|
|||||||
+2
-2
@@ -35,7 +35,7 @@ services:
|
|||||||
- html:/usr/share/nginx/html
|
- html:/usr/share/nginx/html
|
||||||
- ./nginx/certs-data:/etc/acme.sh:rw
|
- ./nginx/certs-data:/etc/acme.sh:rw
|
||||||
environment:
|
environment:
|
||||||
- DEFAULT_EMAIL=admin@norteamericano.com
|
- DEFAULT_EMAIL=${ACME_EMAIL:?ACME_EMAIL env var must be set}
|
||||||
- NGINX_PROXY_CONTAINER=nginx-proxy
|
- NGINX_PROXY_CONTAINER=nginx-proxy
|
||||||
- LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true}
|
- LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
container_name: openccb-db
|
container_name: openccb-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD env var must be set}
|
||||||
POSTGRES_DB: openccb
|
POSTGRES_DB: openccb
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# Custom nginx configuration for OpenCCB Learning
|
# Custom nginx configuration for OpenCCB Learning
|
||||||
# Keep the learning frontend on port 3003 and expose LMS API via same-origin /lms-api.
|
# Keep the learning frontend on port 3003 and expose LMS API via same-origin /lms-api.
|
||||||
|
|
||||||
|
# Security headers (server level)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
location /lms-api/ {
|
location /lms-api/ {
|
||||||
rewrite ^/lms-api/(.*)$ /$1 break;
|
rewrite ^/lms-api/(.*)$ /$1 break;
|
||||||
proxy_pass http://openccb-experience:3002;
|
proxy_pass http://openccb-experience:3002;
|
||||||
@@ -30,11 +37,15 @@ location /cms-api/ {
|
|||||||
add_header Access-Control-Max-Age 86400 always;
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
add_header Content-Length 0 always;
|
add_header Content-Length 0 always;
|
||||||
add_header Content-Type "text/plain; charset=utf-8" always;
|
add_header Content-Type "text/plain; charset=utf-8" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
add_header Vary "Origin" always;
|
add_header Vary "Origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
rewrite ^/cms-api/(.*)$ /$1 break;
|
rewrite ^/cms-api/(.*)$ /$1 break;
|
||||||
proxy_pass http://openccb-studio:3001;
|
proxy_pass http://openccb-studio:3001;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# Custom nginx configuration for OpenCCB Studio
|
# Custom nginx configuration for OpenCCB Studio
|
||||||
# This overrides the default location block to route API requests correctly
|
# This overrides the default location block to route API requests correctly
|
||||||
|
|
||||||
|
# Security headers (server level - heredados por location blocks sin add_header propios)
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
# Allow large ZIP uploads (RAG bulk import can exceed 2GB).
|
# Allow large ZIP uploads (RAG bulk import can exceed 2GB).
|
||||||
client_max_body_size 4096m;
|
client_max_body_size 4096m;
|
||||||
client_body_timeout 43200s;
|
client_body_timeout 43200s;
|
||||||
@@ -45,11 +52,15 @@ location /cms-api/ {
|
|||||||
add_header Access-Control-Max-Age 86400 always;
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
add_header Content-Length 0 always;
|
add_header Content-Length 0 always;
|
||||||
add_header Content-Type "text/plain; charset=utf-8" always;
|
add_header Content-Type "text/plain; charset=utf-8" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||||
add_header Vary "Origin" always;
|
add_header Vary "Origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
rewrite ^/cms-api/(.*)$ /$1 break;
|
rewrite ^/cms-api/(.*)$ /$1 break;
|
||||||
proxy_pass http://openccb-studio:3001;
|
proxy_pass http://openccb-studio:3001;
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ async fn main() {
|
|||||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||||
let origin_str = origin.to_str().unwrap_or("");
|
let origin_str = origin.to_str().unwrap_or("");
|
||||||
|
|
||||||
// Orígenes de desarrollo
|
// Allowlist explícita de orígenes permitidos
|
||||||
let allowed_origins = [
|
let allowed_origins = [
|
||||||
|
// Desarrollo local
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3003",
|
"http://localhost:3003",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
@@ -138,41 +139,14 @@ async fn main() {
|
|||||||
"http://192.168.0.254:3000",
|
"http://192.168.0.254:3000",
|
||||||
"http://192.168.0.254:3003",
|
"http://192.168.0.254:3003",
|
||||||
"http://192.168.0.254",
|
"http://192.168.0.254",
|
||||||
// Producción - Dominios de Norteamericano (.cl y .com)
|
// Producción - solo HTTPS
|
||||||
"http://studio.norteamericano.com",
|
|
||||||
"https://studio.norteamericano.com",
|
"https://studio.norteamericano.com",
|
||||||
"http://learning.norteamericano.com",
|
|
||||||
"https://learning.norteamericano.com",
|
"https://learning.norteamericano.com",
|
||||||
"http://studio.norteamericano.cl",
|
|
||||||
"https://studio.norteamericano.cl",
|
"https://studio.norteamericano.cl",
|
||||||
"http://learning.norteamericano.cl",
|
|
||||||
"https://learning.norteamericano.cl",
|
"https://learning.norteamericano.cl",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Comprobar coincidencias exactas
|
allowed_origins.contains(&origin_str)
|
||||||
if allowed_origins.contains(&origin_str) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comprobar comodín para subdominios en norteamericano.cl/.com sobre HTTP(S)
|
|
||||||
for scheme in ["http://", "https://"] {
|
|
||||||
for domain in [".norteamericano.cl", ".norteamericano.com"] {
|
|
||||||
if origin_str.starts_with(scheme) && origin_str.ends_with(domain) {
|
|
||||||
let subdomain = origin_str
|
|
||||||
.strip_prefix(scheme)
|
|
||||||
.unwrap_or("")
|
|
||||||
.strip_suffix(domain)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Permitir cualquier subdominio (ej., api., cdn., admin., etc.)
|
|
||||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}))
|
}))
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD])
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
|
|||||||
@@ -9,17 +9,20 @@ pub async fn set_session_context(
|
|||||||
event_type: Option<String>,
|
event_type: Option<String>,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
if let Some(uid) = user_id {
|
if let Some(uid) = user_id {
|
||||||
sqlx::query(&format!("SET LOCAL app.current_user_id = '{}'", uid))
|
sqlx::query("SELECT set_config('app.current_user_id', $1, true)")
|
||||||
|
.bind(uid.to_string())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(oid) = org_id {
|
if let Some(oid) = org_id {
|
||||||
sqlx::query(&format!("SET LOCAL app.current_org_id = '{}'", oid))
|
sqlx::query("SELECT set_config('app.current_org_id', $1, true)")
|
||||||
|
.bind(oid.to_string())
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(ip_addr) = ip {
|
if let Some(ip_addr) = ip {
|
||||||
sqlx::query(&format!("SET LOCAL app.client_ip = '{}'", ip_addr))
|
sqlx::query("SELECT set_config('app.client_ip', $1, true)")
|
||||||
|
.bind(ip_addr)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ async fn main() {
|
|||||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||||
let origin_str = origin.to_str().unwrap_or("");
|
let origin_str = origin.to_str().unwrap_or("");
|
||||||
|
|
||||||
// Orígenes de desarrollo
|
// Allowlist explícita de orígenes permitidos
|
||||||
let allowed_origins = [
|
let allowed_origins = [
|
||||||
|
// Desarrollo local
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3003",
|
"http://localhost:3003",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
@@ -103,31 +104,12 @@ async fn main() {
|
|||||||
"http://192.168.0.254:3000",
|
"http://192.168.0.254:3000",
|
||||||
"http://192.168.0.254:3003",
|
"http://192.168.0.254:3003",
|
||||||
"http://192.168.0.254",
|
"http://192.168.0.254",
|
||||||
// Producción - Dominios de Norteamericano (HTTPS)
|
// Producción - solo HTTPS
|
||||||
"https://studio.norteamericano.cl",
|
"https://studio.norteamericano.cl",
|
||||||
"https://learning.norteamericano.cl",
|
"https://learning.norteamericano.cl",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Comprobar coincidencias exactas
|
allowed_origins.contains(&origin_str)
|
||||||
if allowed_origins.contains(&origin_str) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comprobar comodín para subdominios: https://*.norteamericano.cl
|
|
||||||
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
|
|
||||||
let subdomain = origin_str
|
|
||||||
.strip_prefix("https://")
|
|
||||||
.unwrap_or("")
|
|
||||||
.strip_suffix(".norteamericano.cl")
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Permitir cualquier subdominio (p. ej., api., cdn., admin., etc.)
|
|
||||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}))
|
}))
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||||
.allow_headers([
|
.allow_headers([
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub fn create_jwt(
|
|||||||
token_type: Some("access".to_string()),
|
token_type: Some("access".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set");
|
||||||
encode(
|
encode(
|
||||||
&Header::default(),
|
&Header::default(),
|
||||||
&claims,
|
&claims,
|
||||||
@@ -59,7 +59,7 @@ pub fn create_preview_token(
|
|||||||
token_type: Some("preview".to_string()),
|
token_type: Some("preview".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set");
|
||||||
encode(
|
encode(
|
||||||
&Header::default(),
|
&Header::default(),
|
||||||
&claims,
|
&claims,
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ pub async fn org_extractor_middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTA: El secreto debe venir de una variable de entorno en producción.
|
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set");
|
||||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
|
||||||
|
|
||||||
let claims = decode::<Claims>(
|
let claims = decode::<Claims>(
|
||||||
&token,
|
&token,
|
||||||
|
|||||||
@@ -145,8 +145,6 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
}
|
}
|
||||||
return [...prev, res];
|
return [...prev, res];
|
||||||
});
|
});
|
||||||
console.log(`Score for block ${blockId} submitted: ${score}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to submit score for block ${blockId}`, err);
|
console.error(`Failed to submit score for block ${blockId}`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export default function AudioResponsePlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[AudioResponse] Requesting microphone access...');
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
@@ -97,7 +96,6 @@ export default function AudioResponsePlayer({
|
|||||||
sampleRate: 44100
|
sampleRate: 44100
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('[AudioResponse] Microphone access granted');
|
|
||||||
|
|
||||||
const mediaRecorder = new MediaRecorder(stream, {
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
|
||||||
@@ -108,15 +106,12 @@ export default function AudioResponsePlayer({
|
|||||||
mediaRecorder.ondataavailable = (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data.size > 0) {
|
if (event.data.size > 0) {
|
||||||
audioChunksRef.current.push(event.data);
|
audioChunksRef.current.push(event.data);
|
||||||
console.log('[AudioResponse] Data available, chunk size:', event.data.size);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
console.log('[AudioResponse] Recording stopped, creating blob...');
|
|
||||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||||
setAudioBlob(audioBlob);
|
setAudioBlob(audioBlob);
|
||||||
console.log('[AudioResponse] Blob created, size:', audioBlob.size, 'bytes');
|
|
||||||
stream.getTracks().forEach(track => track.stop());
|
stream.getTracks().forEach(track => track.stop());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,14 +123,12 @@ export default function AudioResponsePlayer({
|
|||||||
mediaRecorder.start();
|
mediaRecorder.start();
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setRecordingTime(0);
|
setRecordingTime(0);
|
||||||
console.log('[AudioResponse] Recording started');
|
|
||||||
|
|
||||||
// Start speech recognition
|
// Start speech recognition
|
||||||
if (!isGraded && recognitionRef.current) {
|
if (!isGraded && recognitionRef.current) {
|
||||||
setTranscript("");
|
setTranscript("");
|
||||||
try {
|
try {
|
||||||
recognitionRef.current.start();
|
recognitionRef.current.start();
|
||||||
console.log('[AudioResponse] Speech recognition started');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[AudioResponse] Could not start speech recognition:', err);
|
console.warn('[AudioResponse] Could not start speech recognition:', err);
|
||||||
}
|
}
|
||||||
@@ -146,7 +139,6 @@ export default function AudioResponsePlayer({
|
|||||||
setRecordingTime(prev => {
|
setRecordingTime(prev => {
|
||||||
const newTime = prev + 1;
|
const newTime = prev + 1;
|
||||||
if (timeLimit && newTime >= timeLimit) {
|
if (timeLimit && newTime >= timeLimit) {
|
||||||
console.log('[AudioResponse] Time limit reached, stopping...');
|
|
||||||
stopRecording();
|
stopRecording();
|
||||||
}
|
}
|
||||||
return newTime;
|
return newTime;
|
||||||
|
|||||||
@@ -256,7 +256,6 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
|
|||||||
setFeedback({ isCorrect });
|
setFeedback({ isCorrect });
|
||||||
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
|
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
|
||||||
// Save answer to backend (mocked for now)
|
// Save answer to backend (mocked for now)
|
||||||
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
|
|||||||
@@ -133,10 +133,7 @@ export default function GlobalAiControl() {
|
|||||||
startDate.setDate(startDate.getDate() - 90);
|
startDate.setDate(startDate.getDate() - 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading AI usage from', startDate.toISOString().split('T')[0], 'to', endDate.toISOString().split('T')[0]);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('studio_token');
|
const token = localStorage.getItem('studio_token');
|
||||||
console.log('Token from localStorage:', token ? 'EXISTS' : 'NULL');
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error('No token found. Please login again.');
|
console.error('No token found. Please login again.');
|
||||||
@@ -150,7 +147,6 @@ export default function GlobalAiControl() {
|
|||||||
endDate.toISOString().split('T')[0]
|
endDate.toISOString().split('T')[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Data loaded:', jsonData);
|
|
||||||
setData(jsonData);
|
setData(jsonData);
|
||||||
setAuthError(false);
|
setAuthError(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export default function AdminTokenTracking() {
|
|||||||
const loadTokenUsage = async () => {
|
const loadTokenUsage = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('studio_token');
|
const token = localStorage.getItem('studio_token');
|
||||||
console.log('[TokenUsage] Token from localStorage:', token ? 'Present (studio_token)' : 'Missing');
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error('[TokenUsage] No authentication token found!');
|
console.error('[TokenUsage] No authentication token found!');
|
||||||
@@ -71,8 +70,6 @@ export default function AdminTokenTracking() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[TokenUsage] API Response status:', response.status);
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.error('[TokenUsage] Unauthorized - Token may be expired');
|
console.error('[TokenUsage] Unauthorized - Token may be expired');
|
||||||
alert('Session expired. Please login again.');
|
alert('Session expired. Please login again.');
|
||||||
|
|||||||
@@ -28,26 +28,20 @@ export default function MemoryBlock({ id, title, pairs = [], editMode, onChange
|
|||||||
left: "",
|
left: "",
|
||||||
right: ""
|
right: ""
|
||||||
};
|
};
|
||||||
console.log('[MemoryBlock] Adding new pair:', newPair);
|
|
||||||
onChange({ pairs: [...pairs, newPair] });
|
onChange({ pairs: [...pairs, newPair] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
|
const updatePair = (index: number, updates: Partial<MatchingPair>) => {
|
||||||
const newPairs = [...pairs];
|
const newPairs = [...pairs];
|
||||||
newPairs[index] = { ...newPairs[index], ...updates };
|
newPairs[index] = { ...newPairs[index], ...updates };
|
||||||
console.log('[MemoryBlock] Updating pair at index', index, ':', updates);
|
|
||||||
onChange({ pairs: newPairs });
|
onChange({ pairs: newPairs });
|
||||||
};
|
};
|
||||||
|
|
||||||
const removePair = (index: number) => {
|
const removePair = (index: number) => {
|
||||||
console.log('[MemoryBlock] Removing pair at index', index);
|
|
||||||
const newPairs = pairs.filter((_, i) => i !== index);
|
const newPairs = pairs.filter((_, i) => i !== index);
|
||||||
onChange({ pairs: newPairs });
|
onChange({ pairs: newPairs });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug: Log pairs on render
|
|
||||||
console.log('[MemoryBlock] Render with pairs:', pairs);
|
|
||||||
|
|
||||||
if (!editMode) {
|
if (!editMode) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8" id={id}>
|
<div className="space-y-8" id={id}>
|
||||||
|
|||||||
Reference in New Issue
Block a user