Permisos en progreso
This commit is contained in:
@@ -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();
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user