Merge branch 'master' into feat-cibersecurity

This commit is contained in:
2025-11-17 11:49:18 -03:00
committed by GitHub
26 changed files with 3516 additions and 3322 deletions

View File

@@ -1,65 +1,70 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import {
ExternalLink,
FileText,
File as FileIcon,
Upload,
Trash2,
Eye,
RefreshCw,
} from "lucide-svelte";
import { useConvexClient } from 'convex-svelte';
import { resolve } from '$app/paths';
import {
ExternalLink,
FileText,
File as FileIcon,
Upload,
Trash2,
Eye,
RefreshCw
} from 'lucide-svelte';
interface Props {
label: string;
helpUrl?: string;
value?: string; // storageId
disabled?: boolean;
required?: boolean;
onUpload: (file: globalThis.File) => Promise<void>;
onRemove: () => Promise<void>;
}
interface Props {
label: string;
helpUrl?: string;
value?: string; // storageId
disabled?: boolean;
required?: boolean;
onUpload: (file: globalThis.File) => Promise<void>;
onRemove: () => Promise<void>;
}
let {
label,
helpUrl,
value = $bindable(),
disabled = false,
required = false,
onUpload,
onRemove,
}: Props = $props();
let {
label,
helpUrl,
value = $bindable(),
disabled = false,
required = false,
onUpload,
onRemove
}: Props = $props();
const client = useConvexClient();
const client = useConvexClient() as unknown as {
storage: {
getUrl: (id: string) => Promise<string | null>;
};
};
let fileInput: HTMLInputElement | null = null;
let uploading = $state(false);
let error = $state<string | null>(null);
let fileName = $state<string>("");
let fileType = $state<string>("");
let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null);
let fileInput: HTMLInputElement | null = null;
let uploading = $state(false);
let error = $state<string | null>(null);
let fileName = $state<string>('');
let fileType = $state<string>('');
let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
// Buscar URL do arquivo quando houver um storageId
$effect(() => {
if (!value || fileName) {
return;
if (value && !fileName) {
// Tem storageId mas não é um upload recente
void loadExistingFile(value);
}
let cancelled = false;
const storageId = value;
if (!storageId) {
return;
}
(async () => {
try {
const url = await client.storage.getUrl(storageId as any);
const url = await client.storage.getUrl(storageId);
if (!url || cancelled) {
return;
}
@@ -93,126 +98,161 @@
};
});
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
async function loadExistingFile(storageId: string) {
try {
const url = await client.storage.getUrl(storageId);
if (url) {
fileUrl = url;
error = null;
// Detectar tipo pelo URL ou assumir PDF
if (url.includes('.pdf') || url.includes('application/pdf')) {
fileType = 'application/pdf';
} else {
fileType = 'image/jpeg';
// Para imagens, a URL serve como preview
previewUrl = url;
}
}
} catch (err) {
console.error('Erro ao carregar arquivo existente:', err);
}
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
error = "Arquivo muito grande. Tamanho máximo: 10MB";
target.value = "";
return;
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)";
target.value = "";
return;
}
if (!file) {
return;
}
try {
uploading = true;
fileName = file.name;
fileType = file.type;
error = null;
// Create preview for images
if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) => {
previewUrl = e.target?.result as string;
};
reader.readAsDataURL(file);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
error = 'Arquivo muito grande. Tamanho máximo: 10MB';
target.value = '';
return;
}
await onUpload(file);
} catch (err: any) {
error = err?.message || "Erro ao fazer upload do arquivo";
previewUrl = null;
} finally {
uploading = false;
target.value = "";
}
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
target.value = '';
return;
}
async function handleRemove() {
if (!confirm("Tem certeza que deseja remover este arquivo?")) {
return;
}
try {
uploading = true;
fileName = file.name;
fileType = file.type;
try {
uploading = true;
await onRemove();
fileName = "";
fileType = "";
previewUrl = null;
fileUrl = null;
} catch (err: any) {
error = err?.message || "Erro ao remover arquivo";
} finally {
uploading = false;
}
}
// Create preview for images
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
previewUrl = result;
}
};
reader.readAsDataURL(file);
} else {
previewUrl = null;
}
function handleView() {
if (fileUrl) {
window.open(fileUrl, "_blank");
}
}
await onUpload(file);
} catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao fazer upload do arquivo';
} else {
error = 'Erro ao fazer upload do arquivo';
}
previewUrl = null;
} finally {
uploading = false;
target.value = '';
}
}
function openFileDialog() {
fileInput?.click();
}
async function handleRemove() {
if (!confirm('Tem certeza que deseja remover este arquivo?')) {
return;
}
function setFileInput(node: HTMLInputElement) {
fileInput = node;
return {
destroy() {
if (fileInput === node) {
fileInput = null;
}
},
};
}
try {
uploading = true;
await onRemove();
fileName = '';
fileType = '';
previewUrl = null;
fileUrl = null;
error = null;
} catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao remover arquivo';
} else {
error = 'Erro ao remover arquivo';
}
} finally {
uploading = false;
}
}
function handleView() {
if (fileUrl) {
window.open(fileUrl, '_blank');
}
}
function openFileDialog() {
fileInput?.click();
}
function setFileInput(node: HTMLInputElement) {
fileInput = node;
return {
destroy() {
if (fileInput === node) {
fileInput = null;
}
}
};
}
</script>
<div class="form-control w-full">
<label class="label" for="file-upload-input">
<span class="label-text font-medium flex items-center gap-2">
{label}
{#if required}
<span class="text-error">*</span>
{/if}
{#if helpUrl}
<div
class="tooltip tooltip-right"
data-tip="Clique para acessar o link"
>
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-focus transition-colors"
aria-label="Acessar link"
>
<ExternalLink class="h-4 w-4" strokeWidth={2} />
</a>
</div>
{/if}
</span>
</label>
<label class="label" for="file-upload-input">
<span class="label-text flex items-center gap-2 font-medium">
{label}
{#if required}
<span class="text-error">*</span>
{/if}
{#if helpUrl}
<div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
<a
href={helpUrl ?? '/'}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-focus transition-colors"
aria-label="Acessar link"
>
<ExternalLink class="h-4 w-4" strokeWidth={2} />
</a>
</div>
{/if}
</span>
</label>
<input
id="file-upload-input"
type="file"
use:setFileInput
onchange={handleFileSelect}
accept=".pdf,.jpg,.jpeg,.png"
class="hidden"
{disabled}
/>
<input
id="file-upload-input"
type="file"
use:setFileInput
onchange={handleFileSelect}
accept=".pdf,.jpg,.jpeg,.png"
class="hidden"
{disabled}
/>
{#if value || fileName}
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
@@ -231,73 +271,73 @@
{/if}
</div>
<!-- File info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{fileName || "Arquivo anexado"}
</p>
<p class="text-xs text-base-content/60">
{#if uploading}
Carregando...
{:else}
Enviado com sucesso
{/if}
</p>
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{fileName || 'Arquivo anexado'}
</p>
<p class="text-base-content/60 text-xs">
{#if uploading}
Carregando...
{:else}
Enviado com sucesso
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex gap-2">
{#if fileUrl}
<button
type="button"
onclick={handleView}
class="btn btn-sm btn-ghost text-info"
disabled={uploading || disabled}
title="Visualizar arquivo"
>
<Eye class="h-4 w-4" strokeWidth={2} />
</button>
{/if}
<button
type="button"
onclick={openFileDialog}
class="btn btn-sm btn-ghost"
disabled={uploading || disabled}
title="Substituir arquivo"
>
<RefreshCw class="h-4 w-4" strokeWidth={2} />
</button>
<button
type="button"
onclick={handleRemove}
class="btn btn-sm btn-ghost text-error"
disabled={uploading || disabled}
title="Remover arquivo"
>
<Trash2 class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={openFileDialog}
class="btn btn-outline btn-block justify-start gap-2"
disabled={uploading || disabled}
>
{#if uploading}
<span class="loading loading-spinner loading-sm"></span>
Carregando...
{:else}
<Upload class="h-5 w-5" strokeWidth={2} />
Selecionar arquivo (PDF ou imagem, máx. 10MB)
{/if}
</button>
{/if}
<!-- Actions -->
<div class="flex gap-2">
{#if fileUrl}
<button
type="button"
onclick={handleView}
class="btn btn-sm btn-ghost text-info"
disabled={uploading || disabled}
title="Visualizar arquivo"
>
<Eye class="h-4 w-4" strokeWidth={2} />
</button>
{/if}
<button
type="button"
onclick={openFileDialog}
class="btn btn-sm btn-ghost"
disabled={uploading || disabled}
title="Substituir arquivo"
>
<RefreshCw class="h-4 w-4" strokeWidth={2} />
</button>
<button
type="button"
onclick={handleRemove}
class="btn btn-sm btn-ghost text-error"
disabled={uploading || disabled}
title="Remover arquivo"
>
<Trash2 class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={openFileDialog}
class="btn btn-outline btn-block justify-start gap-2"
disabled={uploading || disabled}
>
{#if uploading}
<span class="loading loading-spinner loading-sm"></span>
Carregando...
{:else}
<Upload class="h-5 w-5" strokeWidth={2} />
Selecionar arquivo (PDF ou imagem, máx. 10MB)
{/if}
</button>
{/if}
{#if error}
<div class="label">
<span class="label-text-alt text-error">{error}</span>
</div>
{/if}
{#if error}
<div class="label">
<span class="label-text-alt text-error">{error}</span>
</div>
{/if}
</div>

View File

@@ -1,80 +1,77 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte";
import { page } from "$app/stores";
import type { Snippet } from "svelte";
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = "/",
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
let {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = '/'
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
let isChecking = $state(true);
let hasAccess = $state(false);
const currentUser = useQuery(api.auth.getCurrentUser, {});
let isChecking = $state(true);
let hasAccess = $state(false);
const currentUser = useQuery(api.auth.getCurrentUser, {});
onMount(() => {
checkAccess();
});
onMount(() => {
checkAccess();
});
function checkAccess() {
isChecking = true;
function checkAccess() {
isChecking = true;
// Aguardar um pouco para o authStore carregar do localStorage
setTimeout(() => {
// Verificar autenticação
if (requireAuth && !currentUser?.data) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
return;
}
// Aguardar um pouco para o authStore carregar do localStorage
setTimeout(() => {
// Verificar autenticação
if (requireAuth && !currentUser?.data) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
return;
}
// Verificar roles
if (allowedRoles.length > 0 && currentUser?.data) {
const hasRole = allowedRoles.includes(
currentUser.data.role?.nome ?? "",
);
if (!hasRole) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
}
// Verificar roles
if (allowedRoles.length > 0 && currentUser?.data) {
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
if (!hasRole) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
}
// Verificar nível
if (
currentUser?.data &&
currentUser.data.role?.nivel &&
currentUser.data.role.nivel > maxLevel
) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
// Verificar nível
if (
currentUser?.data &&
currentUser.data.role?.nivel &&
currentUser.data.role.nivel > maxLevel
) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
hasAccess = true;
isChecking = false;
}, 100);
}
hasAccess = true;
isChecking = false;
}, 100);
}
</script>
{#if isChecking}
<div class="flex justify-center items-center min-h-screen">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
</div>
</div>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
</div>
</div>
{:else if hasAccess}
{@render children()}
{@render children()}
{/if}