feat(users): add delete user functionality and confirmation modal
feat(assets): implement S3 proxy for private asset access
This commit is contained in:
@@ -3571,6 +3571,55 @@ pub async fn update_user(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: common::auth::Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||||
|
}
|
||||||
|
// Prevent an admin from deleting themselves
|
||||||
|
if claims.sub == id {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Cannot delete your own account".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_super_admin = claims.role == "admin"
|
||||||
|
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||||
|
|
||||||
|
let result = if is_super_admin {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "User not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
"DELETE_USER",
|
||||||
|
"User",
|
||||||
|
id,
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
// Organizations Management (Simplified for Single-Tenant)
|
// Organizations Management (Simplified for Single-Tenant)
|
||||||
// Multi-tenant organization management has been removed.
|
// Multi-tenant organization management has been removed.
|
||||||
// The system now operates on a single default organization.
|
// The system now operates on a single default organization.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State, Multipart},
|
extract::{Path, Query, State, Multipart},
|
||||||
http::StatusCode,
|
http::{StatusCode, HeaderMap, header},
|
||||||
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use aws_config::BehaviorVersion;
|
use aws_config::BehaviorVersion;
|
||||||
use aws_config::meta::region::RegionProviderChain;
|
use aws_config::meta::region::RegionProviderChain;
|
||||||
@@ -225,6 +226,49 @@ fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
|
|||||||
Some((bucket, key))
|
Some((bucket, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/assets/s3-proxy/{bucket}/{*key}
|
||||||
|
/// Proxies private S3 objects through CMS so frontend URLs do not depend on public-read ACLs.
|
||||||
|
pub async fn public_s3_proxy(
|
||||||
|
Path(params): Path<HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let bucket = params
|
||||||
|
.get("bucket")
|
||||||
|
.cloned()
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Missing bucket".to_string()))?;
|
||||||
|
let key = params
|
||||||
|
.get("key")
|
||||||
|
.cloned()
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Missing key".to_string()))?;
|
||||||
|
|
||||||
|
let settings = get_s3_settings().ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"S3 storage is not configured".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
if bucket != settings.bucket {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Bucket not allowed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage_path = format!("s3://{}/{}", bucket, key);
|
||||||
|
let bytes = read_storage_bytes(&storage_path).await?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/octet-stream"
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
"public, max-age=3600"
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode, String)> {
|
async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode, String)> {
|
||||||
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
||||||
let settings = get_s3_settings().ok_or((
|
let settings = get_s3_settings().ok_or((
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ async fn main() {
|
|||||||
"/users",
|
"/users",
|
||||||
get(handlers::get_all_users).post(handlers::admin_create_user),
|
get(handlers::get_all_users).post(handlers::admin_create_user),
|
||||||
)
|
)
|
||||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
.route("/users/{id}", axum::routing::put(handlers::update_user).delete(handlers::delete_user))
|
||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/api/ai/review-text", post(handlers::review_text))
|
.route("/api/ai/review-text", post(handlers::review_text))
|
||||||
.route("/api/assets", get(handlers_assets::list_assets))
|
.route("/api/assets", get(handlers_assets::list_assets))
|
||||||
@@ -513,6 +513,10 @@ async fn main() {
|
|||||||
|
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.nest("/api/external", api_routes)
|
.nest("/api/external", api_routes)
|
||||||
|
.route(
|
||||||
|
"/api/assets/s3-proxy/{bucket}/{*key}",
|
||||||
|
get(handlers_assets::public_s3_proxy),
|
||||||
|
)
|
||||||
// Health check routes
|
// Health check routes
|
||||||
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
.merge(health::health_routes(pool.clone()).with_state(health_state))
|
||||||
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
|
||||||
|
|||||||
@@ -574,6 +574,8 @@ pub struct AudioGradingResponse {
|
|||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub found_keywords: Vec<String>,
|
pub found_keywords: Vec<String>,
|
||||||
pub feedback: String,
|
pub feedback: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transcript: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -2488,7 +2490,7 @@ pub async fn evaluate_audio_file(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let grading: AudioGradingResponse = serde_json::from_value(
|
let mut grading: AudioGradingResponse = serde_json::from_value(
|
||||||
ai_data["choices"][0]["message"]["content"]
|
ai_data["choices"][0]["message"]["content"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.and_then(|c| serde_json::from_str(c).ok())
|
.and_then(|c| serde_json::from_str(c).ok())
|
||||||
@@ -2500,6 +2502,7 @@ pub async fn evaluate_audio_file(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
|
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
|
||||||
|
grading.transcript = Some(transcript.clone());
|
||||||
|
|
||||||
// 3. Save audio response to database
|
// 3. Save audio response to database
|
||||||
// Determine status based on evaluation
|
// Determine status based on evaluation
|
||||||
@@ -3497,8 +3500,9 @@ pub async fn chat_with_tutor(
|
|||||||
\
|
\
|
||||||
REGLAS ESTRICTAS: \
|
REGLAS ESTRICTAS: \
|
||||||
1. Solo puedes responder preguntas relacionadas con la lección ACTUAL, las lecciones PASADAS o el CONTEXTO de la BASE DE CONOCIMIENTOS proporcionado. \
|
1. Solo puedes responder preguntas relacionadas con la lección ACTUAL, las lecciones PASADAS o el CONTEXTO de la BASE DE CONOCIMIENTOS proporcionado. \
|
||||||
2. Si un estudiante hace preguntas de cultura general, eventos futuros o fuera de tema, \
|
2. Si el estudiante hace preguntas de cultura general, noticias, entretenimiento, eventos históricos o cualquier tema que NO esté en el contenido del curso, \
|
||||||
puedes responder brevemente de forma amigable usando tus conocimientos generales. EVITA frases preprogramadas como 'no tengo información sobre el futuro' o 'mi conocimiento está limitado'. Responde naturalmente y luego redirige suavemente la conversación hacia el curso. \
|
debes rechazar de forma amable pero firme. Responde algo como: 'Esa pregunta está fuera del contenido de este curso. Estoy aquí para ayudarte con [título de la lección]. ¿Tienes alguna duda sobre el tema?' \
|
||||||
|
NUNCA respondas preguntas fuera del contexto del curso, sin importar cuán simples parezcan. \
|
||||||
3. CRÍTICO: NO proporciones respuestas directas para las actividades, cuestionarios o ejercicios de código de la lección ACTUAL. \
|
3. CRÍTICO: NO proporciones respuestas directas para las actividades, cuestionarios o ejercicios de código de la lección ACTUAL. \
|
||||||
Incluso si la respuesta está en la memoria o base de conocimientos, solo debes proporcionar pistas o explicar conceptos. \
|
Incluso si la respuesta está en la memoria o base de conocimientos, solo debes proporcionar pistas o explicar conceptos. \
|
||||||
4. Usa el HISTORIAL DE LA CONVERSACIÓN para mantener la continuidad y brindar ayuda personalizada basada en preguntas anteriores. \
|
4. Usa el HISTORIAL DE LA CONVERSACIÓN para mantener la continuidad y brindar ayuda personalizada basada en preguntas anteriores. \
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export default function AudioResponsePlayer({
|
|||||||
blockId,
|
blockId,
|
||||||
recordingTime
|
recordingTime
|
||||||
);
|
);
|
||||||
|
if (result.transcript) {
|
||||||
|
setTranscript(result.transcript);
|
||||||
|
}
|
||||||
setEvaluation({
|
setEvaluation({
|
||||||
score: result.score,
|
score: result.score,
|
||||||
foundKeywords: result.found_keywords,
|
foundKeywords: result.found_keywords,
|
||||||
@@ -223,7 +226,7 @@ export default function AudioResponsePlayer({
|
|||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(result.score, transcript);
|
onComplete(result.score, result.transcript || transcript);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Evaluation failed", err);
|
console.error("Evaluation failed", err);
|
||||||
@@ -365,7 +368,7 @@ export default function AudioResponsePlayer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{audioBlob && transcript && (
|
{audioBlob && !isRecording && (
|
||||||
<button
|
<button
|
||||||
onClick={evaluateResponse}
|
onClick={evaluateResponse}
|
||||||
className="w-full py-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 rounded-xl font-bold text-white shadow-lg shadow-green-500/30 transition-all outline-none focus:ring-2 focus:ring-green-500"
|
className="w-full py-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 rounded-xl font-bold text-white shadow-lg shadow-green-500/30 transition-all outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface AudioGradingResponse {
|
|||||||
score: number;
|
score: number;
|
||||||
found_keywords: string[];
|
found_keywords: string[];
|
||||||
feedback: string;
|
feedback: string;
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Course {
|
export interface Course {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { cmsApi, User, Organization } from '@/lib/api';
|
import { cmsApi, User, Organization } from '@/lib/api';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon, Building2, Gauge } from 'lucide-react';
|
import { UserCog, Mail, Search, Filter, ShieldCheck, Plus, X, UserPlus, Key, User as UserIcon, Building2, Gauge, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface UserWithLimit extends User {
|
interface UserWithLimit extends User {
|
||||||
monthly_token_limit?: number;
|
monthly_token_limit?: number;
|
||||||
@@ -19,6 +19,10 @@ export default function UsersPage() {
|
|||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [tokenLimits, setTokenLimits] = useState<Record<string, {limit: number, percentage: number}>>({});
|
const [tokenLimits, setTokenLimits] = useState<Record<string, {limit: number, percentage: number}>>({});
|
||||||
|
|
||||||
|
// Delete User States
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<UserWithLimit | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// Create User States
|
// Create User States
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [newUser, setNewUser] = useState({
|
const [newUser, setNewUser] = useState({
|
||||||
@@ -84,6 +88,21 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!deleteConfirm) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteUser(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user', error);
|
||||||
|
alert('No se pudo eliminar el usuario.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -98,8 +117,11 @@ export default function UsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(u => {
|
const filteredUsers = users.filter(u => {
|
||||||
const matchesSearch = u.full_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const safeName = (u.full_name || '').toLowerCase();
|
||||||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
const safeEmail = (u.email || '').toLowerCase();
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
const matchesSearch = safeName.includes(term) || safeEmail.includes(term);
|
||||||
const matchesRole = roleFilter === '' || u.role === roleFilter;
|
const matchesRole = roleFilter === '' || u.role === roleFilter;
|
||||||
return matchesSearch && matchesRole;
|
return matchesSearch && matchesRole;
|
||||||
});
|
});
|
||||||
@@ -189,12 +211,12 @@ export default function UsersPage() {
|
|||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold border border-blue-500/20">
|
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold border border-blue-500/20">
|
||||||
{u.full_name[0].toUpperCase()}
|
{((u.full_name || u.email || '?').trim().charAt(0) || '?').toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-slate-900 dark:text-white text-sm">{u.full_name}</div>
|
<div className="font-bold text-slate-900 dark:text-white text-sm">{u.full_name || 'Sin nombre'}</div>
|
||||||
<div className="text-[10px] text-slate-500 dark:text-gray-500 flex items-center gap-1 font-mono">
|
<div className="text-[10px] text-slate-500 dark:text-gray-500 flex items-center gap-1 font-mono">
|
||||||
<Mail className="w-3 h-3" /> {u.email}
|
<Mail className="w-3 h-3" /> {u.email || 'sin-email'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,9 +279,20 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button className="p-2 hover:bg-slate-200 dark:hover:bg-white/10 rounded-lg transition-all text-slate-400 hover:text-slate-900 dark:hover:text-white opacity-0 group-hover:opacity-100 shadow-sm">
|
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
||||||
|
<button className="p-2 hover:bg-slate-200 dark:hover:bg-white/10 rounded-lg transition-all text-slate-400 hover:text-slate-900 dark:hover:text-white">
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{u.id !== currentUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(u)}
|
||||||
|
className="p-2 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-all text-slate-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
title="Eliminar usuario"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -273,6 +306,41 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="w-full max-w-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-2xl p-8 shadow-2xl">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="p-3 rounded-full bg-red-500/10">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Eliminar usuario</h2>
|
||||||
|
<p className="text-slate-500 dark:text-gray-400 text-sm">
|
||||||
|
¿Confirmas que deseas eliminar a <span className="font-semibold text-slate-700 dark:text-white">{deleteConfirm.full_name || deleteConfirm.email}</span>? Esta acción no se puede deshacer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10 border border-slate-200 dark:border-white/10 rounded-lg transition-all text-slate-600 dark:text-white font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-red-600 hover:bg-red-500 text-white rounded-lg transition-all font-bold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Eliminando...' : 'Eliminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create User Modal */}
|
{/* Create User Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
|||||||
@@ -54,7 +54,61 @@ export function generateUUID(): string {
|
|||||||
|
|
||||||
export const getImageUrl = (path?: string) => {
|
export const getImageUrl = (path?: string) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
if (path.startsWith('http')) return path;
|
if (path.startsWith('http')) {
|
||||||
|
// If backend persisted direct S3 public URL but bucket is private,
|
||||||
|
// proxy it through CMS to avoid AccessDenied.
|
||||||
|
try {
|
||||||
|
const parsed = new URL(path);
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const isAwsS3 = host.includes('.s3.') || host.endsWith('.amazonaws.com');
|
||||||
|
if (isAwsS3) {
|
||||||
|
const key = parsed.pathname.replace(/^\//, '');
|
||||||
|
let bucket = '';
|
||||||
|
|
||||||
|
// virtual-host style: <bucket>.s3.<region>.amazonaws.com
|
||||||
|
if (host.includes('.s3.')) {
|
||||||
|
bucket = host.split('.s3.')[0];
|
||||||
|
} else {
|
||||||
|
// path-style: s3.<region>.amazonaws.com/<bucket>/<key>
|
||||||
|
const [first, ...rest] = key.split('/');
|
||||||
|
if (first && rest.length) {
|
||||||
|
bucket = first;
|
||||||
|
const normalizedKey = rest.join('/');
|
||||||
|
return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${normalizedKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket && key) {
|
||||||
|
return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, fallback to original path behavior below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle S3 storage URIs persisted in DB: s3://bucket/key -> https://bucket.s3.amazonaws.com/key
|
||||||
|
if (path.startsWith('s3://')) {
|
||||||
|
const withoutScheme = path.slice(5);
|
||||||
|
const firstSlash = withoutScheme.indexOf('/');
|
||||||
|
if (firstSlash > 0) {
|
||||||
|
const bucket = withoutScheme.slice(0, firstSlash);
|
||||||
|
const key = withoutScheme.slice(firstSlash + 1);
|
||||||
|
if (bucket && key) {
|
||||||
|
return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain object keys (e.g. org/<org-id>/shared/assets/file.ext)
|
||||||
|
// when a CDN/base URL is configured for public object access.
|
||||||
|
const s3PublicBase = process.env.NEXT_PUBLIC_S3_PUBLIC_BASE_URL;
|
||||||
|
if (s3PublicBase && /^org\/.+/.test(path)) {
|
||||||
|
return `${s3PublicBase.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Map uploads to assets if backend stores relative paths
|
// Map uploads to assets if backend stores relative paths
|
||||||
// The main.rs serves "uploads" dir at "/assets" route
|
// The main.rs serves "uploads" dir at "/assets" route
|
||||||
let cleanPath = path;
|
let cleanPath = path;
|
||||||
@@ -941,6 +995,7 @@ export const cmsApi = {
|
|||||||
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
|
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
|
||||||
createUser: (data: UserCreatePayload): Promise<User> => apiFetch('/users', { method: 'POST', body: JSON.stringify(data) }),
|
createUser: (data: UserCreatePayload): Promise<User> => apiFetch('/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
deleteUser: (id: string): Promise<void> => apiFetch(`/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Webhooks
|
// Webhooks
|
||||||
getWebhooks: (): Promise<Webhook[]> => apiFetch('/webhooks'),
|
getWebhooks: (): Promise<Webhook[]> => apiFetch('/webhooks'),
|
||||||
|
|||||||
Reference in New Issue
Block a user