Permisos en progreso

This commit is contained in:
2026-03-17 13:49:23 -06:00
parent b67a384923
commit 80d41c9487
9 changed files with 913 additions and 507 deletions

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { tickets } from '$lib/stores/tickets';
import { toast } from '$lib/stores/toast';
export let ticketId: string;
const dispatch = createEventDispatcher();
const PRIORITIES = [
{ value: 'LOW', label: 'Baja' },
{ value: 'MEDIUM', label: 'Media' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
let content = '';
let priority = 'MEDIUM';
let file: File | null = null;
let isSubmitting = false;
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
file = input.files?.[0] ?? null;
}
async function handleSubmit() {
if (!content.trim()) {
toast.error('El contenido del asunto es obligatorio');
return;
}
isSubmitting = true;
try {
await tickets.createIssue(ticketId, {
content: content.trim(),
priority,
tagged_user_ids: [],
file
});
toast.success('Asunto creado correctamente');
dispatch('created');
dispatch('close');
} catch (e: any) {
toast.error(e.message || 'Error al crear el asunto');
} finally {
isSubmitting = false;
}
}
function handleClose() {
dispatch('close');
}
</script>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75"
on:click={handleClose}
role="button"
tabindex="-1"
on:keydown={(e) => e.key === 'Escape' && handleClose()}
/>
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg z-10">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Crear Asunto</h3>
<button type="button" on:click={handleClose} class="text-gray-400 hover:text-gray-500">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-6 py-5 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Descripción <span class="text-red-500">*</span>
</label>
<textarea
rows="4"
bind:value={content}
disabled={isSubmitting}
placeholder="Describe el asunto de escalación..."
class="form-input w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prioridad</label>
<select bind:value={priority} disabled={isSubmitting} class="form-input w-full">
{#each PRIORITIES as p}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Adjunto (opcional)
</label>
<input
type="file"
on:change={handleFileChange}
disabled={isSubmitting}
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4 file:rounded-md
file:border-0 file:text-sm file:font-medium
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100 disabled:opacity-50"
/>
{#if file}
<p class="text-xs text-gray-500 mt-1">{file.name}</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
on:click={handleClose}
disabled={isSubmitting}
class="btn-secondary px-4 py-2"
>
Cancelar
</button>
<button
type="button"
on:click={handleSubmit}
disabled={isSubmitting || !content.trim()}
class="btn-primary px-4 py-2 disabled:opacity-50"
>
{#if isSubmitting}
<div class="flex items-center gap-2">
<div class="spinner w-4 h-4" />
<span>Creando...</span>
</div>
{:else}
Crear Asunto
{/if}
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
@@ -11,50 +12,68 @@ export interface User {
is_two_factor_enabled: boolean;
created_at: string;
}
export interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
email: string;
password: string;
tenant_slug: string;
totp_code?: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false
};
function createAuthStore() {
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Verifica sesión activa al cargar la app (cookie HttpOnly)
init: async () => {
if (typeof window !== 'undefined') {
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client', 'X-Tenant-Slug': 'aduanasoft' }
});
if (response.ok) {
const user = await response.json();
set({ user, token: null, isAuthenticated: true, isLoading: false });
if (typeof window === 'undefined') return;
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: {
'X-App': 'client',
// Sin X-Tenant-ID aquí — /auth/me lee el tenant del JWT directamente
}
} catch (error) {}
});
if (response.ok) {
const user = await response.json();
// Token es null — la autenticación viaja por cookie HttpOnly
// El store solo necesita el user para la UI
set({ user, token: null, isAuthenticated: true, isLoading: false });
} else {
// Cookie expirada o inválida — limpiar estado
set(initialState);
}
} catch {
set(initialState);
}
},
login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true }));
try {
@@ -72,12 +91,15 @@ function createAuthStore() {
throw new Error(error.detail || 'Login failed');
}
const data: LoginResponse = await response.json();
// Guardamos el token en memoria para requests inmediatos
// Si la página se recarga, init() recupera la sesión desde la cookie
set({ user: data.user, token: data.access_token, isAuthenticated: true, isLoading: false });
} catch (error) {
update(state => ({ ...state, isLoading: false }));
throw error;
}
},
logout: async () => {
try {
const token = _state.token;
@@ -86,7 +108,6 @@ function createAuthStore() {
credentials: 'include',
headers: {
'X-App': 'client',
'X-Tenant-Slug': 'aduanasoft',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
@@ -96,9 +117,11 @@ function createAuthStore() {
window.location.href = '/login';
}
},
updateUser: (user: User) => { update(state => ({ ...state, user })); },
setUser: (user: User) => { set({ user, token: null, isAuthenticated: true, isLoading: false }); },
setLoading: (isLoading: boolean) => { update(state => ({ ...state, isLoading })); }
};
}
export const auth = createAuthStore();

View File

@@ -2,7 +2,6 @@ import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
// Types
interface FastAPIValidationError {
loc: (string | number)[];
msg: string;
@@ -24,6 +23,11 @@ export interface Ticket {
updated_at: string;
due_date: string | null;
resolution: string | null;
first_response_at?: string | null;
resolved_at?: string | null;
sla_response_due?: string | null;
sla_resolution_due?: string | null;
created_by?: string;
}
export interface TicketComment {
@@ -50,6 +54,19 @@ export interface TicketAttachment {
uploaded_at: string;
}
export interface TicketIssue {
id: string;
ticket_id: string;
content: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
created_by: string;
created_by_name: string;
tagged_users: { id: string; full_name: string; email: string }[];
attachment_filename: string | null;
created_at: string;
updated_at: string;
}
export interface CreateTicketRequest {
title: string;
description: string;
@@ -62,33 +79,35 @@ export interface TicketsState {
currentTicket: Ticket | null;
comments: TicketComment[];
attachments: TicketAttachment[];
issues: TicketIssue[];
isLoading: boolean;
error: string | null;
}
// Initial state
const initialState: TicketsState = {
tickets: [],
currentTicket: null,
comments: [],
attachments: [],
issues: [],
isLoading: false,
error: null
};
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.user) {
throw new Error('Not authenticated');
}
// Verificar que el usuario sigue autenticado
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
// Solo agregar Authorization si el token existe en memoria
// Si no está (recarga de página), la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
@@ -102,13 +121,11 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI
if (error.detail) {
if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI
errorMessage = error.detail.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`).join(', ');
errorMessage = error.detail
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else {
@@ -117,34 +134,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
} else {
errorMessage = JSON.stringify(error);
}
} catch (e) {
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
}
// Create tickets store
function createTicketsStore() {
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
return {
subscribe,
// Load user's tickets
loadTickets: async () => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const raw = await apiCall('/tickets/');
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
update(state => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
@@ -152,78 +164,117 @@ function createTicketsStore() {
}
},
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
const [ticketRaw, comments, attachments] = await Promise.all([
const [ticketRaw, comments, attachments, issues] = await Promise.all([
apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`),
apiCall(`/tickets/${ticketId}/attachments`)
apiCall(`/tickets/${ticketId}/attachments`),
apiCall(`/tickets/${ticketId}/issues`)
]);
// El backend devuelve 'subject', el tipo Ticket usa 'title'
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: ticket,
comments,
attachments,
issues,
isLoading: false
}));
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
// Reload only comments silently (for polling)
reloadComments: async (ticketId: string) => {
try {
const comments = await apiCall(`/tickets/${ticketId}/comments`);
update((state: TicketsState) => ({ ...state, comments }));
} catch (error) {
// Silent fail - no mostrar error en polling
console.error('Error reloading comments:', error);
update(state => ({ ...state, comments }));
} catch {
// Silent fail en polling
}
},
createIssue: async (ticketId: string, data: {
content: string;
priority: string;
tagged_user_ids: string[];
file?: File | null;
}) => {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = { 'X-App': 'client' };
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
let body: FormData | string;
if (data.file) {
const formData = new FormData();
formData.append('content', data.content);
formData.append('priority', data.priority);
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
formData.append('file', data.file);
body = formData;
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({
content: data.content,
priority: data.priority,
tagged_user_ids: data.tagged_user_ids
});
}
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
method: 'POST',
credentials: 'include',
headers,
body
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
throw new Error(
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
);
}
const newIssue = await response.json();
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
return newIssue;
},
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
update(state => ({ ...state, isLoading: true, error: null }));
try {
// Mapear campos del frontend al formato del backend
const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title"
subject: ticket.title,
description: ticket.description,
category_id: ticket.category_id,
priority: ticket.priority,
system_id: null // ← Opcional
system_id: null
};
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticketData)
});
update((state: TicketsState) => ({
update(state => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
}));
return newTicket;
} catch (error) {
console.error('Create ticket error:', error);
update((state: TicketsState) => ({
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
@@ -232,22 +283,18 @@ function createTicketsStore() {
}
},
// Add comment to ticket
addComment: async (ticketId: string, content: string) => {
try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST',
body: JSON.stringify({ content })
// is_internal siempre false desde el frontend cliente
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
body: JSON.stringify({ content, is_internal: false })
});
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
update(state => ({ ...state, comments: [...state.comments, comment] }));
return comment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
@@ -255,17 +302,12 @@ function createTicketsStore() {
}
},
// Upload attachment
uploadAttachment: async (ticketId: string, file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
@@ -285,15 +327,10 @@ function createTicketsStore() {
const result = await response.json();
const attachment = result.data || result;
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
return attachment;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
@@ -301,23 +338,20 @@ function createTicketsStore() {
}
},
// Close ticket (client can close their own tickets)
closeTicket: async (ticketId: string, resolution?: string) => {
try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH',
body: JSON.stringify({ resolution })
body: JSON.stringify({ resolution_notes: resolution })
});
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update((state: TicketsState) => ({
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
@@ -325,45 +359,36 @@ function createTicketsStore() {
}
},
// Clear error
clearError: () => {
update((state: TicketsState) => ({ ...state, error: null }));
},
clearError: () => { update(state => ({ ...state, error: null })); },
// Clear current ticket
clearCurrentTicket: () => {
update((state: TicketsState) => ({
update(state => ({
...state,
currentTicket: null,
comments: [],
attachments: []
attachments: [],
issues: []
}));
},
// Download attachment
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.user) {
throw new Error('Not authenticated');
}
if (!authState.user) throw new Error('Not authenticated');
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
credentials: 'include',
headers: dlHeaders
});
const response = await fetch(
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
{ method: 'GET', credentials: 'include', headers: dlHeaders }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
// Crear blob y descargar
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');